From bb4ab9ecb117f400e374a16a7bc01c8f8f59e683 Mon Sep 17 00:00:00 2001 From: Paul Rysiavets Date: Thu, 12 Sep 2024 17:24:23 +0200 Subject: [PATCH 01/12] feat(PyJWT): require PyJWT instead of python-jose --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index b7346e0..dcb7aa7 100644 --- a/setup.py +++ b/setup.py @@ -17,5 +17,5 @@ package_dir={'': 'src'}, package_data={'': ['py.typed']}, python_requires='>=3.7', - install_requires=['fastapi>=0.60.0', 'python-jose>=3.2.0'] + install_requires=['fastapi>=0.60.0', 'PyJWT>=2.8.0'] ) From f6a4a3d632dc1f3b44ad38ecbe36ee2f37101c8d Mon Sep 17 00:00:00 2001 From: Paul Rysiavets Date: Thu, 12 Sep 2024 17:27:55 +0200 Subject: [PATCH 02/12] feat(PyJWT): require PyJWT instead of python-jose in github-workflows --- .github/workflows/package.yml | 2 +- .github/workflows/pull_request.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 519d5b8..bb7fe69 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -24,7 +24,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install flake8 mypy pytest fastapi>=0.60.0 python-jose>=3.2.0 pydantic-settings httpx requests types-requests + python -m pip install flake8 mypy pytest fastapi>=0.60.0 PyJWT>=2.8.0 pydantic-settings httpx requests types-requests if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Lint with flake8 run: | diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 7c6e49f..453fd53 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -19,7 +19,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install flake8 mypy pytest fastapi>=0.60.0 python-jose>=3.2.0 pydantic-settings httpx requests types-requests + python -m pip install flake8 mypy pytest fastapi>=0.60.0 PyJWT>=2.8.0 pydantic-settings httpx requests types-requests if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Lint with flake8 run: | From 87b4d1cf1ecfab9ab10c7be2aee54096f9106e7a Mon Sep 17 00:00:00 2001 From: Paul Rysiavets Date: Thu, 12 Sep 2024 17:31:43 +0200 Subject: [PATCH 03/12] feat(PyJWT): migrate code to PyJWT from python-jose --- src/fastapi_auth0/auth.py | 45 +++++++++++++++++---------------------- 1 file changed, 19 insertions(+), 26 deletions(-) diff --git a/src/fastapi_auth0/auth.py b/src/fastapi_auth0/auth.py index 165ecaa..9ea1149 100644 --- a/src/fastapi_auth0/auth.py +++ b/src/fastapi_auth0/auth.py @@ -1,11 +1,11 @@ -import json import logging import os -from typing import Optional, Dict, List, Type +from typing import Optional, Dict, List, Type, Any import urllib.parse import urllib.request -from jose import jwt # type: ignore +import jwt +from jwt import PyJWKClient from fastapi import HTTPException, Depends, Request from fastapi.security import SecurityScopes, HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import OAuth2, OAuth2PasswordBearer, OAuth2AuthorizationCodeBearer, OpenIdConnect @@ -77,7 +77,7 @@ class JwksDict(TypedDict): class Auth0: def __init__(self, domain: str, api_audience: str, scopes: Dict[str, str]={}, auto_error: bool=True, scope_auto_error: bool=True, email_auto_error: bool=False, - auth0user_model: Type[Auth0User]=Auth0User): + auth0user_model: Type[Auth0User]=Auth0User, options: Dict[str, Any] | None = None): self.domain = domain self.audience = api_audience @@ -88,8 +88,6 @@ def __init__(self, domain: str, api_audience: str, scopes: Dict[str, str]={}, self.auth0_user_model = auth0user_model self.algorithms = ['RS256'] - r = urllib.request.urlopen(f'https://{domain}/.well-known/jwks.json') - self.jwks: JwksDict = json.loads(r.read()) authorization_url_qs = urllib.parse.urlencode({'audience': api_audience}) authorization_url = f'https://{domain}/authorize?{authorization_url_qs}' @@ -103,6 +101,8 @@ def __init__(self, domain: str, api_audience: str, scopes: Dict[str, str]={}, tokenUrl=f'https://{domain}/oauth/token', scopes=scopes) self.oidc_scheme = OpenIdConnect(openIdConnectUrl=f'https://{domain}/.well-known/openid-configuration') + self.options = options + self.jwks_client = PyJWKClient(f"https://{self.domain}/.well-known/jwks.json") async def get_user(self, @@ -139,27 +139,20 @@ async def get_user(self, logger.warning(msg) return None - rsa_key = {} - for key in self.jwks['keys']: - if key['kid'] == unverified_header['kid']: - rsa_key = { - 'kty': key['kty'], - 'kid': key['kid'], - 'use': key['use'], - 'n': key['n'], - 'e': key['e'] - } - break - if rsa_key: + try: + signing_key = self.jwks_client.get_signing_key_from_jwt(token) + leeway = self.options.pop("leeway", 0) if self.options else 0 payload = jwt.decode( token, - rsa_key, + signing_key.key, algorithms=self.algorithms, audience=self.audience, - issuer=f'https://{self.domain}/' + issuer=f"https://{self.domain}/", + leeway=leeway, + options=self.options, ) - else: - msg = 'Invalid kid header (wrong tenant or rotated public key)' + except jwt.PyJWKClientError as e: + msg = str(e) if self.auto_error: raise Auth0UnauthenticatedException(detail=msg) else: @@ -174,16 +167,16 @@ async def get_user(self, logger.warning(msg) return None - except jwt.JWTClaimsError: - msg = 'Invalid token claims (wrong issuer or audience)' + except (jwt.InvalidAudienceError, jwt.InvalidIssuerError): + msg = "Invalid token claims (wrong issuer or audience)" if self.auto_error: raise Auth0UnauthenticatedException(detail=msg) else: logger.warning(msg) return None - except jwt.JWTError: - msg = 'Malformed token' + except jwt.PyJWTError as e: + msg = f"Malformed token: {e}" if self.auto_error: raise Auth0UnauthenticatedException(detail=msg) else: From 2e480ef749c29aae1bf8963f7d1262a133f84833 Mon Sep 17 00:00:00 2001 From: Paul Rysiavets Date: Thu, 12 Sep 2024 17:32:09 +0200 Subject: [PATCH 04/12] feat(PyJWT): remove unused classes --- src/fastapi_auth0/auth.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/fastapi_auth0/auth.py b/src/fastapi_auth0/auth.py index 9ea1149..a7781b4 100644 --- a/src/fastapi_auth0/auth.py +++ b/src/fastapi_auth0/auth.py @@ -11,7 +11,6 @@ from fastapi.security import OAuth2, OAuth2PasswordBearer, OAuth2AuthorizationCodeBearer, OpenIdConnect from fastapi.openapi.models import OAuthFlows, OAuthFlowImplicit from pydantic import BaseModel, Field, ValidationError -from typing_extensions import TypedDict logger = logging.getLogger('fastapi_auth0') @@ -62,18 +61,6 @@ async def __call__(self, request: Request) -> Optional[str]: return None -class JwksKeyDict(TypedDict): - kid: str - kty: str - use: str - n: str - e: str - -class JwksDict(TypedDict): - keys: List[JwksKeyDict] - - - class Auth0: def __init__(self, domain: str, api_audience: str, scopes: Dict[str, str]={}, auto_error: bool=True, scope_auto_error: bool=True, email_auto_error: bool=False, From 2679324a719023104a151ab4b3920f08864d84bb Mon Sep 17 00:00:00 2001 From: Paul Rysiavets Date: Thu, 12 Sep 2024 17:39:54 +0200 Subject: [PATCH 05/12] feat(PyJWT): re-use jwt import --- src/fastapi_auth0/auth.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/fastapi_auth0/auth.py b/src/fastapi_auth0/auth.py index a7781b4..dca0015 100644 --- a/src/fastapi_auth0/auth.py +++ b/src/fastapi_auth0/auth.py @@ -5,7 +5,6 @@ import urllib.request import jwt -from jwt import PyJWKClient from fastapi import HTTPException, Depends, Request from fastapi.security import SecurityScopes, HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import OAuth2, OAuth2PasswordBearer, OAuth2AuthorizationCodeBearer, OpenIdConnect @@ -89,7 +88,7 @@ def __init__(self, domain: str, api_audience: str, scopes: Dict[str, str]={}, scopes=scopes) self.oidc_scheme = OpenIdConnect(openIdConnectUrl=f'https://{domain}/.well-known/openid-configuration') self.options = options - self.jwks_client = PyJWKClient(f"https://{self.domain}/.well-known/jwks.json") + self.jwks_client = jwt.PyJWKClient(f"https://{self.domain}/.well-known/jwks.json") async def get_user(self, From 8c99de9d3c00c9258ccc06980f7eab5a0e9f879d Mon Sep 17 00:00:00 2001 From: Paul Rysiavets Date: Thu, 12 Sep 2024 17:45:14 +0200 Subject: [PATCH 06/12] feat(PyJWT): set Auth0User.email to None (as this requires Auth0 Management Access) --- src/fastapi_auth0/auth.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/fastapi_auth0/auth.py b/src/fastapi_auth0/auth.py index dca0015..91842da 100644 --- a/src/fastapi_auth0/auth.py +++ b/src/fastapi_auth0/auth.py @@ -14,8 +14,6 @@ logger = logging.getLogger('fastapi_auth0') -auth0_rule_namespace: str = os.getenv('AUTH0_RULE_NAMESPACE', 'https://github.com/dorinclisu/fastapi-auth0') - class Auth0UnauthenticatedException(HTTPException): def __init__(self, detail: str, **kwargs): @@ -38,7 +36,7 @@ class HTTPAuth0Error(BaseModel): class Auth0User(BaseModel): id: str = Field(..., alias='sub') permissions: Optional[List[str]] = None - email: Optional[str] = Field(None, alias=f'{auth0_rule_namespace}/email') # type: ignore [literal-required] + email: Optional[str] = None class Auth0HTTPBearer(HTTPBearer): From 585da875b77cc807df999fcb41e5aa6558bcb5f3 Mon Sep 17 00:00:00 2001 From: Paul Rysiavets Date: Fri, 13 Sep 2024 09:44:42 +0200 Subject: [PATCH 07/12] Revert "feat(PyJWT): set Auth0User.email to None (as this requires Auth0 Management Access)" This reverts commit e579556354935ae88d04aa72012209b7f2193e21. --- src/fastapi_auth0/auth.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/fastapi_auth0/auth.py b/src/fastapi_auth0/auth.py index 91842da..dca0015 100644 --- a/src/fastapi_auth0/auth.py +++ b/src/fastapi_auth0/auth.py @@ -14,6 +14,8 @@ logger = logging.getLogger('fastapi_auth0') +auth0_rule_namespace: str = os.getenv('AUTH0_RULE_NAMESPACE', 'https://github.com/dorinclisu/fastapi-auth0') + class Auth0UnauthenticatedException(HTTPException): def __init__(self, detail: str, **kwargs): @@ -36,7 +38,7 @@ class HTTPAuth0Error(BaseModel): class Auth0User(BaseModel): id: str = Field(..., alias='sub') permissions: Optional[List[str]] = None - email: Optional[str] = None + email: Optional[str] = Field(None, alias=f'{auth0_rule_namespace}/email') # type: ignore [literal-required] class Auth0HTTPBearer(HTTPBearer): From 96325500a6fb9916b71888d39bb4841c7a418585 Mon Sep 17 00:00:00 2001 From: Paul Rysiavets Date: Fri, 13 Sep 2024 09:47:14 +0200 Subject: [PATCH 08/12] feat(PyJWT): verify claims by default --- src/fastapi_auth0/auth.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/fastapi_auth0/auth.py b/src/fastapi_auth0/auth.py index dca0015..93988a9 100644 --- a/src/fastapi_auth0/auth.py +++ b/src/fastapi_auth0/auth.py @@ -87,7 +87,7 @@ def __init__(self, domain: str, api_audience: str, scopes: Dict[str, str]={}, tokenUrl=f'https://{domain}/oauth/token', scopes=scopes) self.oidc_scheme = OpenIdConnect(openIdConnectUrl=f'https://{domain}/.well-known/openid-configuration') - self.options = options + self.options = options or {"require": ["exp", "iat", "sub"]} self.jwks_client = jwt.PyJWKClient(f"https://{self.domain}/.well-known/jwks.json") @@ -127,7 +127,7 @@ async def get_user(self, try: signing_key = self.jwks_client.get_signing_key_from_jwt(token) - leeway = self.options.pop("leeway", 0) if self.options else 0 + leeway = self.options.pop("leeway", 0) payload = jwt.decode( token, signing_key.key, From 700c8e370292ffa8d6684ca79e49c539086bf7d9 Mon Sep 17 00:00:00 2001 From: Paul Rysiavets Date: Fri, 13 Sep 2024 10:14:45 +0200 Subject: [PATCH 09/12] feat(PyJWT): always verify needed claims --- src/fastapi_auth0/auth.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/fastapi_auth0/auth.py b/src/fastapi_auth0/auth.py index 93988a9..8784fbf 100644 --- a/src/fastapi_auth0/auth.py +++ b/src/fastapi_auth0/auth.py @@ -87,7 +87,8 @@ def __init__(self, domain: str, api_audience: str, scopes: Dict[str, str]={}, tokenUrl=f'https://{domain}/oauth/token', scopes=scopes) self.oidc_scheme = OpenIdConnect(openIdConnectUrl=f'https://{domain}/.well-known/openid-configuration') - self.options = options or {"require": ["exp", "iat", "sub"]} + self.options = options or {} + self.options.update({"require": ["exp", "iat", "sub"]}) self.jwks_client = jwt.PyJWKClient(f"https://{self.domain}/.well-known/jwks.json") From 734eded8b9fbe7479253420d118f3bd78c118a79 Mon Sep 17 00:00:00 2001 From: Paul Rysiavets Date: Fri, 13 Sep 2024 10:20:39 +0200 Subject: [PATCH 10/12] feat(PyJWT): create an empty dict explicitly --- src/fastapi_auth0/auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fastapi_auth0/auth.py b/src/fastapi_auth0/auth.py index 8784fbf..e47cabb 100644 --- a/src/fastapi_auth0/auth.py +++ b/src/fastapi_auth0/auth.py @@ -87,7 +87,7 @@ def __init__(self, domain: str, api_audience: str, scopes: Dict[str, str]={}, tokenUrl=f'https://{domain}/oauth/token', scopes=scopes) self.oidc_scheme = OpenIdConnect(openIdConnectUrl=f'https://{domain}/.well-known/openid-configuration') - self.options = options or {} + self.options = options or dict() self.options.update({"require": ["exp", "iat", "sub"]}) self.jwks_client = jwt.PyJWKClient(f"https://{self.domain}/.well-known/jwks.json") From bb27f5d96648d0d9f36640bef79bfff4b9b92f0d Mon Sep 17 00:00:00 2001 From: Paul Rysiavets Date: Fri, 13 Sep 2024 11:31:18 +0200 Subject: [PATCH 11/12] fix(PyJWT): preserve original "leeway" value in options --- src/fastapi_auth0/auth.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/fastapi_auth0/auth.py b/src/fastapi_auth0/auth.py index e47cabb..320bfb7 100644 --- a/src/fastapi_auth0/auth.py +++ b/src/fastapi_auth0/auth.py @@ -88,7 +88,6 @@ def __init__(self, domain: str, api_audience: str, scopes: Dict[str, str]={}, scopes=scopes) self.oidc_scheme = OpenIdConnect(openIdConnectUrl=f'https://{domain}/.well-known/openid-configuration') self.options = options or dict() - self.options.update({"require": ["exp", "iat", "sub"]}) self.jwks_client = jwt.PyJWKClient(f"https://{self.domain}/.well-known/jwks.json") @@ -128,7 +127,9 @@ async def get_user(self, try: signing_key = self.jwks_client.get_signing_key_from_jwt(token) - leeway = self.options.pop("leeway", 0) + options = self.options.copy() + leeway = options.pop("leeway", 0) + options.setdefault("require", ["iss", "sub", "aud", "iat", "exp"]) payload = jwt.decode( token, signing_key.key, @@ -136,7 +137,7 @@ async def get_user(self, audience=self.audience, issuer=f"https://{self.domain}/", leeway=leeway, - options=self.options, + options=options, ) except jwt.PyJWKClientError as e: msg = str(e) From ad55984debaadd53376bc34850b13b3de32adb57 Mon Sep 17 00:00:00 2001 From: Paul Rysiavets Date: Fri, 13 Sep 2024 11:41:34 +0200 Subject: [PATCH 12/12] fix(PyJWT): set default strengthened claim verification requirements (unless overridden by developer) --- src/fastapi_auth0/auth.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/fastapi_auth0/auth.py b/src/fastapi_auth0/auth.py index 320bfb7..54a119f 100644 --- a/src/fastapi_auth0/auth.py +++ b/src/fastapi_auth0/auth.py @@ -88,6 +88,12 @@ def __init__(self, domain: str, api_audience: str, scopes: Dict[str, str]={}, scopes=scopes) self.oidc_scheme = OpenIdConnect(openIdConnectUrl=f'https://{domain}/.well-known/openid-configuration') self.options = options or dict() + self.options.setdefault("verify_signature", True) + self.options.setdefault("verify_aud", True) + self.options.setdefault("verify_iss", True) + self.options.setdefault("verify_exp", True) + self.options.setdefault("verify_iat", True) + self.options.setdefault("require", ["iss", "sub", "aud", "iat", "exp"]) self.jwks_client = jwt.PyJWKClient(f"https://{self.domain}/.well-known/jwks.json") @@ -129,7 +135,6 @@ async def get_user(self, signing_key = self.jwks_client.get_signing_key_from_jwt(token) options = self.options.copy() leeway = options.pop("leeway", 0) - options.setdefault("require", ["iss", "sub", "aud", "iat", "exp"]) payload = jwt.decode( token, signing_key.key,