Skip to content

Commit 0ef4a91

Browse files
Merge pull request #46 from AustralianBioCommons/token-issuer-fix
AAI-268 fix token verification: need to allow for different issuers
2 parents 4372870 + d0fa029 commit 0ef4a91

8 files changed

Lines changed: 171 additions & 5 deletions

File tree

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1+
# Auth0 tenant domain
12
AUTH0_DOMAIN=mytenant.auth0.com
23
# ID and secret for an app authorized to use the management API
34
AUTH0_MANAGEMENT_ID=management-app-id
45
AUTH0_MANAGEMENT_SECRET=management-secret
56
AUTH0_AUDIENCE=https://audience.com/api
7+
# Issuer for Auth0 tokens. Note that when you use a custom domain
8+
# for login, the issuer will be the custom domain, but the
9+
# audience will still be Auth0 tenant domain
10+
AUTH0_ISSUER=https://customdomain.org/
611
# JWT secret key: used to provide some protection around registration
712
# Generate with: python -c "import secrets; print(secrets.token_urlsafe(32))"
813
JWT_SECRET_KEY=secret-key

.github/workflows/build-ecr.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ jobs:
4343
AUTH0_MANAGEMENT_ID=${{ secrets.AUTH0_MANAGEMENT_ID }}
4444
AUTH0_MANAGEMENT_SECRET=${{ secrets.AUTH0_MANAGEMENT_SECRET }}
4545
AUTH0_AUDIENCE=${{ secrets.AUTH0_AUDIENCE }}
46+
AUTH0_ISSUER=${{ secrets.AUTH0_ISSUER }}
4647
JWT_SECRET_KEY=${{ secrets.JWT_SECRET_KEY }}
4748
ADMIN_ROLES=${{ secrets.ADMIN_ROLES }}
4849
CORS_ALLOWED_ORIGINS=${{ secrets.CORS_ALLOWED_ORIGINS }}

auth/management.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88

99
def get_management_token(settings: Annotated[Settings, Depends(get_settings)]):
10+
# Note: need to call the default auth0 domain here, not the custom
11+
# domain
1012
url = f"https://{settings.auth0_domain}/oauth/token"
1113
payload = {
1214
"grant_type": "client_credentials",

auth/validator.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,17 @@ def verify_jwt(token: str, settings: Settings) -> AccessTokenPayload:
2525
)
2626

2727
try:
28+
# Issuer may be the Auth0 tenant domain, or the custom domain
29+
# used for the app. Allow for both
30+
issuers = [f"https://{settings.auth0_domain}/"]
31+
if settings.auth0_issuer is not None:
32+
issuers.append(settings.auth0_issuer)
2833
payload = jwt.decode(
2934
token,
3035
rsa_key,
3136
algorithms=settings.auth0_algorithms,
3237
audience=settings.auth0_audience,
33-
issuer=f"https://{settings.auth0_domain}/",
38+
issuer=issuers,
3439
)
3540
except JWTError as e:
3641
raise HTTPException(status_code=401, detail=f"Invalid token: {e}")

config.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from functools import lru_cache
2-
from typing import Dict
2+
from typing import Dict, Optional
33

44
from pydantic_settings import BaseSettings, SettingsConfigDict
55

@@ -9,6 +9,9 @@ class Settings(BaseSettings):
99
auth0_management_id: str
1010
auth0_management_secret: str
1111
auth0_audience: str
12+
# Optional: issuer may be different to the auth0_domain if
13+
# a custom domain is used
14+
auth0_issuer: Optional[str] = None
1215
jwt_secret_key: str
1316
auth0_algorithms: list[str] = ["RS256"]
1417
admin_roles: list[str] = []

tests/conftest.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,14 +79,15 @@ def mock_settings():
7979
"""Fixture that returns mocked Settings object."""
8080
return Settings(
8181
auth0_domain="mock-domain",
82+
auth0_issuer=None,
8283
auth0_management_id="mock-id",
8384
auth0_management_secret="mock-secret",
8485
auth0_audience="mock-audience",
8586
jwt_secret_key="mock-secret-key",
8687
cors_allowed_origins="https://test",
8788
send_email=False,
8889
admin_roles=["Admin"],
89-
auth0_algorithms=["HS256"]
90+
auth0_algorithms=["RS256"]
9091
)
9192

9293

tests/test_auth_validator.py

Lines changed: 149 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,113 @@
1+
import uuid
2+
from dataclasses import dataclass
3+
from datetime import datetime, timedelta
4+
from typing import Optional
15
from unittest.mock import patch
26

7+
import pytest
8+
9+
# Tools from hazmat should only be used for testing!
10+
from cryptography.hazmat.primitives.asymmetric import rsa
11+
from cryptography.hazmat.primitives.asymmetric.rsa import (
12+
RSAPrivateKey,
13+
RSAPublicKey,
14+
)
15+
from fastapi import HTTPException
316
from jose import jwt
417
from jose.backends.cryptography_backend import CryptographyRSAKey
518

6-
from auth.validator import get_rsa_key
19+
from auth.validator import get_rsa_key, verify_jwt
720
from config import Settings
821

922

