Skip to content

Commit c6a60a2

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

17 files changed

+457
-27
lines changed

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: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
from drf_spectacular.plumbing import build_bearer_security_scheme_object
1010
from rest_framework import authentication
1111
from rest_framework_simplejwt.authentication import JWTAuthentication
12+
from rest_framework_simplejwt.backends import TokenBackend
1213
from rest_framework_simplejwt.exceptions import InvalidToken
1314
from rest_framework_simplejwt.settings import api_settings
15+
from rest_framework_simplejwt.tokens import AccessToken
1416

1517

1618
def get_user_dict(token):
@@ -28,6 +30,9 @@ def get_user_dict(token):
2830
except LookupError:
2931
values["language"] = settings.LANGUAGE_CODE
3032

33+
if "has_subscribed_to_commercial_newsletter" not in values:
34+
values["has_subscribed_to_commercial_newsletter"] = True
35+
3136
return values
3237

3338

@@ -94,3 +99,17 @@ class OpenApiSessionAuthenticationExtension(SessionScheme):
9499
target_class = (
95100
"joanie.core.authentication.SessionAuthenticationWithAuthenticateHeader"
96101
)
102+
103+
104+
class KeycloakAccessToken(AccessToken):
105+
"""Custom AccessToken class to add a `typ` header."""
106+
107+
# ruff: noqa : S105
108+
token_type = "ID"
109+
110+
def get_token_backend(self) -> "TokenBackend":
111+
"""Return the token backend, adding the `User-Agent` header."""
112+
backend = super().get_token_backend()
113+
if backend.jwks_client is not None:
114+
backend.jwks_client.headers = {"User-Agent": "Keycloak-python-urllib"}
115+
return backend

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/api/order/test_create.perf.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ OrderCreateApiTest.test_api_order_create_authenticated_payment_binding:
4141
- db: 'SELECT ... FROM "joanie_course_product_relation" INNER JOIN "joanie_course_product_relation_organizations" ON ("joanie_course_product_relation"."id" = "joanie_course_product_relation_organizations"."courseproductrelation_id") WHERE ("joanie_course_product_relation"."course_id" = #::uuid AND "joanie_course_product_relation_organizations"."organization_id" = #::uuid AND "joanie_course_product_relation"."product_id" = #::uuid) LIMIT #'
4242
- db: 'SELECT ... FROM "joanie_offeringrule" WHERE ("joanie_offeringrule"."course_product_relation_id" = #::uuid AND "joanie_offeringrule"."is_active") ORDER BY "joanie_offeringrule"."position" ASC'
4343
- db: 'SELECT ... FROM "joanie_user" WHERE "joanie_user"."username" = # LIMIT #'
44+
- db: 'UPDATE "joanie_user" SET ... WHERE "joanie_user"."username" = #'
4445
- db: 'SELECT # AS "a" FROM "joanie_organization" WHERE "joanie_organization"."id" = #::uuid LIMIT #'
4546
- db: 'SELECT # AS "a" FROM "joanie_product" WHERE "joanie_product"."id" = #::uuid LIMIT #'
4647
- db: 'SELECT # AS "a" FROM "joanie_course" WHERE "joanie_course"."id" = #::uuid LIMIT #'
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
OrganizationAccessesAPITestCase.test_api_organization_accesses_list_authenticated_member:
22
- db: 'SELECT ... FROM "joanie_user" WHERE "joanie_user"."username" = # LIMIT #'
3+
- db: 'UPDATE "joanie_user" SET ... WHERE "joanie_user"."username" = #'
34
- db: 'SELECT COUNT(*) FROM (SELECT DISTINCT ... FROM "joanie_organization_access" INNER JOIN "joanie_organization" ON ("joanie_organization_access"."organization_id" = "joanie_organization"."id") INNER JOIN "joanie_organization_access" T3 ON ("joanie_organization"."id" = T3."organization_id") WHERE ("joanie_organization_access"."organization_id" = #::uuid AND "joanie_organization_access"."organization_id" = #::uuid AND T3."user_id" = #::uuid)) subquery'
45
- db: 'SELECT DISTINCT ... FROM "joanie_organization_access" INNER JOIN "joanie_organization" ON ("joanie_organization_access"."organization_id" = "joanie_organization"."id") INNER JOIN "joanie_organization_access" T3 ON ("joanie_organization"."id" = T3."organization_id") INNER JOIN "joanie_user" T5 ON ("joanie_organization_access"."user_id" = T5."id") WHERE ("joanie_organization_access"."organization_id" = #::uuid AND "joanie_organization_access"."organization_id" = #::uuid AND T3."user_id" = #::uuid) ORDER BY "joanie_organization_access"."created_on" DESC LIMIT #'

