Skip to content

Commit 0dc97d0

Browse files
committed
✨(backend) integrate Keycloak JWT support
Added support for Keycloak JWT tokens, including custom claims and a dedicated `KeycloakAccessToken` class.
1 parent 72bb44c commit 0dc97d0

File tree

11 files changed

+434
-29
lines changed

11 files changed

+434
-29
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ and this project adheres to
2323
in admin backoffice
2424
- Add batch order export csv for admin backoffice
2525
- Add quote definitions admin in the back office
26+
- Add support for keycloak openid token
2627

2728
### Fixed
2829

env.d/development/common.dist

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ JOANIE_LMS_BACKENDS = '[{
1919

2020
#JWT
2121
DJANGO_JWT_PRIVATE_SIGNING_KEY=ThisIsAnExampleKeyForDevPurposeOnly
22+
DJANGO_JWT_ALGORITHM=RS256
23+
DJANGO_JWT_TYPE_CLAIM=typ
24+
DJANGO_KEYCLOAK_USERNAME_CLAIM=preferred_username
25+
DJANGO_KEYCLOAK_ISSUER=http://keycloak/realms/master
26+
DJANGO_KEYCLOAK_JWK_URL=http://keycloak/realms/master/protocol/openid-connect/certs
27+
JOANIE_JWT_USER_FIELDS_SYNC={"username": "preferred_username", "first_name": "given_name", "last_name": "family_name", "email": "email", "language": "locale"}
2228

2329
# Joanie settings
2430

src/backend/joanie/core/authentication.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,10 @@
1111
from rest_framework_simplejwt.authentication import JWTAuthentication
1212
from rest_framework_simplejwt.exceptions import InvalidToken
1313
from rest_framework_simplejwt.settings import api_settings
14+
from rest_framework_simplejwt.tokens import AccessToken
1415

1516

16-
def get_user_dict(token):
17+
def get_user_dict(token, force_newsletter_subscription=False):
1718
"""Get user field values from token."""
1819
values = {
1920
field: token[token_field]
@@ -28,6 +29,12 @@ def get_user_dict(token):
2829
except LookupError:
2930
values["language"] = settings.LANGUAGE_CODE
3031

32+
if (
33+
"has_subscribed_to_commercial_newsletter" not in values
34+
and force_newsletter_subscription
35+
):
36+
values["has_subscribed_to_commercial_newsletter"] = True
37+
3138
return values
3239

3340

@@ -45,10 +52,16 @@ def get_user(self, validated_token):
4552
_("Token contained no recognizable user identification")
4653
) from exc
4754

55+
# force newsletter subscription if the token is from keycloak (openid)
56+
force_newsletter_subscription = isinstance(validated_token, KeycloakAccessToken)
57+
4858
def get_or_create_and_update_user():
4959
user, _created = self.user_model.objects.get_or_create(
5060
**{api_settings.USER_ID_FIELD: user_id},
51-
defaults=get_user_dict(validated_token),
61+
defaults=get_user_dict(
62+
validated_token,
63+
force_newsletter_subscription=force_newsletter_subscription,
64+
),
5265
)
5366
user.update_from_token(validated_token)
5467
return user
@@ -94,3 +107,10 @@ class OpenApiSessionAuthenticationExtension(SessionScheme):
94107
target_class = (
95108
"joanie.core.authentication.SessionAuthenticationWithAuthenticateHeader"
96109
)
110+
111+
112+
class KeycloakAccessToken(AccessToken):
113+
"""Custom AccessToken class with `ID` token type."""
114+
115+
# ruff: noqa : S105
116+
token_type = "ID"

src/backend/joanie/core/models/accounts.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ def save(self, *args, **kwargs):
118118

