Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle JWT response data in userinfo endpoint #85

Merged
merged 1 commit into from
Feb 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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",
sergei-maertens marked this conversation as resolved.
Show resolved Hide resolved
"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`.
Viicos marked this conversation as resolved.
Show resolved Hide resolved
"""

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