Skip to content

Commit b71b841

Browse files
committed
v0.3.0 - new features, breaking API changes and 100% test coverage
### Added - OAuth2 and OIDC can now be enabled by just passing an OIDC discovery URL to `FastAPISecurity.init_oauth2_through_oidc` - Cached data is now used for JWKS and OIDC endpoints in case the "refresh requests" fail. ### Changed - `UserPermission` objects are now created via `FastAPISecurity.user_permission`. - `FastAPISecurity.init` was split into three distinct methods: `.init_basic_auth`, `.init_oauth2_through_oidc` and `.init_oauth2_through_jwks`. - Broke out the `permission_overrides` argument from the old `.init` method and added a distinct method for adding new overrides `add_permission_overrides`. This method can be called multiple times. - The dependency `FastAPISecurity.has_permission` and `FastAPISecurity.user_with_permissions` has been replaced by `FastAPISecurity.user_holding`. API is the same (takes a variable number of UserPermission arguments, i.e. compatible with both). ### Removed - Remove `app` argument to the `FastAPISecurity.init...` methods (it wasn't used before) - The global permissions registry has been removed. Now there should be no global mutable state left.
1 parent cb134b9 commit b71b841

36 files changed

+1280
-406
lines changed

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Changelog
2+
All notable changes to this project will be documented in this file.
3+
4+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6+
7+
## [Unreleased]
8+
9+
- Nothing
10+
11+
## [0.3.0](https://github.com/jmagnusson/fastapi-security/compare/v0.2.0...v0.3.0) - 2021-03-26
12+
13+
### Added
14+
15+
- OAuth2 and OIDC can now be enabled by just passing an OIDC discovery URL to `FastAPISecurity.init_oauth2_through_oidc`
16+
- Cached data is now used for JWKS and OIDC endpoints in case the "refresh requests" fail.
17+
18+
### Changed
19+
- `UserPermission` objects are now created via `FastAPISecurity.user_permission`.
20+
- `FastAPISecurity.init` was split into three distinct methods: `.init_basic_auth`, `.init_oauth2_through_oidc` and `.init_oauth2_through_jwks`.
21+
- Broke out the `permission_overrides` argument from the old `.init` method and added a distinct method for adding new overrides `add_permission_overrides`. This method can be called multiple times.
22+
- The dependency `FastAPISecurity.has_permission` and `FastAPISecurity.user_with_permissions` has been replaced by `FastAPISecurity.user_holding`. API is the same (takes a variable number of UserPermission arguments, i.e. compatible with both).
23+
24+
### Removed
25+
- Remove `app` argument to the `FastAPISecurity.init...` methods (it wasn't used before)
26+
- The global permissions registry has been removed. Now there should be no global mutable state left.

examples/app1/README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1-
# FastAPI Security Example App
1+
# FastAPI-Security Example App
22

33
To try out:
44

55
```bash
66
pip install fastapi-security uvicorn
7+
export OIDC_DISCOVERY_URL='https://my-auth0-tenant.eu.auth0.com/.well-known/openid-configuration'
8+
export OAUTH2_AUDIENCES='["my-audience"]'
79
export BASIC_AUTH_CREDENTIALS='[{"username": "user1", "password": "test"}]'
8-
export AUTH_JWKS_URL='https://my-auth0-tenant.eu.auth0.com/.well-known/jwks.json'
9-
export AUTH_AUDIENCES='["my-audience"]'
1010
export PERMISSION_OVERRIDES='{"user1": ["products:create"]}'
1111
uvicorn app1:app
1212
```
13+
14+
You would need to replace the `my-auth0-tenant.eu.auth0.com` part to make it work.

examples/app1/app.py

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
from fastapi import Depends, FastAPI
55

6-
from fastapi_security import FastAPISecurity, User, UserPermission
6+
from fastapi_security import FastAPISecurity, User
77

88
from . import db
99
from .models import Product
@@ -15,18 +15,26 @@
1515

1616
security = FastAPISecurity()
1717

18-
security.init(
19-
app,
20-
basic_auth_credentials=settings.basic_auth_credentials,
21-
jwks_url=settings.oauth2_jwks_url,
22-
audiences=settings.oauth2_audiences,
23-
oidc_discovery_url=settings.oidc_discovery_url,
24-
permission_overrides=settings.permission_overrides,
25-
)
18+
if settings.basic_auth_credentials:
19+
security.init_basic_auth(settings.basic_auth_credentials)
20+
21+
if settings.oidc_discovery_url:
22+
security.init_oauth2_through_oidc(
23+
settings.oidc_discovery_url,
24+
audiences=settings.oauth2_audiences,
25+
)
26+
elif settings.oauth2_jwks_url:
27+
security.init_oauth2_through_jwks(
28+
settings.oauth2_jwks_url,
29+
audiences=settings.oauth2_audiences,
30+
)
31+
32+
security.add_permission_overrides(settings.permission_overrides or {})
33+
2634

2735
logger = logging.getLogger(__name__)
2836

29-
create_product_perm = UserPermission("products:create")
37+
create_product_perm = security.user_permission("products:create")
3038

3139

3240
@app.get("/users/me")
@@ -41,10 +49,10 @@ def get_user_permissions(user: User = Depends(security.authenticated_user_or_401
4149
return user.permissions
4250

4351

44-
@app.post("/products", response_model=Product)
52+
@app.post("/products", response_model=Product, status_code=201)
4553
async def create_product(
4654
product: Product,
47-
user: User = Depends(security.user_with_permissions(create_product_perm)),
55+
user: User = Depends(security.user_holding(create_product_perm)),
4856
):
4957
"""Create product
5058

examples/app1/settings.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
11
from functools import lru_cache
2-
from typing import Dict, List, Optional
2+
from typing import List, Optional
33

4-
from fastapi.security import HTTPBasicCredentials
54
from pydantic import BaseSettings
65

6+
from fastapi_security import HTTPBasicCredentials, PermissionOverrides
7+
78
__all__ = ("get_settings",)
89

910

1011
class _Settings(BaseSettings):
11-
oauth2_jwks_url: Optional[
12-
str
13-
] = None # TODO: This could be retrieved from OIDC discovery URL
12+
# NOTE: You only need to supply `oidc_discovery_url` (preferred) OR `oauth2_jwks_url`
13+
oidc_discovery_url: Optional[str] = None
14+
oauth2_jwks_url: Optional[str] = None
1415
oauth2_audiences: Optional[List[str]] = None
1516
basic_auth_credentials: Optional[List[HTTPBasicCredentials]] = None
16-
oidc_discovery_url: Optional[str] = None
17-
permission_overrides: Optional[Dict[str, List[str]]] = None
17+
permission_overrides: PermissionOverrides = {}
1818

1919

2020
@lru_cache()

fastapi_security/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,4 @@
44
from .oauth2 import * # noqa
55
from .oidc import * # noqa
66
from .permissions import * # noqa
7-
from .registry import * # noqa
87
from .schemes import * # noqa

fastapi_security/api.py

Lines changed: 79 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,15 @@
11
import logging
2-
from typing import Callable, Dict, List, Optional
2+
from typing import Callable, Iterable, List, Optional, Type
33

4-
from fastapi import Depends, FastAPI, HTTPException
5-
from fastapi.security import HTTPBasicCredentials
4+
from fastapi import Depends, HTTPException
65
from fastapi.security.http import HTTPAuthorizationCredentials
76

8-
from . import registry
9-
from .basic import BasicAuthValidator
7+
from .basic import BasicAuthValidator, IterableOfHTTPBasicCredentials
108
from .entities import AuthMethod, User, UserAuth, UserInfo
119
from .exceptions import AuthNotConfigured
1210
from .oauth2 import Oauth2JwtAccessTokenValidator
1311
from .oidc import OpenIdConnectDiscovery
14-
from .permissions import UserPermission
12+
from .permissions import PermissionOverrides, UserPermission
1513
from .schemes import http_basic_scheme, jwt_bearer_scheme
1614

1715
logger = logging.getLogger(__name__)
@@ -25,34 +23,60 @@ class FastAPISecurity:
2523
Must be initialized after object creation via the `init()` method.
2624
"""
2725

28-
def __init__(self):
26+
def __init__(self, *, user_permission_class: Type[UserPermission] = UserPermission):
2927
self.basic_auth = BasicAuthValidator()
3028
self.oauth2_jwt = Oauth2JwtAccessTokenValidator()
3129
self.oidc_discovery = OpenIdConnectDiscovery()
32-
self._permission_overrides = None
33-
34-
def init(
35-
self,
36-
app: FastAPI,
37-
basic_auth_credentials: List[HTTPBasicCredentials] = None,
38-
permission_overrides: Dict[str, List[str]] = None,
39-
jwks_url: str = None,
40-
audiences: List[str] = None,
41-
oidc_discovery_url: str = None,
30+
self._permission_overrides: PermissionOverrides = {}
31+
self._user_permission_class = user_permission_class
32+
self._all_permissions: List[UserPermission] = []
33+
self._oauth2_init_through_oidc = False
34+
self._oauth2_audiences: List[str] = []
35+
36+
def init_basic_auth(self, basic_auth_credentials: IterableOfHTTPBasicCredentials):
37+
self.basic_auth.init(basic_auth_credentials)
38+
39+
def init_oauth2_through_oidc(
40+
self, oidc_discovery_url: str, *, audiences: Iterable[str] = None
4241
):
43-
self._permission_overrides = permission_overrides
42+
"""Initialize OIDC and OAuth2 authentication/authorization
4443
45-
if basic_auth_credentials:
46-
# Initialize basic auth (superusers with all permissions)
47-
self.basic_auth.init(basic_auth_credentials)
44+
OAuth2 JWKS URL is lazily fetched from the OIDC endpoint once it's needed for the first time.
45+
46+
This method is preferred over `init_oauth2_through_jwks` as you get all the
47+
benefits of OIDC, with less configuration supplied.
48+
"""
49+
self._oauth2_audiences.extend(audiences or [])
50+
self.oidc_discovery.init(oidc_discovery_url)
4851

49-
if jwks_url:
50-
# # Initialize OAuth 2.0 - user permissions are required for all flows
51-
# # except Client Credentials
52-
self.oauth2_jwt.init(jwks_url, audiences=audiences or [])
52+
def init_oauth2_through_jwks(
53+
self, jwks_uri: str, *, audiences: Iterable[str] = None
54+
):
55+
"""Initialize OAuth2
56+
57+
It's recommended to use `init_oauth2_through_oidc` instead.
58+
"""
59+
self._oauth2_audiences.extend(audiences or [])
60+
self.oauth2_jwt.init(jwks_uri, audiences=self._oauth2_audiences)
5361

54-
if oidc_discovery_url and self.oauth2_jwt.is_configured():
55-
self.oidc_discovery.init(oidc_discovery_url)
62+
def add_permission_overrides(self, overrides: PermissionOverrides):
63+
"""Add wildcard or specific permissions to basic auth and/or OAuth2 users
64+
65+
Example:
66+
security = FastAPISecurity()
67+
create_product = security.user_permission("products:create")
68+
69+
# Give all permissions to the user johndoe
70+
security.add_permission_overrides({"johndoe": "*"})
71+
72+
# Give the OAuth2 user `7ZmI5ycgNHeZ9fHPZZwTNbIRd9Ectxca@clients` the
73+
# "products:create" permission.
74+
security.add_permission_overrides({
75+
"7ZmI5ycgNHeZ9fHPZZwTNbIRd9Ectxca@clients": ["products:create"],
76+
})
77+
78+
"""
79+
self._permission_overrides.update(overrides)
5680

5781
@property
5882
def user(self) -> Callable:
@@ -79,7 +103,7 @@ def user_with_info(self) -> Callable:
79103
"""Dependency that returns User object with user info, authenticated or not"""
80104

81105
async def dependency(user_auth: UserAuth = Depends(self._user_auth)):
82-
if user_auth.is_oauth2():
106+
if user_auth.is_oauth2() and user_auth.access_token:
83107
info = await self.oidc_discovery.get_user_info(user_auth.access_token)
84108
else:
85109
info = UserInfo.make_dummy()
@@ -94,26 +118,20 @@ def authenticated_user_with_info_or_401(self) -> Callable:
94118
"""
95119

96120
async def dependency(user_auth: UserAuth = Depends(self._user_auth_or_401)):
97-
if user_auth.is_oauth2():
121+
if user_auth.is_oauth2() and user_auth.access_token:
98122
info = await self.oidc_discovery.get_user_info(user_auth.access_token)
99123
else:
100124
info = UserInfo.make_dummy()
101125
return User(auth=user_auth, info=info)
102126

103127
return dependency
104128

105-
def has_permission(self, permission: UserPermission) -> Callable:
106-
"""Dependency that raises HTTP403 if the user is missing the given permission"""
129+
def user_permission(self, identifier: str) -> UserPermission:
130+
perm = self._user_permission_class(identifier)
131+
self._all_permissions.append(perm)
132+
return perm
107133

108-
async def dependency(
109-
user: User = Depends(self.authenticated_user_or_401),
110-
) -> User:
111-
self._has_permission_or_raise_forbidden(user, permission)
112-
return user
113-
114-
return dependency
115-
116-
def user_with_permissions(self, *permissions: UserPermission) -> Callable:
134+
def user_holding(self, *permissions: UserPermission) -> Callable:
117135
"""Dependency that returns the user if it has the given permissions, otherwise
118136
raises HTTP403
119137
"""
@@ -137,12 +155,17 @@ async def dependency(
137155
),
138156
http_credentials: HTTPAuthorizationCredentials = Depends(http_basic_scheme),
139157
) -> Optional[UserAuth]:
140-
if not any(
141-
[self.oauth2_jwt.is_configured(), self.basic_auth.is_configured()]
142-
):
158+
oidc_configured = self.oidc_discovery.is_configured()
159+
oauth2_configured = self.oauth2_jwt.is_configured()
160+
basic_auth_configured = self.basic_auth.is_configured()
143161

162+
if not any([oidc_configured, oauth2_configured, basic_auth_configured]):
144163
raise AuthNotConfigured()
145164

165+
if oidc_configured and not oauth2_configured:
166+
jwks_uri = await self.oidc_discovery.get_jwks_uri()
167+
self.init_oauth2_through_jwks(jwks_uri)
168+
146169
if bearer_credentials is not None:
147170
bearer_token = bearer_credentials.credentials
148171
access_token = await self.oauth2_jwt.parse(bearer_token)
@@ -199,16 +222,21 @@ def _raise_forbidden(self, required_permission: str):
199222
)
200223

201224
def _maybe_override_permissions(self, user_auth: UserAuth) -> UserAuth:
202-
overrides = (self._permission_overrides or {}).get(user_auth.subject)
203-
204-
if overrides is None:
205-
return user_auth
225+
overrides = self._permission_overrides.get(user_auth.subject)
206226

207-
all_permissions = registry.get_all_permissions()
227+
all_permission_identifiers = [p.identifier for p in self._all_permissions]
208228

209-
if "*" in overrides:
210-
return user_auth.with_permissions(all_permissions)
229+
if overrides is None:
230+
return user_auth.with_permissions(
231+
[
232+
incoming_id
233+
for incoming_id in user_auth.permissions
234+
if incoming_id in all_permission_identifiers
235+
]
236+
)
237+
elif "*" in overrides:
238+
return user_auth.with_permissions(all_permission_identifiers)
211239
else:
212240
return user_auth.with_permissions(
213-
[p for p in overrides if p in all_permissions]
241+
[p for p in overrides if p in all_permission_identifiers]
214242
)

fastapi_security/basic.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
import secrets
2-
from typing import Dict, List, Union
2+
from typing import Dict, Iterable, List, Union
33

44
from fastapi.security.http import HTTPBasicCredentials
55

6-
__all__ = ()
6+
__all__ = ("HTTPBasicCredentials",)
77

8-
ListOfCredentials = List[Union[HTTPBasicCredentials, Dict]]
8+
IterableOfHTTPBasicCredentials = Iterable[Union[HTTPBasicCredentials, Dict]]
99

1010

1111
class BasicAuthValidator:
1212
def __init__(self):
1313
self._credentials = []
1414

15-
def init(self, credentials: ListOfCredentials):
15+
def init(self, credentials: IterableOfHTTPBasicCredentials):
1616
self._credentials = self._make_credentials(credentials)
1717

1818
def is_configured(self) -> bool:
@@ -29,7 +29,9 @@ def validate(self, credentials: HTTPBasicCredentials) -> bool:
2929
for c in self._credentials
3030
)
3131

32-
def _make_credentials(self, credentials: ListOfCredentials):
32+
def _make_credentials(
33+
self, credentials: IterableOfHTTPBasicCredentials
34+
) -> List[HTTPBasicCredentials]:
3335
return [
3436
c if isinstance(c, HTTPBasicCredentials) else HTTPBasicCredentials(**c)
3537
for c in credentials

0 commit comments

Comments
 (0)