Skip to content

Commit 728239d

Browse files
Task/cdd 3145 validate cognito jwt (#3092)
* Add common/auth/cognito_jwt * Uses custom X-UHD-AUTH header for token
1 parent cdd7496 commit 728239d

File tree

16 files changed

+642
-1
lines changed

16 files changed

+642
-1
lines changed

common/__init__.py

Whitespace-only changes.

common/auth/__init__.py

Whitespace-only changes.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .backend import JSONWebTokenAuthentication # noqa

common/auth/cognito_jwt/backend.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
from django.apps import apps as django_apps
2+
from django.conf import settings
3+
from django.utils.encoding import force_str
4+
from django.utils.module_loading import import_string
5+
from django.utils.translation import gettext as _
6+
from rest_framework import HTTP_HEADER_ENCODING, exceptions
7+
from rest_framework.authentication import BaseAuthentication
8+
9+
from .validator import TokenError, TokenValidator
10+
11+
# 2 objects expected when parsing Auth Header: 'Bearer' + token
12+
VALID_AUTH_HEADER_LENGTH = 2
13+
14+
15+
def get_authorization_header(request):
16+
"""
17+
Return request's 'X-UHD-AUTH:' header, as a bytestring.
18+
19+
Hide some test client ickyness where the header can be unicode.
20+
"""
21+
auth = request.META.get("HTTP_X_UHD_AUTH", b"")
22+
if isinstance(auth, str):
23+
# Work around django test client oddness
24+
auth = auth.encode(HTTP_HEADER_ENCODING)
25+
return auth
26+
27+
28+
class JSONWebTokenAuthentication(BaseAuthentication):
29+
"""Token based authentication using the JSON Web Token standard.
30+
Based on https://github.com/labd/django-cognito-jwt and modified
31+
to suit our use case
32+
"""
33+
34+
def authenticate(self, request):
35+
"""Entrypoint for Django Rest Framework"""
36+
jwt_token = self.get_jwt_token(request)
37+
if jwt_token is None:
38+
return None
39+
40+
# Authenticate token
41+
try:
42+
token_validator = self.get_token_validator(request)
43+
jwt_payload = token_validator.validate(jwt_token)
44+
except TokenError:
45+
raise exceptions.AuthenticationFailed from None
46+
47+
custom_user_manager = self.get_custom_user_manager()
48+
if custom_user_manager:
49+
user = custom_user_manager.get_or_create_for_cognito(jwt_payload)
50+
else:
51+
user_model = self.get_user_model()
52+
user = user_model.objects.get_or_create_for_cognito(jwt_payload)
53+
return (user, jwt_token)
54+
55+
@staticmethod
56+
def get_custom_user_manager():
57+
"""If COGNITO_USER_MANAGER is set, then the user object is obtained
58+
via get_or_create_for_cognito on the user manager, this allows use
59+
of the default unmodified Django User model"""
60+
result = None
61+
custom_user_manager_path = getattr(settings, "COGNITO_USER_MANAGER", False)
62+
if custom_user_manager_path:
63+
result = import_string(custom_user_manager_path)()
64+
return result
65+
66+
@staticmethod
67+
def get_user_model():
68+
user_model = getattr(settings, "COGNITO_USER_MODEL", settings.AUTH_USER_MODEL)
69+
return django_apps.get_model(user_model, require_ready=False)
70+
71+
@staticmethod
72+
def get_jwt_token(request):
73+
auth = get_authorization_header(request).split()
74+
if not auth or force_str(auth[0].lower()) != "bearer":
75+
return None
76+
77+
if len(auth) == 1:
78+
msg = _("Invalid Authorization header. No credentials provided.")
79+
raise exceptions.AuthenticationFailed(msg)
80+
if len(auth) > VALID_AUTH_HEADER_LENGTH:
81+
msg = _(
82+
"Invalid Authorization header. Credentials string "
83+
"should not contain spaces."
84+
)
85+
raise exceptions.AuthenticationFailed(msg)
86+
87+
return auth[1]
88+
89+
@staticmethod
90+
def get_token_validator(request):
91+
return TokenValidator(
92+
settings.COGNITO_AWS_REGION,
93+
settings.COGNITO_USER_POOL,
94+
settings.COGNITO_AUDIENCE,
95+
)
96+
97+
@staticmethod
98+
def authenticate_header(request):
99+
"""
100+
Method required by the DRF in order to return 401 responses for authentication failures, instead of 403.
101+
More details in https://www.django-rest-framework.org/api-guide/authentication/#custom-authentication.
102+
"""
103+
return "Bearer: api"
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import logging
2+
3+
from django.contrib.auth import get_user_model
4+
from django.contrib.auth.models import BaseUserManager, User
5+
6+
logger = logging.getLogger(__name__)
7+
8+
9+
class CognitoManager(BaseUserManager):
10+
11+
@staticmethod
12+
def get_or_create_for_cognito(jwt_payload):
13+
username = jwt_payload["entraObjectId"]
14+
try:
15+
user = get_user_model().objects.get(username=username)
16+
logger.debug("Found existing user %s", user.username)
17+
except User.DoesNotExist:
18+
password = None
19+
user = get_user_model().objects.create_user(
20+
username=username,
21+
password=password,
22+
)
23+
logger.info("Created user %s", user.username)
24+
user.is_active = True
25+
user.save()
26+
return user
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import json
2+
import logging
3+
4+
import jwt
5+
import requests
6+
from django.conf import settings
7+
from django.core.cache import cache
8+
from django.utils.functional import cached_property
9+
from jwt.algorithms import RSAAlgorithm
10+
11+
logger = logging.getLogger(__name__)
12+
13+
14+
class TokenError(Exception):
15+
pass
16+
17+
18+
class TokenValidator:
19+
def __init__(self, aws_region, aws_user_pool, audience):
20+
self.aws_region = aws_region
21+
self.aws_user_pool = aws_user_pool
22+
self.audience = audience
23+
24+
@cached_property
25+
def pool_url(self):
26+
return (
27+
f"https://cognito-idp.{self.aws_region}.amazonaws.com/{self.aws_user_pool}"
28+
)
29+
30+
@cached_property
31+
def _json_web_keys(self):
32+
response = requests.get(self.pool_url + "/.well-known/jwks.json", timeout=10)
33+
response.raise_for_status()
34+
json_data = response.json()
35+
return {item["kid"]: json.dumps(item) for item in json_data["keys"]}
36+
37+
def _get_public_key(self, token):
38+
try:
39+
headers = jwt.get_unverified_header(token)
40+
except jwt.DecodeError as exc:
41+
raise TokenError(str(exc)) from exc
42+
43+
if getattr(settings, "COGNITO_PUBLIC_KEYS_CACHING_ENABLED", False):
44+
cache_key = "cognito_jwt:{}".format(headers["kid"])
45+
jwk_data = cache.get(cache_key)
46+
47+
if not jwk_data:
48+
jwk_data = self._json_web_keys.get(headers["kid"])
49+
timeout = getattr(settings, "COGNITO_PUBLIC_KEYS_CACHING_TIMEOUT", 300)
50+
cache.set(cache_key, jwk_data, timeout=timeout)
51+
else:
52+
jwk_data = self._json_web_keys.get(headers["kid"])
53+
54+
if jwk_data:
55+
return RSAAlgorithm.from_jwk(jwk_data)
56+
return None
57+
58+
def validate(self, token):
59+
public_key = self._get_public_key(token)
60+
if not public_key:
61+
msg = "No key found for this token"
62+
raise TokenError(msg)
63+
64+
params = {
65+
"jwt": token,
66+
"key": public_key,
67+
"issuer": self.pool_url,
68+
"algorithms": ["RS256"],
69+
}
70+
71+
logger.debug("JWT - %s", params)
72+
token_payload = jwt.decode(
73+
token, options={"verify_signature": False} # noqa: S5659
74+
)
75+
logger.debug("JWT decoded - %s", token_payload)
76+
77+
if "aud" in token_payload:
78+
params.update({"audience": self.audience})
79+
80+
try:
81+
jwt_data = jwt.decode(**params)
82+
except (
83+
jwt.InvalidTokenError,
84+
jwt.ExpiredSignatureError,
85+
jwt.DecodeError,
86+
) as exc:
87+
raise TokenError(str(exc)) from exc
88+
return jwt_data

config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@
6161
# The name of the AWS profile to use for the AWS client used for ingestion
6262
AWS_PROFILE_NAME = os.environ.get("AWS_PROFILE_NAME")
6363

64+
# Cognito configuration
65+
COGNITO_AWS_REGION = os.environ.get("COGNITO_AWS_REGION")
66+
COGNITO_USER_POOL = os.environ.get("COGNITO_USER_POOL")
67+
6468
# Database configuration
6569
POSTGRES_DB = os.environ.get("POSTGRES_DB")
6670
POSTGRES_USER = os.environ.get("POSTGRES_USER")

metrics/api/settings/default.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,11 +111,18 @@
111111
},
112112
]
113113

