diff --git a/docker/README.md b/docker/README.md index ca24494..09ed3aa 100644 --- a/docker/README.md +++ b/docker/README.md @@ -13,7 +13,7 @@ You can now log in via `http://localhost:8080` with the `admin`/`admin` credenti In short - exporting through the admin UI (rightfully) obfuscates client secrets and user credentials. However, for reproducible builds/environments, we want to include -this data in the Realm export. +this data in the Realm export(s). Ensure the service is up and running through docker-compose. @@ -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 +``` diff --git a/docker/import/test-realm.json b/docker/import/test-realm.json index 6271fa5..e8c3d86 100644 --- a/docker/import/test-realm.json +++ b/docker/import/test-realm.json @@ -245,6 +245,7 @@ "attributes" : { } } ], "security-admin-console" : [ ], + "test-userinfo-jwt" : [ ], "admin-cli" : [ ], "testid" : [ ], "account-console" : [ ], @@ -513,7 +514,9 @@ "publicClient" : true, "frontchannelLogout" : false, "protocol" : "openid-connect", - "attributes" : { }, + "attributes" : { + "post.logout.redirect.uris" : "+" + }, "authenticationFlowBindingOverrides" : { }, "fullScopeAllowed" : false, "nodeReRegistrationTimeout" : 0, @@ -539,7 +542,9 @@ "publicClient" : false, "frontchannelLogout" : false, "protocol" : "openid-connect", - "attributes" : { }, + "attributes" : { + "post.logout.redirect.uris" : "+" + }, "authenticationFlowBindingOverrides" : { }, "fullScopeAllowed" : false, "nodeReRegistrationTimeout" : 0, @@ -565,7 +570,9 @@ "publicClient" : false, "frontchannelLogout" : false, "protocol" : "openid-connect", - "attributes" : { }, + "attributes" : { + "post.logout.redirect.uris" : "+" + }, "authenticationFlowBindingOverrides" : { }, "fullScopeAllowed" : false, "nodeReRegistrationTimeout" : 0, @@ -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", @@ -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, @@ -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", @@ -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", @@ -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", @@ -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", @@ -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" } } ] }, { @@ -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", @@ -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", @@ -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" }, diff --git a/mozilla_django_oidc_db/admin.py b/mozilla_django_oidc_db/admin.py index 4c0009a..df6966a 100644 --- a/mozilla_django_oidc_db/admin.py +++ b/mozilla_django_oidc_db/admin.py @@ -64,6 +64,7 @@ class OpenIDConnectConfigAdmin(SingletonModelAdmin): "oidc_state_size", "oidc_exempt_urls", "userinfo_claims_source", + "userinfo_endpoint_accept_header", ), "classes": [ "collapse in", diff --git a/mozilla_django_oidc_db/backends.py b/mozilla_django_oidc_db/backends.py index 8c07e22..a9af791 100644 --- a/mozilla_django_oidc_db/backends.py +++ b/mozilla_django_oidc_db/backends.py @@ -1,20 +1,22 @@ import fnmatch import logging -from typing import Any, Dict, Generic, List, TypeVar, cast +from typing import Any, Generic, TypeVar, cast from django.contrib.auth import get_user_model from django.contrib.auth.models import Group from django.core.exceptions import ObjectDoesNotExist +import requests from glom import glom from mozilla_django_oidc.auth import ( OIDCAuthenticationBackend as _OIDCAuthenticationBackend, ) from .constants import UserInformationClaimsSources +from .jwt import verify_and_decode_token from .mixins import GetAttributeMixin, SoloConfigMixin from .models import OpenIDConnectConfig -from .utils import obfuscate_claims +from .utils import extract_content_type, obfuscate_claims logger = logging.getLogger(__name__) @@ -27,6 +29,9 @@ class OIDCAuthenticationBackend( """ Modifies the default OIDCAuthenticationBackend to use a configurable claim as unique identifier (default `sub`). + + .. todo:: It would make sense to set up ``self.session = requests.Session()`` for + connection pooling so that subsequent calls are a bit more performant. """ config_identifier_field = "username_claim" @@ -75,7 +80,46 @@ 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 only tested with RS256, no idea how this behaves on other algorithms + 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: @@ -83,7 +127,7 @@ def authenticate(self, *args, **kwargs): return super().authenticate(*args, **kwargs) - def get_user_instance_values(self, claims) -> Dict[str, Any]: + def get_user_instance_values(self, claims) -> dict[str, Any]: """ Map the names and values of the claims to the fields of the User model """ @@ -163,7 +207,7 @@ def update_user_superuser_status(self, user, claims): """ groups_claim = self.config.groups_claim # can't do an isinstance check here - superuser_group_names = cast(List[str], self.config.superuser_group_names) + superuser_group_names = cast(list[str], self.config.superuser_group_names) if not superuser_group_names: return diff --git a/mozilla_django_oidc_db/jwt.py b/mozilla_django_oidc_db/jwt.py new file mode 100644 index 0000000..90c6d18 --- /dev/null +++ b/mozilla_django_oidc_db/jwt.py @@ -0,0 +1,53 @@ +""" +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. I'm u + # Unsure what the type of this parameter can be :/ + 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")) diff --git a/mozilla_django_oidc_db/utils.py b/mozilla_django_oidc_db/utils.py index c035f3e..5033bf6 100644 --- a/mozilla_django_oidc_db/utils.py +++ b/mozilla_django_oidc_db/utils.py @@ -2,6 +2,7 @@ from typing import Any, List from glom import assign, glom +from requests.utils import _parse_content_type_header def obfuscate_claim_value(value: Any) -> str: @@ -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 diff --git a/tests/cassettes/test_integration_oidc_flow_variants/test_return_jwt_from_userinfo_endpoint.yaml b/tests/cassettes/test_integration_oidc_flow_variants/test_return_jwt_from_userinfo_endpoint.yaml new file mode 100644 index 0000000..92d6e4f --- /dev/null +++ b/tests/cassettes/test_integration_oidc_flow_variants/test_return_jwt_from_userinfo_endpoint.yaml @@ -0,0 +1,351 @@ +interactions: +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - python-requests/2.31.0 + method: GET + uri: http://localhost:8080/realms/test/.well-known/openid-configuration + response: + body: + string: '{"issuer":"http://localhost:8080/realms/test","authorization_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/auth","token_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/token","introspection_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/token/introspect","userinfo_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/userinfo","end_session_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/logout","frontchannel_logout_session_supported":true,"frontchannel_logout_supported":true,"jwks_uri":"http://localhost:8080/realms/test/protocol/openid-connect/certs","check_session_iframe":"http://localhost:8080/realms/test/protocol/openid-connect/login-status-iframe.html","grant_types_supported":["authorization_code","implicit","refresh_token","password","client_credentials","urn:openid:params:grant-type:ciba","urn:ietf:params:oauth:grant-type:device_code"],"acr_values_supported":["0","1"],"response_types_supported":["code","none","id_token","token","id_token + token","code id_token","code token","code id_token token"],"subject_types_supported":["public","pairwise"],"id_token_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"id_token_encryption_alg_values_supported":["RSA-OAEP","RSA-OAEP-256","RSA1_5"],"id_token_encryption_enc_values_supported":["A256GCM","A192GCM","A128GCM","A128CBC-HS256","A192CBC-HS384","A256CBC-HS512"],"userinfo_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512","none"],"userinfo_encryption_alg_values_supported":["RSA-OAEP","RSA-OAEP-256","RSA1_5"],"userinfo_encryption_enc_values_supported":["A256GCM","A192GCM","A128GCM","A128CBC-HS256","A192CBC-HS384","A256CBC-HS512"],"request_object_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512","none"],"request_object_encryption_alg_values_supported":["RSA-OAEP","RSA-OAEP-256","RSA1_5"],"request_object_encryption_enc_values_supported":["A256GCM","A192GCM","A128GCM","A128CBC-HS256","A192CBC-HS384","A256CBC-HS512"],"response_modes_supported":["query","fragment","form_post","query.jwt","fragment.jwt","form_post.jwt","jwt"],"registration_endpoint":"http://localhost:8080/realms/test/clients-registrations/openid-connect","token_endpoint_auth_methods_supported":["private_key_jwt","client_secret_basic","client_secret_post","tls_client_auth","client_secret_jwt"],"token_endpoint_auth_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"introspection_endpoint_auth_methods_supported":["private_key_jwt","client_secret_basic","client_secret_post","tls_client_auth","client_secret_jwt"],"introspection_endpoint_auth_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"authorization_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"authorization_encryption_alg_values_supported":["RSA-OAEP","RSA-OAEP-256","RSA1_5"],"authorization_encryption_enc_values_supported":["A256GCM","A192GCM","A128GCM","A128CBC-HS256","A192CBC-HS384","A256CBC-HS512"],"claims_supported":["aud","sub","iss","auth_time","name","given_name","family_name","preferred_username","email","acr"],"claim_types_supported":["normal"],"claims_parameter_supported":true,"scopes_supported":["openid","email","roles","phone","profile","address","kvk","web-origins","microprofile-jwt","acr","offline_access","bsn"],"request_parameter_supported":true,"request_uri_parameter_supported":true,"require_request_uri_registration":true,"code_challenge_methods_supported":["plain","S256"],"tls_client_certificate_bound_access_tokens":true,"revocation_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/revoke","revocation_endpoint_auth_methods_supported":["private_key_jwt","client_secret_basic","client_secret_post","tls_client_auth","client_secret_jwt"],"revocation_endpoint_auth_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"backchannel_logout_supported":true,"backchannel_logout_session_supported":true,"device_authorization_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/auth/device","backchannel_token_delivery_modes_supported":["poll","ping"],"backchannel_authentication_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/ext/ciba/auth","backchannel_authentication_request_signing_alg_values_supported":["PS384","ES384","RS384","ES256","RS256","ES512","PS256","PS512","RS512"],"require_pushed_authorization_requests":false,"pushed_authorization_request_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/ext/par/request","mtls_endpoint_aliases":{"token_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/token","revocation_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/revoke","introspection_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/token/introspect","device_authorization_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/auth/device","registration_endpoint":"http://localhost:8080/realms/test/clients-registrations/openid-connect","userinfo_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/userinfo","pushed_authorization_request_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/ext/par/request","backchannel_authentication_endpoint":"http://localhost:8080/realms/test/protocol/openid-connect/ext/ciba/auth"},"authorization_response_iss_parameter_supported":true}' + headers: + Cache-Control: + - no-cache, must-revalidate, no-transform, no-store + Content-Type: + - application/json;charset=UTF-8 + Referrer-Policy: + - no-referrer + Strict-Transport-Security: + - max-age=31536000; includeSubDomains + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-XSS-Protection: + - 1; mode=block + content-length: + - '5847' + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - '*/*' + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - python-requests/2.31.0 + method: GET + uri: http://localhost:8080/realms/test/protocol/openid-connect/auth?response_type=code&scope=openid+email+profile+bsn+kvk&client_id=test-userinfo-jwt&redirect_uri=http%3A%2F%2Ftestserver%2Foidc%2Fcallback%2F&state=not-a-random-string&nonce=not-a-random-string + response: + body: + string: "\n\n\n
\n \n + \ \n \n\n \n