src/backend/joanie/tests/core/api/organizations/test_api_organizations_agreements.perf.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ OrganizationAgreementApiTest.test_api_organizations_agreement_list_by_signature_
3434
- cache|get: parler.core.ProductTranslation.#.en-us
3535
- db: 'SELECT ... FROM "joanie_quote" WHERE "joanie_quote"."batch_order_id" = #::uuid LIMIT #'
3636
- db: 'SELECT ... FROM "joanie_user" WHERE "joanie_user"."username" = # LIMIT #'
37+
- db: 'UPDATE "joanie_user" SET ... WHERE "joanie_user"."username" = #'
3738
- db: 'SELECT "joanie_organization_access"."role" FROM "joanie_organization_access" WHERE ("joanie_organization_access"."organization_id" = #::uuid AND "joanie_organization_access"."user_id" = #::uuid) ORDER BY "joanie_organization_access"."created_on" DESC LIMIT #'
3839
OrganizationAgreementApiTest.test_api_organizations_agreement_list_by_signature_state.2:
3940
- db: SELECT DISTINCT "joanie_contract"."id" FROM "joanie_contract" ORDER BY "joanie_contract"."id" ASC
@@ -161,6 +162,7 @@ OrganizationAgreementApiTest.test_api_organizations_agreements_list_by_offering:
161162
- cache|get: parler.core.ProductTranslation.#.en-us
162163
- db: 'SELECT ... FROM "joanie_quote" WHERE "joanie_quote"."batch_order_id" = #::uuid LIMIT #'
163164
- db: 'SELECT ... FROM "joanie_user" WHERE "joanie_user"."username" = # LIMIT #'
165+
- db: 'UPDATE "joanie_user" SET ... WHERE "joanie_user"."username" = #'
164166
- db: 'SELECT "joanie_organization_access"."role" FROM "joanie_organization_access" WHERE ("joanie_organization_access"."organization_id" = #::uuid AND "joanie_organization_access"."user_id" = #::uuid) ORDER BY "joanie_organization_access"."created_on" DESC LIMIT #'
165167
OrganizationAgreementApiTest.test_api_organizations_agreements_list_by_offering.2:
166168
- db: SELECT DISTINCT "joanie_contract"."id" FROM "joanie_contract" ORDER BY "joanie_contract"."id" ASC
@@ -373,6 +375,7 @@ OrganizationAgreementApiTest.test_api_organizations_agreements_list_with_accesse
373375
- cache|get: parler.core.ProductTranslation.#.en-us
374376
- db: 'SELECT ... FROM "joanie_quote" WHERE "joanie_quote"."batch_order_id" = #::uuid LIMIT #'
375377
- db: 'SELECT ... FROM "joanie_user" WHERE "joanie_user"."username" = # LIMIT #'
378+
- db: 'UPDATE "joanie_user" SET ... WHERE "joanie_user"."username" = #'
376379
- db: 'SELECT "joanie_organization_access"."role" FROM "joanie_organization_access" WHERE ("joanie_organization_access"."organization_id" = #::uuid AND "joanie_organization_access"."user_id" = #::uuid) ORDER BY "joanie_organization_access"."created_on" DESC LIMIT #'
377380
- db: 'SELECT ... FROM "joanie_quote" WHERE "joanie_quote"."batch_order_id" = #::uuid LIMIT #'
378381
- db: 'SELECT "joanie_organization_access"."role" FROM "joanie_organization_access" WHERE ("joanie_organization_access"."organization_id" = #::uuid AND "joanie_organization_access"."user_id" = #::uuid) ORDER BY "joanie_organization_access"."created_on" DESC LIMIT #'

