Skip to content

Commit

Permalink
Merge pull request #11 from Intility/remove_app_from_init
Browse files Browse the repository at this point in the history
v2: Remove `app` parameter from AzureAuthorizationCodeBearer, dependency now returns a User object
  • Loading branch information
JonasKs authored Aug 18, 2021
2 parents a8f108e + 8bc02f9 commit db2d852
Show file tree
Hide file tree
Showing 12 changed files with 148 additions and 49 deletions.
2 changes: 2 additions & 0 deletions .flake8
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ max-line-length = 120
ignore=
# E501: Line length
E501
# Function calls in argument defaults
B008
# Docstring at the top of a public module
D100
# Docstring at the top of a public class (method is enough)
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/coverage.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
name: coverage

on:
pull_request:
push:
branches:
- main
Expand Down
86 changes: 73 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<h1 align="center">
<img src=".github/images/intility.png" width="124px"/><br/>
<img src="https://raw.githubusercontent.com/Intility/fastapi-azure-auth/remove_app_from_init/.github/images/intility.png" width="124px"/><br/>
FastAPI-Azure-auth
</h1>

Expand Down Expand Up @@ -55,16 +55,18 @@ please use the [.NET](https://create.intility.app/dotnet/setup/authorization) do

### FastAPI

1. Install this library:
#### 1. Install this library:
```bash
pip install fastapi-azure-auth
# or
poetry add fastapi-azure-auth
```

2. Include `swagger_ui_oauth2_redirect_url` and `swagger_ui_init_oauth` in your FastAPI app initialization:
#### 2. Configure your FastAPI app
Include `swagger_ui_oauth2_redirect_url` and `swagger_ui_init_oauth` in your FastAPI app initialization:

```python
# file: main.py
app = FastAPI(
...
swagger_ui_oauth2_redirect_url='/oauth2-redirect',
Expand All @@ -75,12 +77,19 @@ app = FastAPI(
)
```

3. Ensure you have CORS enabled for your local environment, such as `http://localhost:8000`. See [main.py](main.py)
#### 3. Setup CORS
Ensure you have CORS enabled for your local environment, such as `http://localhost:8000`. See [main.py](main.py)
and the `BACKEND_CORS_ORIGINS` in [config.py](demoproj/core/config.py)

4. Import and configure your Azure authentication:
#### 4. Configure the `AzureAuthorizationCodeBearer`
You _can_ do this in `main.py`, but it's recommended to put it
in your `dependencies.py` file instead, as this will avoid circular imports later.
See the [demo project](demoproj/api/api_v1/endpoints/hello_world.py) and read the official documentation
on [bigger applications](https://fastapi.tiangolo.com/tutorial/bigger-applications/)


```python
# file: demoproj/api/dependencies.py
from fastapi_azure_auth.auth import AzureAuthorizationCodeBearer

azure_scheme = AzureAuthorizationCodeBearer(
Expand All @@ -92,47 +101,98 @@ azure_scheme = AzureAuthorizationCodeBearer(
)
```


5. Set your `intility_scheme` as a dependency for your wanted views/routers:

```python
# file: main.py
from demoproj.api.dependencies import azure_scheme

app.include_router(api_router, prefix=settings.API_V1_STR, dependencies=[Depends(azure_scheme)])
```

## ⚙️ Configuration
For those using a non-Intility tenant, you also need to make changes to the `provider_config`:
6. Load config on startup

```python
# file: main.py
from fastapi_azure_auth.provider_config import provider_config

intility_scheme = AzureAuthorizationCodeBearer(
...
)
@app.on_event('startup')
async def load_config() -> None:
"""
Load config on startup.
"""
await provider_config.load_config()
```


## ⚙️ Configuration
For those using a non-Intility tenant, you also need to make changes to the `provider_config` to match
your tenant ID. You can do this in your previously created `load_config()` function.

```python
# file: main.py
from fastapi_azure_auth.provider_config import provider_config

provider_config.tenant_id = 'my-own-tenant-id'
@app.on_event('startup')
async def load_config() -> None:
provider_config.tenant_id = 'my-own-tenant-id'
await provider_config.load_config()
```


If you want, you can deny guest users to access your API by passing the `allow_guest_users=False`
to `AzureAuthorizationCodeBearer`:

```python
intility_scheme = AzureAuthorizationCodeBearer(
# file: demoproj/api/dependencies.py
azure_scheme = AzureAuthorizationCodeBearer(
...
allow_guest_users=False
)
```

## 💡 Nice to knows

#### User object
A `User` object is attached to the request state if the token is valid. Unparsed claims can be accessed at
`request.state.user.claims`.

```python
# file: demoproj/api/api_v1/endpoints/hello_world.py
from fastapi_azure_auth.user import User
from fastapi import Request

@router.get(...)
async def world(request: Request) -> dict:
user: User = request.state.user
return {'user': user}
```
```


#### Permission checking
You often want to check that a user has a role or using a specific scope. This
can be done by creating your own dependency, which depends on `azure_scheme`. The `azure_scheme` dependency
returns a [`fastapi_azure_auth.user.User`](fastapi_azure_auth/user.py) object.

Create your new dependency, which checks that the user has the correct role (in this case the
`AdminUser`-role):

```python
# file: demoproj/api/dependencies.py
from fastapi import Depends
from fastapi_azure_auth.auth import InvalidAuth
from fastapi_azure_auth.user import User

async def validate_is_admin_user(user: User = Depends(azure_scheme)) -> None:
"""
Validated that a user is in the `AdminUser` role in order to access the API.
Raises a 401 authentication error if not.
"""
if 'AdminUser' not in user.roles:
raise InvalidAuth('User is not an AdminUser')
```

Add the new dependency on either your route or on the API, as we've
done in our [demo project](demoproj/api/api_v1/endpoints/hello_world.py).

4 changes: 3 additions & 1 deletion demoproj/api/api_v1/endpoints/hello_world.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from demoproj.api.dependencies import validate_is_admin_user
from demoproj.schemas.hello_world import HelloWorldResponse
from fastapi import APIRouter, Request
from fastapi import APIRouter, Depends, Request

from fastapi_azure_auth.user import User

Expand All @@ -12,6 +13,7 @@
summary='Say hello',
name='hello_world',
operation_id='helloWorld',
dependencies=[Depends(validate_is_admin_user)],
)
async def world(request: Request) -> dict:
"""
Expand Down
21 changes: 21 additions & 0 deletions demoproj/api/dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from demoproj.core.config import settings
from fastapi import Depends

from fastapi_azure_auth.auth import AzureAuthorizationCodeBearer, InvalidAuth
from fastapi_azure_auth.user import User

azure_scheme = AzureAuthorizationCodeBearer(
app_client_id=settings.APP_CLIENT_ID,
scopes={
f'api://{settings.APP_CLIENT_ID}/user_impersonation': '**No client secret needed, leave blank**',
},
)


async def validate_is_admin_user(user: User = Depends(azure_scheme)) -> None:
"""
Validated that a user is in the `AdminUser` role in order to access the API.
Raises a 401 authentication error if not.
"""
if 'AdminUser' not in user.roles:
raise InvalidAuth('User is not an AdminUser')
2 changes: 1 addition & 1 deletion fastapi_azure_auth/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = '1.1.1'
__version__ = '2.0.0'
17 changes: 4 additions & 13 deletions fastapi_azure_auth/auth.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import base64
import json
import logging
from typing import Any, Dict, Optional
from typing import Dict, Optional

from fastapi import FastAPI, status
from fastapi import status
from fastapi.exceptions import HTTPException
from fastapi.security import OAuth2AuthorizationCodeBearer
from jose import jwt
Expand Down Expand Up @@ -39,7 +39,6 @@ class GuestUserException(Exception):
class AzureAuthorizationCodeBearer(OAuth2AuthorizationCodeBearer):
def __init__(
self,
app: FastAPI,
app_client_id: str,
scopes: Optional[Dict[str, str]] = None,
allow_guest_users: bool = True,
Expand All @@ -48,7 +47,6 @@ def __init__(
"""
Initialize settings.
:param app: Your FastAPI app.
:param app_client_id: Client ID for this app (not your SPA)
:param scopes: Scopes, these are the ones you've configured in Azure AD. Key is scope, value is a description.
Example:
Expand All @@ -61,21 +59,14 @@ def __init__(
self.app_client_id: str = app_client_id
self.allow_guest_users: bool = allow_guest_users

@app.on_event('startup')
async def load_config() -> None:
"""
Load config on startup.
"""
await provider_config.load_config() # pragma: no cover

super().__init__(
authorizationUrl=f'https://login.microsoftonline.com/{provider_config.tenant_id}/oauth2/v2.0/authorize',
tokenUrl=f'https://login.microsoftonline.com/{provider_config.tenant_id}/oauth2/v2.0/token',
scopes=scopes,
description='`Leave client_secret blank`',
)

async def __call__(self, request: Request) -> dict[str, Any]:
async def __call__(self, request: Request) -> User:
"""
Extends call to also validate the token
"""
Expand Down Expand Up @@ -128,7 +119,7 @@ async def __call__(self, request: Request) -> dict[str, Any]:
# Attach the user to the request. Can be accessed through `request.state.user`
user: User = User(**token | {'claims': token})
request.state.user = user
return token
return user
except GuestUserException:
raise InvalidAuth('Guest users not allowed')
except JWTClaimsError as error:
Expand Down
1 change: 1 addition & 0 deletions fastapi_azure_auth/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ class User(BaseModel):
upn: str = Field(..., description='UPN')
roles: list[str] = Field(default=[], description='Roles (Groups) the user has for this app')
claims: dict = Field(..., description='The entire decoded token')
scp: Optional[str] = Field(default=None, description='Scope')
25 changes: 14 additions & 11 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@

import uvicorn
from demoproj.api.api_v1.api import api_router
from demoproj.api.dependencies import azure_scheme
from demoproj.core.config import settings
from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware

from fastapi_azure_auth.auth import AzureAuthorizationCodeBearer
from fastapi_azure_auth.provider_config import provider_config

log = logging.getLogger(__name__)

Expand All @@ -30,16 +31,18 @@
allow_headers=['*'],
)

azure_scheme = AzureAuthorizationCodeBearer(
app=app,
app_client_id=settings.APP_CLIENT_ID,
scopes={
f'api://{settings.APP_CLIENT_ID}/user_impersonation': '**No client secret needed, leave blank**',
},
)
# For non-Intility tenants, you need to configure the provider_config to match your own tenant ID:
# from fastapi_azure_auth.provider_config import provider_config
# provider_config.tenant_id = 'my-tenant-id'

@app.on_event('startup')
async def load_config() -> None:
"""
Load config on startup.
"""
# For non-Intility tenants, you need to configure the provider_config to match your own tenant ID:
# from fastapi_azure_auth.provider_config import provider_config

# provider_config.tenant_id = 'my-tenant-id'
await provider_config.load_config()


app.include_router(api_router, prefix=settings.API_V1_STR, dependencies=[Depends(azure_scheme)])

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "fastapi-azure-auth"
version = "1.1.1" # Remember to change in __init__.py as well
version = "2.0.0" # Remember to change in __init__.py as well
description = "Easy and secure implementation of Azure AD for your FastAPI APIs"
authors = ["Jonas Krüger Svensson <jonas.svensson@intility.no>"]
readme = "README.md"
Expand Down
Loading

0 comments on commit db2d852

Please sign in to comment.