23+
def generate_public_private_key_pair():
24+
# Code from https://fmpm.dev/mocking-auth0-tokens
25+
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
26+
public_key = private_key.public_key()
27+
return public_key, private_key
28+
29+
30+
@dataclass
31+
class AuthTokenData:
32+
"""
33+
Stores all the information needed to generate an access token and
34+
test that it can be decoded.
35+
"""
36+
37+
private_key: RSAPrivateKey
38+
public_key: RSAPublicKey
39+
access_token_str: str
40+
access_token_data: dict
41+
key_id: str
42+
43+
44+
def create_access_token(
45+
email: str = "user@example.com",
46+
roles: Optional[list[str]] = None,
47+
iss: str = "https://issuer.example.com",
48+
sub: Optional[str] = None,
49+
iat: Optional[int] = None,
50+
exp: Optional[int] = None,
51+
aud: str = "https://audience.example.com",
52+
scope: Optional[list[str]] = None,
53+
azp: Optional[str] = None,
54+
permissions: Optional[list[str]] = None,
55+
algorithm: str = "RS256",
56+
public_key_id: str = "example-key",
57+
) -> AuthTokenData:
58+
"""
59+
Create an OIDC access token along with a dummy private and public key
60+
for signing it. Each field of the payload can be set, but otherwise
61+
will get a sensible default (e.g. expiry time in the future).
62+
"""
63+
if roles is None:
64+
roles = []
65+
# Generate a random alphanumeric ID
66+
if sub is None:
67+
sub = uuid.uuid4().hex
68+
if iat is None:
69+
iat = int(datetime.now().strftime("%s"))
70+
if exp is None:
71+
exp = int((datetime.now() + timedelta(hours=1)).strftime("%s"))
72+
if azp is None:
73+
azp = uuid.uuid4().hex
74+
if permissions is None:
75+
permissions = []
76+
77+
payload = {
78+
"email": email,
79+
"https://biocommons.org.au/roles": roles,
80+
"iss": iss,
81+
"sub": sub,
82+
"aud": [aud],
83+
"iat": iat,
84+
"exp": exp,
85+
"scope": scope,
86+
"azp": azp,
87+
"permissions": permissions,
88+
}
89+
public_key, private_key = generate_public_private_key_pair()
90+
from cryptography.hazmat.primitives import serialization
91+
pem_private_key = private_key.private_bytes(
92+
encoding=serialization.Encoding.PEM,
93+
format=serialization.PrivateFormat.PKCS8,
94+
encryption_algorithm=serialization.NoEncryption(),
95+
)
96+
access_token_encoded = jwt.encode(
97+
payload,
98+
key=pem_private_key,
99+
algorithm=algorithm,
100+
headers={"kid": public_key_id},
101+
)
102+
return AuthTokenData(
103+
private_key=private_key,
104+
public_key=public_key,
105+
access_token_str=access_token_encoded,
106+
access_token_data=payload,
107+
key_id=public_key_id,
108+
)
109+
110+
10111
def test_get_rsa_key_returns_key(mock_settings: Settings):
11112
token = jwt.encode({"some": "payload"}, "secret", algorithm="HS256")
12113
unverified_header = {"kid": "testkey"}
@@ -27,3 +128,50 @@ def test_get_rsa_key_returns_key(mock_settings: Settings):
27128
key = get_rsa_key(token, settings=mock_settings)
28129
assert key is not None
29130
assert isinstance(key, CryptographyRSAKey)
131+
132+
133+
def test_verify_jwt(mock_settings: Settings, mocker):
134+
"""
135+
Test we can verify a JWT based on issuer and audience.
136+
"""
137+
mock_settings.auth0_audience = f"https://{mock_settings.auth0_domain}/api/"
138+
token = create_access_token(
139+
email="user@example.com",
140+
# Our verify code assumes the issuer will be the auth0 domain
141+
iss=f"https://{mock_settings.auth0_domain}/",
142+
aud=f"https://{mock_settings.auth0_domain}/api/",
143+
)
144+
mocker.patch("auth.validator.get_rsa_key", return_value=token.public_key)
145+
decoded = verify_jwt(token.access_token_str, settings=mock_settings)
146+
assert decoded.email == "user@example.com"
147+
148+
149+
def test_verify_jwt_invalid_issuer(mock_settings: Settings, mocker):
150+
"""
151+
Test invalid JWT issuer raises an error
152+
"""
153+
mock_settings.auth0_audience = f"https://{mock_settings.auth0_domain}/api/"
154+
token = create_access_token(
155+
email="user@example.com",
156+
iss="https://other.example.com/",
157+
aud=f"https://{mock_settings.auth0_domain}/api/",
158+
)
159+
mocker.patch("auth.validator.get_rsa_key", return_value=token.public_key)
160+
with pytest.raises(HTTPException, match="Invalid issuer"):
161+
verify_jwt(token.access_token_str, settings=mock_settings)
162+
163+
164+
def test_verify_jwt_custom_domain_issuer(mock_settings: Settings, mocker):
165+
"""
166+
Check that our verify code also works with the auth0_issuer setting
167+
"""
168+
mock_settings.auth0_audience = f"https://{mock_settings.auth0_domain}/api/"
169+
mock_settings.auth0_issuer = "https://mydomain.org/"
170+
token = create_access_token(
171+
email="user@example.com",
172+
iss=mock_settings.auth0_issuer,
173+
aud=mock_settings.auth0_audience,
174+
)
175+
mocker.patch("auth.validator.get_rsa_key", return_value=token.public_key)
176+
decoded = verify_jwt(token.access_token_str, settings=mock_settings)
177+
assert decoded.email == "user@example.com"

tests/test_galaxy.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,11 @@ def test_get_registration_token(test_client, mock_settings):
4444
"""
4545
Test get-registration-token endpoint returns a valid JWT token.
4646
"""
47+
from register.tokens import ALGORITHM
4748
response = test_client.get("/galaxy/register/get-registration-token")
4849
assert response.status_code == 200
4950
jwt.decode(response.json()["token"], mock_settings.jwt_secret_key,
50-
algorithms=mock_settings.auth0_algorithms)
51+
algorithms=ALGORITHM)
5152

5253

5354
def test_registration_token_invalid_purpose(mock_settings):

0 commit comments

Comments
 (0)