119119
def update_from_token(self, token):
120120
"""Update user from token token."""
121-
values = get_user_dict(token)
121+
values = get_user_dict(token, force_newsletter_subscription=False)
122122
for key, value in values.items():
123123
if value != getattr(self, key):
124124
User.objects.filter(

src/backend/joanie/core/utils/jwt_tokens.py

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,17 @@
44

55
from datetime import datetime, timedelta
66

7+
from django.conf import settings
8+
79
from rest_framework_simplejwt.tokens import AccessToken
810

911
from joanie.core import models
12+
from joanie.core.authentication import KeycloakAccessToken
1013

1114

1215
def generate_jwt_token_from_user(
1316
user: models.User, expires_at: datetime = None
14-
) -> AccessToken:
17+
) -> AccessToken | KeycloakAccessToken:
1518
"""
1619
Generate a jwt token used to authenticate a user from a user registered in
1720
the database
@@ -24,15 +27,45 @@ def generate_jwt_token_from_user(
2427
token, the jwt token generated as it should
2528
"""
2629
issued_at = datetime.utcnow()
27-
token = AccessToken()
28-
token.payload.update(
29-
{
30-
"email": user.email,
31-
"exp": expires_at or issued_at + timedelta(days=2),
32-
"iat": issued_at,
33-
"language": user.language,
34-
"username": user.username,
35-
"full_name": user.get_full_name(),
36-
}
37-
)
30+
if issuer := settings.SIMPLE_JWT.get("ISSUER"):
31+
token = KeycloakAccessToken()
32+
token.payload.update(
33+
{
34+
"exp": expires_at or issued_at + timedelta(days=2),
35+
"iat": issued_at,
36+
"auth_time": 1768924092,
37+
"jti": "c7ee46da-8127-51d1-35b1-3f07fa1a49a5",
38+
"iss": issuer,
39+
"aud": "keycloak-client",
40+
"sub": "095009db-b774-4e26-ab58-5e55c1474d98",
41+
"typ": "ID",
42+
"azp": "keycloak-client",
43+
"nonce": "1a21d63a-930b-457f-9445-0858645f77ba",
44+
"sid": "9f00242d-9199-80d8-178a-5f9c73968385",
45+
"at_hash": "SPRUypol4zCSgENoM3764g",
46+
"acr": "0",
47+
"s_hash": "YFa348xSzi5FBMi4x9w6jg",
48+
"email_verified": False,
49+
"name": user.get_full_name(),
50+
"preferred_username": user.username,
51+
"given_name": user.first_name,
52+
"family_name": user.last_name,
53+
"email": user.email,
54+
"locale": user.language,
55+
}
56+
)
57+
backend = token.get_token_backend()
58+
backend.algorithm = "HS256"
59+
else:
60+
token = AccessToken()
61+
token.payload.update(
62+
{
63+
"email": user.email,
64+
"exp": expires_at or issued_at + timedelta(days=2),
65+
"iat": issued_at,
66+
"language": user.language,
67+
"username": user.username,
68+
"full_name": user.get_full_name(),
69+
}
70+
)
3871
return token