src/backend/joanie/tests/core/api/organizations/test_api_organizations_contract.perf.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ OrganizationContractApiTest.test_api_organizations_contracts_list_filter_by_offe
5656
- db: 'SELECT ... FROM "joanie_address" WHERE ("joanie_address"."organization_id" = #::uuid AND "joanie_address"."is_main" AND "joanie_address"."is_reusable") ORDER BY "joanie_address"."created_on" DESC LIMIT #'
5757
- cache|get: parler.core.ProductTranslation.#.en-us
5858
- db: 'SELECT ... FROM "joanie_user" WHERE "joanie_user"."username" = # LIMIT #'
59+
- db: 'UPDATE "joanie_user" SET ... WHERE "joanie_user"."username" = #'
5960
- db: 'SELECT "joanie_organization_access"."role" FROM "joanie_organization_access" WHERE ("joanie_organization_access"."organization_id" = #::uuid AND "joanie_organization_access"."user_id" = #::uuid) ORDER BY "joanie_organization_access"."created_on" DESC LIMIT #'
6061
- cache|get: parler.core.CourseTranslation.#.en-us
6162
- cache|get: parler.core.OrganizationTranslation.#.en-us
@@ -222,6 +223,7 @@ OrganizationContractApiTest.test_api_organizations_contracts_list_filter_signatu
222223
- db: 'SELECT ... FROM "joanie_address" WHERE ("joanie_address"."organization_id" = #::uuid AND "joanie_address"."is_main" AND "joanie_address"."is_reusable") ORDER BY "joanie_address"."created_on" DESC LIMIT #'
223224
- cache|get: parler.core.ProductTranslation.#.en-us
224225
- db: 'SELECT ... FROM "joanie_user" WHERE "joanie_user"."username" = # LIMIT #'
226+
- db: 'UPDATE "joanie_user" SET ... WHERE "joanie_user"."username" = #'
225227
- db: 'SELECT "joanie_organization_access"."role" FROM "joanie_organization_access" WHERE ("joanie_organization_access"."organization_id" = #::uuid AND "joanie_organization_access"."user_id" = #::uuid) ORDER BY "joanie_organization_access"."created_on" DESC LIMIT #'
226228
- cache|get: parler.core.CourseTranslation.#.en-us
227229
- cache|get: parler.core.OrganizationTranslation.#.en-us
@@ -332,6 +334,7 @@ OrganizationContractApiTest.test_api_organizations_contracts_list_with_accesses:
332334
- db: 'SELECT ... FROM "joanie_address" WHERE ("joanie_address"."organization_id" = #::uuid AND "joanie_address"."is_main" AND "joanie_address"."is_reusable") ORDER BY "joanie_address"."created_on" DESC LIMIT #'
333335
- cache|get: parler.core.ProductTranslation.#.en-us
334336
- db: 'SELECT ... FROM "joanie_user" WHERE "joanie_user"."username" = # LIMIT #'
337+
- db: 'UPDATE "joanie_user" SET ... WHERE "joanie_user"."username" = #'
335338
- db: 'SELECT "joanie_organization_access"."role" FROM "joanie_organization_access" WHERE ("joanie_organization_access"."organization_id" = #::uuid AND "joanie_organization_access"."user_id" = #::uuid) ORDER BY "joanie_organization_access"."created_on" DESC LIMIT #'
336339
- cache|get: parler.core.CourseTranslation.#.en-us
337340
- cache|get: parler.core.OrganizationTranslation.#.en-us
@@ -367,6 +370,7 @@ OrganizationContractApiTest.test_api_organizations_contracts_retrieve_with_acces
367370
- db: 'SELECT ... FROM "joanie_address" WHERE ("joanie_address"."organization_id" = #::uuid AND "joanie_address"."is_main" AND "joanie_address"."is_reusable") ORDER BY "joanie_address"."created_on" DESC LIMIT #'
368371
- cache|get: parler.core.ProductTranslation.#.en-us
369372
- db: 'SELECT ... FROM "joanie_user" WHERE "joanie_user"."username" = # LIMIT #'
373+
- db: 'UPDATE "joanie_user" SET ... WHERE "joanie_user"."username" = #'
370374
- db: 'SELECT "joanie_organization_access"."role" FROM "joanie_organization_access" WHERE ("joanie_organization_access"."organization_id" = #::uuid AND "joanie_organization_access"."user_id" = #::uuid) ORDER BY "joanie_organization_access"."created_on" DESC LIMIT #'
371375
OrganizationContractApiTest.test_api_organizations_contracts_retrieve_with_accesses_and_canceled_order:
372376
- db: SELECT DISTINCT "joanie_contract"."id" FROM "joanie_contract" ORDER BY "joanie_contract"."id" ASC
@@ -423,6 +427,7 @@ OrganizationContractApiTest.test_api_organizations_contracts_retrieve_with_acces
423427
- db: 'SELECT ... FROM "joanie_address" WHERE ("joanie_address"."organization_id" = #::uuid AND "joanie_address"."is_main" AND "joanie_address"."is_reusable") ORDER BY "joanie_address"."created_on" DESC LIMIT #'
424428
- cache|get: parler.core.ProductTranslation.#.en-us
425429
- db: 'SELECT ... FROM "joanie_user" WHERE "joanie_user"."username" = # LIMIT #'
430+
- db: 'UPDATE "joanie_user" SET ... WHERE "joanie_user"."username" = #'
426431
- db: 'SELECT "joanie_organization_access"."role" FROM "joanie_organization_access" WHERE ("joanie_organization_access"."organization_id" = #::uuid AND "joanie_organization_access"."user_id" = #::uuid) ORDER BY "joanie_organization_access"."created_on" DESC LIMIT #'
427432
OrganizationContractApiTest.test_api_organizations_contracts_retrieve_without_access:
428433
- db: SELECT DISTINCT "joanie_contract"."id" FROM "joanie_contract" ORDER BY "joanie_contract"."id" ASC