114+
COGNITO_USER_MANAGER = "common.auth.cognito_jwt.user_manager.CognitoManager"
115+
COGNITO_AWS_REGION = config.COGNITO_AWS_REGION
116+
COGNITO_USER_POOL = config.COGNITO_USER_POOL
117+
COGNITO_AUDIENCE = None
118+
COGNITO_PUBLIC_KEYS_CACHING_ENABLED = True
119+
COGNITO_PUBLIC_KEYS_CACHING_TIMEOUT = 60 * 60 * 24 # 24h caching, default is 300s
114120

115121
REST_FRAMEWORK = {
116122
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
117123
"DEFAULT_AUTHENTICATION_CLASSES": [
118124
"rest_framework.authentication.SessionAuthentication",
125+
"common.auth.cognito_jwt.JSONWebTokenAuthentication",
119126
],
120127
}
121128

requirements-dev.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ pytest==9.0.2
2020
pytest-cov==7.1.0
2121
pytest-django==4.12.0
2222
pytest-random-order==1.2.0
23+
pytest-responses==0.5.1
2324
ruff==0.15.7
2425
stevedore==5.7.0
25-
django-debug-toolbar==6.2.0
26+
django-debug-toolbar==6.2.0

requirements-prod.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ click==8.3.1
1010
colorama==0.4.6
1111
coreapi==2.3.3
1212
coreschema==0.0.4
13+
cryptography==46.0.5
1314
defusedxml==0.7.1
1415
distlib==0.4.0
1516
django-cors-headers==4.8.0
@@ -58,6 +59,7 @@ Pillow==12.1.1
5859
platformdirs==4.9.4
5960
plotly==6.6.0
6061
pluggy==1.6.0
62+
pyjwt==2.12.1
6163
pyparsing==3.3.2
6264
pyrsistent==0.20.0
6365
python-dateutil==2.9.0.post0

0 commit comments

Comments
 (0)