src/backend/joanie/settings.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -341,8 +341,17 @@ class Base(Configuration):
341341
"AUTH_HEADER_TYPES": ("Bearer",),
342342
"AUTH_HEADER_NAME": "HTTP_AUTHORIZATION",
343343
"USER_ID_FIELD": "username",
344-
"USER_ID_CLAIM": "username",
345-
"AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",),
344+
"USER_ID_CLAIM": values.Value(
345+
"username", environ_name="KEYCLOAK_USERNAME_CLAIM"
346+
),
347+
"ISSUER": values.Value(None, environ_name="KEYCLOAK_ISSUER"),
348+
"JWK_URL": values.Value(None, environ_name="KEYCLOAK_JWK_URL"),
349+
# "TOKEN_TYPE_CLAIM": "typ",
350+
"TOKEN_TYPE_CLAIM": values.Value("token_type", environ_name="JWT_TYPE_CLAIM"),
351+
"AUTH_TOKEN_CLASSES": (
352+
"rest_framework_simplejwt.tokens.AccessToken",
353+
"joanie.core.authentication.KeycloakAccessToken",
354+
),
346355
}
347356
JWT_USER_FIELDS_SYNC = values.DictValue(
348357
{

src/backend/joanie/tests/base.py

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from rest_framework_simplejwt.tokens import AccessToken
1414

1515
from joanie.core import enums
16+
from joanie.core.authentication import KeycloakAccessToken
1617
from joanie.core.models import ActivityLog
1718
from joanie.core.utils.jwt_tokens import generate_jwt_token_from_user
1819
from joanie.core.utils.sentry import serialize_data
@@ -43,16 +44,46 @@ def get_user_token(username, expires_at=None):
4344
token, the jwt token generated as it should
4445
"""
4546
issued_at = datetime.utcnow()
46-
token = AccessToken()
47-
token.payload.update(
48-
{
49-
"email": f"{username}@funmooc.fr",
50-
"exp": expires_at or issued_at + timedelta(days=2),
51-
"iat": issued_at,
52-
"language": settings.LANGUAGE_CODE,
53-
"username": username,
54-
}
55-
)
47+
if issuer := settings.SIMPLE_JWT.get("ISSUER"):
48+
token = KeycloakAccessToken()
49+
token.payload.update(
50+
{
51+
"exp": expires_at or issued_at + timedelta(days=2),
52+
"iat": issued_at,
53+
"auth_time": 1768924092,
54+
"jti": "c7ee46da-8127-51d1-35b1-3f07fa1a49a5",
55+
"iss": issuer,
56+
"aud": "keycloak-client",
57+
"sub": "095009db-b774-4e26-ab58-5e55c1474d98",
58+
"typ": "ID",
59+
"azp": "keycloak-client",
60+
"nonce": "1a21d63a-930b-457f-9445-0858645f77ba",
61+
"sid": "9f00242d-9199-80d8-178a-5f9c73968385",
62+
"at_hash": "SPRUypol4zCSgENoM3764g",
63+
"acr": "0",
64+
"s_hash": "YFa348xSzi5FBMi4x9w6jg",
65+
"email_verified": False,
66+
"name": "",
67+
"preferred_username": username,
68+
"given_name": "",
69+
"family_name": "",
70+
"email": f"{username}@funmooc.fr",
71+
"locale": settings.LANGUAGE_CODE,
72+
}
73+
)
74+
backend = token.get_token_backend()
75+
backend.algorithm = "HS256"
76+
else:
77+
token = AccessToken()
78+
token.payload.update(
79+
{
80+
"email": f"{username}@funmooc.fr",
81+
"exp": expires_at or issued_at + timedelta(days=2),
82+
"iat": issued_at,
83+
"language": settings.LANGUAGE_CODE,
84+
"username": username,
85+
}
86+
)
5687
return token
5788

5889
@staticmethod

src/backend/joanie/tests/core/test_api_base.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,19 @@
44

55
from datetime import datetime, timedelta
66

7+
from django.test import override_settings
8+
79
from joanie.core import factories
810
from joanie.tests.base import BaseAPITestCase
911

1012

1113
class BaseAPITestTestCase(BaseAPITestCase):
1214
"""Test suite for BaseAPITest class"""
1315

16+
@override_settings(SIMPLE_JWT={"ISSUER": None})
1417
def test_base_api_generate_token_from_users(self):
15-
"""If a user is passed to the method generate_token_from_user,
18+
"""
19+
If a user is passed to the method generate_token_from_user,
1620
the token attributes should correspond to the data of the user
1721
"""
1822
user = factories.UserFactory(
@@ -27,3 +31,24 @@ def test_base_api_generate_token_from_users(self):
2731
self.assertGreater(
2832
token.payload.get("exp"), datetime.utcnow() + timedelta(days=1)
2933
)
34+
35+
@override_settings(SIMPLE_JWT={"ISSUER": "AnyIssuer"})
36+
def test_base_api_generate_token_from_users_keycloak(self):
37+
"""
38+
If a user is passed to the method generate_token_from_user,
39+
the token attributes should correspond to the data of the user.
40+
In Keycloak, preferred_username is used instead of username
41+
and locale is used instead of language.
42+
"""
43+
user = factories.UserFactory(
44+
username="Sam", email="sam@fun-test.fr", language="fr-fr"
45+
)
46+
token = self.generate_token_from_user(user)
47+
self.assertEqual("sam@fun-test.fr", token.payload.get("email"))
48+
self.assertEqual("Sam", token.payload.get("preferred_username"))
49+
self.assertEqual("fr-fr", token.payload.get("locale"))
50+
51+
# expiration date is over a day from now
52+
self.assertGreater(
53+
token.payload.get("exp"), datetime.utcnow() + timedelta(days=1)
54+
)

0 commit comments

Comments
 (0)