Skip to content

Commit

Permalink
✨ [#83] Implement JWT user-info response data processing
Browse files Browse the repository at this point in the history
  • Loading branch information
sergei-maertens committed Feb 6, 2024
1 parent f37648c commit 68f9742
Show file tree
Hide file tree
Showing 8 changed files with 583 additions and 41 deletions.
15 changes: 8 additions & 7 deletions docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
1 change: 1 addition & 0 deletions mozilla_django_oidc_db/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ class OpenIDConnectConfigAdmin(SingletonModelAdmin):
"oidc_state_size",
"oidc_exempt_urls",
"userinfo_claims_source",
"userinfo_endpoint_accept_header",
),
"classes": [
"collapse in",
Expand Down
54 changes: 49 additions & 5 deletions mozilla_django_oidc_db/backends.py
Original file line number Diff line number Diff line change
@@ -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__)

Expand All @@ -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"
Expand Down Expand Up @@ -75,15 +80,54 @@ 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:
return None

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
"""
Expand Down Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions mozilla_django_oidc_db/jwt.py
Original file line number Diff line number Diff line change
@@ -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"))
Loading

0 comments on commit 68f9742

Please sign in to comment.