1+ import uuid
2+ from dataclasses import dataclass
3+ from datetime import datetime , timedelta
4+ from typing import Optional
15from 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
316from jose import jwt
417from 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
720from 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+
10111def 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"
0 commit comments