Skip to content

Commit

Permalink
Merge pull request #85 from maykinmedia/feature/83-jwt-userinfo-response
Browse files Browse the repository at this point in the history
Handle JWT response data in userinfo endpoint
  • Loading branch information
sergei-maertens authored Feb 7, 2024
2 parents e1543de + e1b6b12 commit 7ffe295
Show file tree
Hide file tree
Showing 7 changed files with 586 additions and 20 deletions.
13 changes: 7 additions & 6 deletions docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,10 @@ chmod o+rwx ./docker/import/

Then open another terminal and run:

```bash
docker-compose exec keycloak ./bin/kc.sh \
export \
--file /opt/keycloak/data/import/test-realm.json \
--realm test
```
```bash
docker-compose exec keycloak \
/opt/keycloak/bin/kc.sh \
export \
--file /opt/keycloak/data/import/test-realm.json \
--realm test
```
87 changes: 78 additions & 9 deletions docker/import/test-realm.json
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@
"attributes" : { }
} ],
"security-admin-console" : [ ],
"test-userinfo-jwt" : [ ],
"admin-cli" : [ ],
"testid" : [ ],
"account-console" : [ ],
Expand Down Expand Up @@ -513,7 +514,9 @@
"publicClient" : true,
"frontchannelLogout" : false,
"protocol" : "openid-connect",
"attributes" : { },
"attributes" : {
"post.logout.redirect.uris" : "+"
},
"authenticationFlowBindingOverrides" : { },
"fullScopeAllowed" : false,
"nodeReRegistrationTimeout" : 0,
Expand All @@ -539,7 +542,9 @@
"publicClient" : false,
"frontchannelLogout" : false,
"protocol" : "openid-connect",
"attributes" : { },
"attributes" : {
"post.logout.redirect.uris" : "+"
},
"authenticationFlowBindingOverrides" : { },
"fullScopeAllowed" : false,
"nodeReRegistrationTimeout" : 0,
Expand All @@ -565,7 +570,9 @@
"publicClient" : false,
"frontchannelLogout" : false,
"protocol" : "openid-connect",
"attributes" : { },
"attributes" : {
"post.logout.redirect.uris" : "+"
},
"authenticationFlowBindingOverrides" : { },
"fullScopeAllowed" : false,
"nodeReRegistrationTimeout" : 0,
Expand Down Expand Up @@ -618,6 +625,51 @@
} ],
"defaultClientScopes" : [ "web-origins", "acr", "roles", "profile", "email" ],
"optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ]
}, {
"id" : "42a22604-c3d9-48a7-9186-e8ef84e05223",
"clientId" : "test-userinfo-jwt",
"name" : "",
"description" : "",
"rootUrl" : "",
"adminUrl" : "",
"baseUrl" : "",
"surrogateAuthRequired" : false,
"enabled" : true,
"alwaysDisplayInConsole" : false,
"clientAuthenticatorType" : "client-secret",
"secret" : "ktGlGUELd1FR7dTXc84L7dJzUTjCtw9S",
"redirectUris" : [ "http://testserver/*", "http://127.0.0.1:8000/*", "http://localhost:8000/*" ],
"webOrigins" : [ "http://127.0.0.1:8000" ],
"notBefore" : 0,
"bearerOnly" : false,
"consentRequired" : false,
"standardFlowEnabled" : true,
"implicitFlowEnabled" : false,
"directAccessGrantsEnabled" : true,
"serviceAccountsEnabled" : false,
"publicClient" : false,
"frontchannelLogout" : true,
"protocol" : "openid-connect",
"attributes" : {
"client.secret.creation.time" : "1707218309",
"user.info.response.signature.alg" : "RS256",
"oauth2.device.authorization.grant.enabled" : "false",
"backchannel.logout.revoke.offline.tokens" : "false",
"use.refresh.tokens" : "true",
"oidc.ciba.grant.enabled" : "false",
"backchannel.logout.session.required" : "true",
"client_credentials.use_refresh_token" : "false",
"tls.client.certificate.bound.access.tokens" : "false",
"require.pushed.authorization.requests" : "false",
"acr.loa.map" : "{}",
"display.on.consent.screen" : "false",
"token.response.type.bearer.lower-case" : "false"
},
"authenticationFlowBindingOverrides" : { },
"fullScopeAllowed" : true,
"nodeReRegistrationTimeout" : -1,
"defaultClientScopes" : [ "web-origins", "kvk", "acr", "roles", "profile", "bsn", "email" ],
"optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ]
}, {
"id" : "adf4ad83-4550-4619-9231-73bd8d700f45",
"clientId" : "testid",
Expand All @@ -644,12 +696,20 @@
"frontchannelLogout" : true,
"protocol" : "openid-connect",
"attributes" : {
"oidc.ciba.grant.enabled" : "false",
"client.secret.creation.time" : "1707141299",
"backchannel.logout.session.required" : "true",
"user.info.response.signature.alg" : "RS256",
"post.logout.redirect.uris" : "+",
"oauth2.device.authorization.grant.enabled" : "false",
"backchannel.logout.revoke.offline.tokens" : "false",
"use.refresh.tokens" : "true",
"oidc.ciba.grant.enabled" : "false",
"backchannel.logout.session.required" : "true",
"client_credentials.use_refresh_token" : "false",
"tls.client.certificate.bound.access.tokens" : "false",
"require.pushed.authorization.requests" : "false",
"acr.loa.map" : "{}",
"display.on.consent.screen" : "false",
"backchannel.logout.revoke.offline.tokens" : "false"
"token.response.type.bearer.lower-case" : "false"
},
"authenticationFlowBindingOverrides" : { },
"fullScopeAllowed" : true,
Expand All @@ -663,6 +723,7 @@
"config" : {
"user.session.note" : "client_id",
"introspection.token.claim" : "true",
"userinfo.token.claim" : "true",
"id.token.claim" : "true",
"access.token.claim" : "true",
"claim.name" : "client_id",
Expand All @@ -677,6 +738,7 @@
"config" : {
"user.session.note" : "clientAddress",
"introspection.token.claim" : "true",
"userinfo.token.claim" : "true",
"id.token.claim" : "true",
"access.token.claim" : "true",
"claim.name" : "clientAddress",
Expand All @@ -691,6 +753,7 @@
"config" : {
"user.session.note" : "clientHost",
"introspection.token.claim" : "true",
"userinfo.token.claim" : "true",
"id.token.claim" : "true",
"access.token.claim" : "true",
"claim.name" : "clientHost",
Expand Down Expand Up @@ -1165,6 +1228,7 @@
"config" : {
"introspection.token.claim" : "true",
"multivalued" : "true",
"userinfo.token.claim" : "true",
"user.attribute" : "foo",
"id.token.claim" : "true",
"access.token.claim" : "true",
Expand Down Expand Up @@ -1205,7 +1269,8 @@
"config" : {
"id.token.claim" : "true",
"introspection.token.claim" : "true",
"access.token.claim" : "true"
"access.token.claim" : "true",
"userinfo.token.claim" : "true"
}
} ]
}, {
Expand Down Expand Up @@ -1299,7 +1364,7 @@
"subType" : "anonymous",
"subComponents" : { },
"config" : {
"allowed-protocol-mapper-types" : [ "saml-role-list-mapper", "saml-user-property-mapper", "oidc-address-mapper", "oidc-sha256-pairwise-sub-mapper", "saml-user-attribute-mapper", "oidc-full-name-mapper", "oidc-usermodel-property-mapper", "oidc-usermodel-attribute-mapper" ]
"allowed-protocol-mapper-types" : [ "oidc-full-name-mapper", "oidc-usermodel-property-mapper", "oidc-usermodel-attribute-mapper", "saml-user-attribute-mapper", "saml-role-list-mapper", "oidc-address-mapper", "saml-user-property-mapper", "oidc-sha256-pairwise-sub-mapper" ]
}
}, {
"id" : "c6b13ddf-1676-4e33-85d7-c778891156b3",
Expand All @@ -1324,7 +1389,7 @@
"subType" : "authenticated",
"subComponents" : { },
"config" : {
"allowed-protocol-mapper-types" : [ "oidc-address-mapper", "saml-role-list-mapper", "oidc-sha256-pairwise-sub-mapper", "oidc-usermodel-attribute-mapper", "saml-user-property-mapper", "oidc-usermodel-property-mapper", "oidc-full-name-mapper", "saml-user-attribute-mapper" ]
"allowed-protocol-mapper-types" : [ "oidc-sha256-pairwise-sub-mapper", "oidc-address-mapper", "saml-role-list-mapper", "saml-user-property-mapper", "saml-user-attribute-mapper", "oidc-usermodel-attribute-mapper", "oidc-usermodel-property-mapper", "oidc-full-name-mapper" ]
}
}, {
"id" : "9557d357-cc12-443e-bba6-a89e89b22c2e",
Expand Down Expand Up @@ -1916,8 +1981,12 @@
"cibaExpiresIn" : "120",
"cibaAuthRequestedUserHint" : "login_hint",
"oauth2DeviceCodeLifespan" : "600",
"clientOfflineSessionMaxLifespan" : "0",
"oauth2DevicePollingInterval" : "5",
"clientSessionIdleTimeout" : "0",
"parRequestUriLifespan" : "60",
"clientSessionMaxLifespan" : "0",
"clientOfflineSessionIdleTimeout" : "0",
"cibaInterval" : "5",
"realmReusableOtpCode" : "false"
},
Expand Down
53 changes: 49 additions & 4 deletions mozilla_django_oidc_db/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,19 @@
from typing import Any, TypeVar, cast

from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from django.contrib.auth.models import AbstractUser, Group
from django.core.exceptions import ObjectDoesNotExist

import requests
from glom import glom
from mozilla_django_oidc.auth import (
OIDCAuthenticationBackend as _OIDCAuthenticationBackend,
)

from .jwt import verify_and_decode_token
from .mixins import GetAttributeMixin, SoloConfigMixin
from .models import OpenIDConnectConfig, UserInformationClaimsSources
from .utils import obfuscate_claims
from .utils import extract_content_type, obfuscate_claims

logger = logging.getLogger(__name__)

Expand All @@ -32,7 +34,9 @@ class OIDCAuthenticationBackend(
sensitive_claim_names = []

def __init__(self, *args, **kwargs):
self.UserModel = get_user_model()
# django-stubs returns AbstractBaseUser, but we depend on properties of
# AbstractUser.
self.UserModel = cast(AbstractUser, get_user_model())

# See: https://github.com/maykinmedia/mozilla-django-oidc-db/issues/30
# `super().__init__` is not called here, because this attempts to initialize
Expand Down Expand Up @@ -74,7 +78,48 @@ def get_userinfo(self, access_token, id_token, payload):
return payload

logger.debug("Retrieving user information from userinfo endpoint")
return super().get_userinfo(access_token, id_token, payload)

# copy of upstream get_userinfo which doesn't support application/jwt yet.
# Overridden to handle application/jwt responses.
# See https://github.com/mozilla/mozilla-django-oidc/issues/517
#
# Specifying the preferred format in the ``Accept`` header does not work with
# Keycloak, as it depends on the client settings.
user_response = requests.get(
self.OIDC_OP_USER_ENDPOINT,
headers={
"Authorization": "Bearer {0}".format(access_token),
},
verify=self.get_settings("OIDC_VERIFY_SSL", True),
timeout=self.get_settings("OIDC_TIMEOUT", None),
proxies=self.get_settings("OIDC_PROXY", None),
)
user_response.raise_for_status()

# From https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse
#
# > The UserInfo Endpoint MUST return a content-type header to indicate which
# > format is being returned.
content_type = extract_content_type(user_response.headers["Content-Type"])
match content_type:
case "application/json":
# the default case of upstream library
return user_response.json()
case "application/jwt":
token = user_response.content
# get the key from the configured keys endpoint
# XXX: tested with asymmetric encryption. algorithms like HS256 rely on
# out-of-band key exchange and are currently not supported until such a
# case arrives.
key = self.retrieve_matching_jwk(token)
payload = verify_and_decode_token(token, key)
return payload
case _:
raise ValueError(
f"Got an invalid Content-Type header value ({content_type}) "
"according to OpenID Connect Core 1.0 standard. Contact your "
"vendor."
)

def authenticate(self, *args, **kwargs):
if not self.config.enabled:
Expand Down
54 changes: 54 additions & 0 deletions mozilla_django_oidc_db/jwt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""
Support for user info JWT verification and decoding.
The bulk of the implementation is taken from mozilla-django-oidc where the access token
is processed, but adapted for non-hardcoded/configured parameters.
In the case of Keycloak for example, the token signing algorithm is configured on the
server and can change on a whim.
"""

import json
from typing import Any

from django.core.exceptions import SuspiciousOperation
from django.utils.encoding import smart_bytes

from josepy.jwk import JWK
from josepy.jws import JWS


def verify_and_decode_token(token: bytes, key) -> dict[str, Any]:
"""
Verify that the token was not tampered with and if okay, return the payload.
This is mostly taken from
:meth:`mozilla_django_oidc.auth.OIDCAuthenticationBackend._verify_jws`.
"""

jws = JWS.from_compact(token)

# validate the signing algorithm
if (alg := jws.signature.combined.alg) is None:
raise SuspiciousOperation("No alg value found in header")

# one of the most common implementation weaknesses -> attacker can supply 'none'
# algorithm
if alg.name == "none":
raise SuspiciousOperation("'none' for alg value is not allowed")

# process key parameter which was/may have been loaded from keys endpoint. The
# string variant is unknown - this code is replicated from upstream
# mozilla-django-oidc key verification.
match key:
case str():
jwk = JWK.load(smart_bytes(key))
case _:
jwk = JWK.from_json(key)
# address some missing upstream Self type declarations
assert isinstance(jwk, JWK)

if not jws.verify(jwk):
raise SuspiciousOperation("JWS token verification failed.")

return json.loads(jws.payload.decode("utf-8"))
14 changes: 14 additions & 0 deletions mozilla_django_oidc_db/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from typing import Any, List

from glom import assign, glom
from requests.utils import _parse_content_type_header # type: ignore


def obfuscate_claim_value(value: Any) -> str:
Expand All @@ -27,3 +28,16 @@ def obfuscate_claims(claims: dict, claims_to_obfuscate: List[str]) -> dict:
claim_value = glom(copied_claims, claim_name)
assign(copied_claims, claim_name, obfuscate_claim_value(claim_value))
return copied_claims


def extract_content_type(ct_header: str) -> str:
"""
Get the content type + parameters from content type header.
This is internal API since we use a requests internal utility, which may be
removed/modified at any time. However, this is a deliberate choices since I trust
requests to have a correct implementation more than coming up with one myself.
"""
content_type, _ = _parse_content_type_header(ct_header)
# discard the params, we only want the content type itself
return content_type
Loading

0 comments on commit 7ffe295

Please sign in to comment.