Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Lazy load public key for boost start time for both unit test and app #81

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 35 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand All @@ -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
Expand All @@ -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"]
)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
{
Expand All @@ -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.
```
Expand All @@ -237,22 +241,25 @@ 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}"
```

### 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')}"
```
Expand All @@ -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.
9 changes: 9 additions & 0 deletions fastapi_cloudauth/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
21 changes: 17 additions & 4 deletions fastapi_cloudauth/verification.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,15 +62,26 @@ 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()


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)

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:
Expand Down Expand Up @@ -131,6 +142,8 @@ def null(cls: Type["JWKS"]) -> "JWKS":

@property
def expires(self) -> Optional[datetime]:
if not self.__expires:
self._refresh_keys()
return self.__expires


Expand Down
2 changes: 1 addition & 1 deletion tests/test_verification.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down