src/backend/joanie/tests/core/api/organizations/test_list.perf.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ OrganizationApiListTest.test_api_organization_list_authenticated_queries:
2626
- cache|get: parler.core.OrganizationTranslation.#.en-us
2727
- db: 'SELECT ... FROM "joanie_address" WHERE ("joanie_address"."organization_id" = #::uuid AND "joanie_address"."is_main" AND "joanie_address"."is_reusable") ORDER BY "joanie_address"."created_on" DESC LIMIT #'
2828
- db: 'SELECT ... FROM "joanie_user" WHERE "joanie_user"."username" = # LIMIT #'
29+
- db: 'UPDATE "joanie_user" SET ... WHERE "joanie_user"."username" = #'
2930
- db: 'SELECT ... FROM "easy_thumbnails_source" WHERE ("easy_thumbnails_source"."name" = # AND "easy_thumbnails_source"."storage_hash" = #) LIMIT #'
3031
- db: 'UPDATE "easy_thumbnails_source" SET ... WHERE "easy_thumbnails_source"."id" = #'
3132
- db: 'SELECT ... FROM "easy_thumbnails_thumbnail" WHERE ("easy_thumbnails_thumbnail"."name" = # AND "easy_thumbnails_thumbnail"."source_id" = # AND "easy_thumbnails_thumbnail"."storage_hash" = #) LIMIT #'

0 commit comments

Comments
 (0)