From 96397867edf979b5f149b012020a39735a060c67 Mon Sep 17 00:00:00 2001 From: Kidy Lee Date: Mon, 13 Mar 2023 22:11:12 +0800 Subject: [PATCH 1/2] Lazy load public key for boost start time for both of unit test and fastAPI application. --- fastapi_cloudauth/verification.py | 8 ++++---- tests/test_verification.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/fastapi_cloudauth/verification.py b/fastapi_cloudauth/verification.py index 4c51c9b..c8cc451 100644 --- a/fastapi_cloudauth/verification.py +++ b/fastapi_cloudauth/verification.py @@ -62,15 +62,13 @@ def __init__( self.__expires: Optional[datetime] = None self.__refreshing = Event() self.__refreshing.set() - if self.__fixed_keys is None: - # query jwks from provider without mutex - self._refresh_keys() + async def get_publickey(self, kid: str) -> Optional[Key]: if self.__fixed_keys is not None: return self.__fixed_keys.get(kid) - if self.__expires is not None: + if self.expires is not None: # Check expiration current_time = datetime.now(tz=self.__expires.tzinfo) if current_time >= self.__expires: @@ -131,6 +129,8 @@ def null(cls: Type["JWKS"]) -> "JWKS": @property def expires(self) -> Optional[datetime]: + if not self.__expires: + self._refresh_keys() return self.__expires diff --git a/tests/test_verification.py b/tests/test_verification.py index 8b8b0ef..59959df 100644 --- a/tests/test_verification.py +++ b/tests/test_verification.py @@ -149,7 +149,7 @@ async def test_refresh_jwks_multiple(mocker): ) # jwks was refreshed only at once (counter incremented once). # all three return publickey from refreshed jwks. - assert list(res) == [2, 2, 2] + assert list(res) == [1, 1, 1] @pytest.mark.unittest From 6696a8c84f5e87aa93bbb39373187f3e14312b4d Mon Sep 17 00:00:00 2001 From: Kidy Lee Date: Tue, 14 Mar 2023 19:46:32 +0800 Subject: [PATCH 2/2] For supporting override deps, adding __eq__ and __hash__ for instance comparison. --- README.md | 47 +++++++++++++++++++++++-------- fastapi_cloudauth/base.py | 9 ++++++ fastapi_cloudauth/verification.py | 13 +++++++++ 3 files changed, 57 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index f9da168..bd5b2e1 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,13 @@ [![codecov](https://codecov.io/gh/tokusumi/fastapi-cloudauth/branch/master/graph/badge.svg)](https://codecov.io/gh/tokusumi/fastapi-cloudauth) [![PyPI version](https://badge.fury.io/py/fastapi-cloudauth.svg)](https://badge.fury.io/py/fastapi-cloudauth) -fastapi-cloudauth standardizes and simplifies the integration between FastAPI and cloud authentication services (AWS Cognito, Auth0, Firebase Authentication). +fastapi-cloudauth standardizes and simplifies the integration between FastAPI and cloud authentication services (AWS +Cognito, Auth0, Firebase Authentication). ## Features * [X] Verify access/id token: standard JWT validation (signature, expiration), token audience claims, etc. -* [X] Verify permissions based on scope (or groups) within access token and extract user info +* [X] Verify permissions based on scope (or groups) within access token and extract user info * [X] Get the detail of login user info (name, email, etc.) within ID token * [X] Dependency injection for verification/getting user, powered by [FastAPI](https://github.com/tiangolo/fastapi) * [X] Support for: @@ -32,10 +33,11 @@ $ pip install fastapi-cloudauth ### Pre-requirements * Check `region`, `userPoolID` and `AppClientID` of AWS Cognito that you manage to -* Create a user's assigned `read:users` permission in AWS Cognito +* Create a user's assigned `read:users` permission in AWS Cognito * Get Access/ID token for the created user -NOTE: access token is valid for verification, scope-based authentication, and getting user info (optional). ID token is valid for verification and getting full user info from claims. +NOTE: access token is valid for verification, scope-based authentication, and getting user info (optional). ID token is +valid for verification and getting full user info from claims. ### Create it @@ -49,11 +51,12 @@ from fastapi_cloudauth.cognito import Cognito, CognitoCurrentUser, CognitoClaims app = FastAPI() auth = Cognito( - region=os.environ["REGION"], + region=os.environ["REGION"], userPoolId=os.environ["USERPOOLID"], client_id=os.environ["APPCLIENTID"] ) + @app.get("/", dependencies=[Depends(auth.scope(["read:users"]))]) def secure(): # access token is valid @@ -71,7 +74,7 @@ def secure_access(current_user: AccessUser = Depends(auth.claim(AccessUser))): get_current_user = CognitoCurrentUser( - region=os.environ["REGION"], + region=os.environ["REGION"], userPoolId=os.environ["USERPOOLID"], client_id=os.environ["APPCLIENTID"] ) @@ -106,13 +109,12 @@ You can supply a token and try the endpoint interactively. ![Swagger UI](https://raw.githubusercontent.com/tokusumi/fastapi-cloudauth/master/docs/src/authorize_in_doc.jpg) - ## Example (Auth0) ### Pre-requirement * Check `domain`, `customAPI` (Audience) and `ClientID` of Auth0 that you manage to -* Create a user assigned `read:users` permission in Auth0 +* Create a user assigned `read:users` permission in Auth0 * Get Access/ID token for the created user ### Create it @@ -160,7 +162,6 @@ def secure_user(current_user: Auth0Claims = Depends(get_current_user)): Try to run the server and see interactive UI in the same way. - ## Example (Firebase Authentication) ### Pre-requirement @@ -197,7 +198,8 @@ We can get values for the current user from access/ID token by writing a few lin ### Custom Claims -For Auth0, the ID token contains the following extra values (Ref at [Auth0 official doc](https://auth0.com/docs/tokens)): +For Auth0, the ID token contains the following extra values (Ref +at [Auth0 official doc](https://auth0.com/docs/tokens)): ```json { @@ -224,10 +226,12 @@ Here is sample code for extracting extra user information (adding `user_id`) fro from pydantic import Field from fastapi_cloudauth.auth0 import Auth0Claims # base current user info model (inheriting `pydantic`). + # extend current user info model by `pydantic`. class CustomAuth0Claims(Auth0Claims): user_id: str = Field(alias="sub") + get_current_user = Auth0CurrentUser(domain=DOMAIN, client_id=CLIENTID) get_current_user.user_info = CustomAuth0Claims # override user info model with a custom one. ``` @@ -237,6 +241,7 @@ Or, we can set new custom claims as follows: ```python3 get_user_detail = get_current_user.claim(CustomAuth0Claims) + @app.get("/new/") async def detail(user: CustomAuth0Claims = Depends(get_user_detail)): return f"Hello, {user.user_id}" @@ -244,15 +249,17 @@ async def detail(user: CustomAuth0Claims = Depends(get_user_detail)): ### Raw payload -If you don't require `pydantic` data serialization (validation), `FastAPI-CloudAuth` has an option to extract the raw payload. +If you don't require `pydantic` data serialization (validation), `FastAPI-CloudAuth` has an option to extract the raw +payload. All you need is: ```python3 get_raw_info = get_current_user.claim(None) + @app.get("/new/") -async def raw_detail(user = Depends(get_raw_info)): +async def raw_detail(user=Depends(get_raw_info)): # user has all items (ex. iss, sub, aud, exp, ... it depends on passed token) return f"Hello, {user.get('sub')}" ``` @@ -272,15 +279,31 @@ Use as (`auth` is this instanse and `app` is fastapi.FastAPI instanse): from fastapi import Depends from fastapi_cloudauth import Operator + @app.get("/", dependencies=[Depends(auth.scope(["allowned", "scopes"]))]) def api_all_scope(): return "user has 'allowned' and 'scopes' scopes" + @app.get("/", dependencies=[Depends(auth.scope(["allowned", "scopes"], op=Operator._any))]) def api_any_scope(): return "user has at least one of scopes (allowned, scopes)" ``` +## Unit testing + +For testing, fastAPI has a comprehensive document about how to use `overrider_dependencies` and `TestClient` to test the +endpoints. + +Please refer to [Testing Dependencies with Overrides](https://fastapi.tiangolo.com/advanced/testing-dependencies/). + +```python +client = TestClient(app) + +app.dependency_overrides[auth.scope(["allowned", "scopes"], op=Operator._any)] = lambda: True + +``` + ## Development - Contributing Please read [CONTRIBUTING](./CONTRIBUTING.md) for how to set up the development environment and testing. diff --git a/fastapi_cloudauth/base.py b/fastapi_cloudauth/base.py index 052c79d..d1c067b 100644 --- a/fastapi_cloudauth/base.py +++ b/fastapi_cloudauth/base.py @@ -196,6 +196,7 @@ def __init__( op: Operator = Operator._all, extra: Optional[ExtraVerifier] = None, ): + self.jwks = jwks self.user_info = user_info self.auto_error = auto_error self._scope_name = scope_name @@ -213,6 +214,14 @@ def __init__( extra=extra, ) + def __eq__(self, other: object) -> bool: + if not isinstance(other, ScopedAuth): + return False + return self.jwks == other.jwks and self.scope_name == other.scope_name + + def __hash__(self) -> int: + return hash((self.jwks, "".join(self.scope_name or []))) + @property def verifier(self) -> ScopedJWKsVerifier: return self._verifier diff --git a/fastapi_cloudauth/verification.py b/fastapi_cloudauth/verification.py index c8cc451..38ab92f 100644 --- a/fastapi_cloudauth/verification.py +++ b/fastapi_cloudauth/verification.py @@ -64,6 +64,19 @@ def __init__( self.__refreshing.set() + def __eq__(self, other: object) -> bool: + """ + Compare two JWKS instance + Args: + other: JWKS instance + """ + if isinstance(other, JWKS): + return self.__url == other.__url + return False + + def __hash__(self) -> int: + return hash(self.__url) + async def get_publickey(self, kid: str) -> Optional[Key]: if self.__fixed_keys is not None: return self.__fixed_keys.get(kid)