fastapi-identity-model 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastapi_identity_model/__init__.py +49 -0
- fastapi_identity_model/config.py +83 -0
- fastapi_identity_model/dependencies.py +287 -0
- fastapi_identity_model/middleware.py +180 -0
- fastapi_identity_model/py.typed +0 -0
- fastapi_identity_model/rp.py +416 -0
- fastapi_identity_model/token_manager.py +162 -0
- fastapi_identity_model-0.1.0.dist-info/METADATA +139 -0
- fastapi_identity_model-0.1.0.dist-info/RECORD +10 -0
- fastapi_identity_model-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""fastapi-identity-model: OIDC/OAuth2 integration for FastAPI.
|
|
2
|
+
|
|
3
|
+
Built on `py-identity-model`. Provides:
|
|
4
|
+
|
|
5
|
+
- ``TokenValidationMiddleware`` — validate incoming Bearer tokens (resource server).
|
|
6
|
+
- ``build_oidc_router`` — a mountable authorization-code + PKCE login flow (RP).
|
|
7
|
+
- ``Depends``-based helpers — ``get_current_user``, ``require_scope``, ``require_claim`` …
|
|
8
|
+
- ``TokenManager`` — access-token refresh lifecycle.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
12
|
+
|
|
13
|
+
from .config import OIDCSettings
|
|
14
|
+
from .dependencies import (
|
|
15
|
+
Claims,
|
|
16
|
+
CurrentUser,
|
|
17
|
+
get_claim_value,
|
|
18
|
+
get_claim_values,
|
|
19
|
+
get_claims,
|
|
20
|
+
get_current_user,
|
|
21
|
+
get_token,
|
|
22
|
+
require_claim,
|
|
23
|
+
require_scope,
|
|
24
|
+
)
|
|
25
|
+
from .middleware import TokenValidationMiddleware
|
|
26
|
+
from .rp import build_oidc_router
|
|
27
|
+
from .token_manager import TokenManager
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
__version__ = version("fastapi-identity-model")
|
|
32
|
+
except PackageNotFoundError: # pragma: no cover - only during local, uninstalled use
|
|
33
|
+
__version__ = "0.0.0"
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"Claims",
|
|
37
|
+
"CurrentUser",
|
|
38
|
+
"OIDCSettings",
|
|
39
|
+
"TokenManager",
|
|
40
|
+
"TokenValidationMiddleware",
|
|
41
|
+
"build_oidc_router",
|
|
42
|
+
"get_claim_value",
|
|
43
|
+
"get_claim_values",
|
|
44
|
+
"get_claims",
|
|
45
|
+
"get_current_user",
|
|
46
|
+
"get_token",
|
|
47
|
+
"require_claim",
|
|
48
|
+
"require_scope",
|
|
49
|
+
]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Typed configuration for the FastAPI OIDC integration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _default_excluded_paths() -> list[str]:
|
|
10
|
+
return ["/docs", "/openapi.json", "/health"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class OIDCSettings:
|
|
15
|
+
"""Configuration shared by the RP login router and the resource-server middleware.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
discovery_url: OpenID Connect discovery document URL of the provider.
|
|
19
|
+
client_id: OAuth2 client identifier.
|
|
20
|
+
redirect_uri: Absolute callback URL registered with the provider
|
|
21
|
+
(must match the router's ``/callback`` route). Only required for the
|
|
22
|
+
login router; a resource-server-only deployment may leave it empty.
|
|
23
|
+
client_secret: Client secret; omit for public/PKCE clients.
|
|
24
|
+
scope: Space-delimited scopes requested at authorization.
|
|
25
|
+
audience: Expected ``aud`` for the resource-server middleware. Defaults
|
|
26
|
+
to ``client_id`` when not set.
|
|
27
|
+
post_login_redirect: Where ``/callback`` redirects after a successful login.
|
|
28
|
+
post_logout_redirect: Where ``/logout`` redirects after clearing the session.
|
|
29
|
+
excluded_paths: Paths the resource-server middleware skips (health/docs).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
discovery_url: str
|
|
33
|
+
client_id: str
|
|
34
|
+
redirect_uri: str = ""
|
|
35
|
+
client_secret: str | None = None
|
|
36
|
+
scope: str = "openid profile email"
|
|
37
|
+
audience: str | None = None
|
|
38
|
+
post_login_redirect: str = "/"
|
|
39
|
+
post_logout_redirect: str = "/"
|
|
40
|
+
excluded_paths: list[str] = field(default_factory=_default_excluded_paths)
|
|
41
|
+
|
|
42
|
+
def __post_init__(self) -> None:
|
|
43
|
+
# Validate required fields are non-empty even on direct construction
|
|
44
|
+
# (from_env guards its own inputs, but OIDCSettings(...) did not).
|
|
45
|
+
for name in ("discovery_url", "client_id"):
|
|
46
|
+
if not getattr(self, name):
|
|
47
|
+
raise ValueError(f"OIDCSettings requires a non-empty {name}")
|
|
48
|
+
# The middleware validates the ID/access token audience; default it to
|
|
49
|
+
# the client_id, which is the audience Descope and most OPs mint.
|
|
50
|
+
if self.audience is None:
|
|
51
|
+
self.audience = self.client_id
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_env(cls, prefix: str = "OIDC_") -> OIDCSettings:
|
|
55
|
+
"""Build settings from environment variables (e.g. ``OIDC_DISCOVERY_URL``).
|
|
56
|
+
|
|
57
|
+
Required: ``{prefix}DISCOVERY_URL``, ``{prefix}CLIENT_ID``. ``REDIRECT_URI``
|
|
58
|
+
is required only for the login router; a resource-server-only deployment
|
|
59
|
+
may omit it. Others fall back to the dataclass defaults.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def _req(name: str) -> str:
|
|
63
|
+
value = os.environ.get(f"{prefix}{name}")
|
|
64
|
+
if not value:
|
|
65
|
+
raise ValueError(f"Missing required env var {prefix}{name}")
|
|
66
|
+
return value
|
|
67
|
+
|
|
68
|
+
excluded = os.environ.get(f"{prefix}EXCLUDED_PATHS")
|
|
69
|
+
return cls(
|
|
70
|
+
discovery_url=_req("DISCOVERY_URL"),
|
|
71
|
+
client_id=_req("CLIENT_ID"),
|
|
72
|
+
redirect_uri=os.environ.get(f"{prefix}REDIRECT_URI", ""),
|
|
73
|
+
client_secret=os.environ.get(f"{prefix}CLIENT_SECRET"),
|
|
74
|
+
scope=os.environ.get(f"{prefix}SCOPE", "openid profile email"),
|
|
75
|
+
audience=os.environ.get(f"{prefix}AUDIENCE"),
|
|
76
|
+
post_login_redirect=os.environ.get(f"{prefix}POST_LOGIN_REDIRECT", "/"),
|
|
77
|
+
post_logout_redirect=os.environ.get(f"{prefix}POST_LOGOUT_REDIRECT", "/"),
|
|
78
|
+
excluded_paths=(
|
|
79
|
+
[p.strip() for p in excluded.split(",") if p.strip()]
|
|
80
|
+
if excluded
|
|
81
|
+
else _default_excluded_paths()
|
|
82
|
+
),
|
|
83
|
+
)
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI dependency injection functions for accessing user claims and identity.
|
|
3
|
+
|
|
4
|
+
These dependencies can be used in FastAPI route handlers to access
|
|
5
|
+
validated token information and user claims.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from typing import Annotated
|
|
10
|
+
|
|
11
|
+
from fastapi import ( # type: ignore[attr-defined]
|
|
12
|
+
Depends,
|
|
13
|
+
HTTPException,
|
|
14
|
+
Request,
|
|
15
|
+
status,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from py_identity_model.identity import ClaimsPrincipal
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# Error message constants
|
|
22
|
+
_NOT_AUTHENTICATED_MSG = "Not authenticated"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_current_user(request: Request) -> ClaimsPrincipal:
|
|
26
|
+
"""
|
|
27
|
+
Dependency to get the current authenticated user as a ClaimsPrincipal.
|
|
28
|
+
|
|
29
|
+
This dependency should be used after the TokenValidationMiddleware has
|
|
30
|
+
validated the token and attached the user to the request state.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
request: The FastAPI request object
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
ClaimsPrincipal: The authenticated user principal
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
HTTPException: If no authenticated user is found
|
|
40
|
+
|
|
41
|
+
Example:
|
|
42
|
+
```python
|
|
43
|
+
@app.get("/profile")
|
|
44
|
+
async def get_profile(
|
|
45
|
+
user: ClaimsPrincipal = Depends(get_current_user),
|
|
46
|
+
):
|
|
47
|
+
return {"user_id": user.identity.name}
|
|
48
|
+
```
|
|
49
|
+
"""
|
|
50
|
+
if not hasattr(request.state, "user"):
|
|
51
|
+
raise HTTPException(
|
|
52
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
53
|
+
detail=_NOT_AUTHENTICATED_MSG,
|
|
54
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
return request.state.user
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_claims(request: Request) -> dict:
|
|
61
|
+
"""
|
|
62
|
+
Dependency to get all claims from the validated token.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
request: The FastAPI request object
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
dict: Dictionary of all claims from the token
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
HTTPException: If no claims are found
|
|
72
|
+
|
|
73
|
+
Example:
|
|
74
|
+
```python
|
|
75
|
+
@app.get("/claims")
|
|
76
|
+
async def get_all_claims(claims: dict = Depends(get_claims)):
|
|
77
|
+
return {"claims": claims}
|
|
78
|
+
```
|
|
79
|
+
"""
|
|
80
|
+
if not hasattr(request.state, "claims"):
|
|
81
|
+
raise HTTPException(
|
|
82
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
83
|
+
detail=_NOT_AUTHENTICATED_MSG,
|
|
84
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return request.state.claims
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def get_token(request: Request) -> str:
|
|
91
|
+
"""
|
|
92
|
+
Dependency to get the raw JWT token.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
request: The FastAPI request object
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
str: The raw JWT token
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
HTTPException: If no token is found
|
|
102
|
+
|
|
103
|
+
Example:
|
|
104
|
+
```python
|
|
105
|
+
@app.get("/token-info")
|
|
106
|
+
async def get_token_info(token: str = Depends(get_token)):
|
|
107
|
+
return {"token_length": len(token)}
|
|
108
|
+
```
|
|
109
|
+
"""
|
|
110
|
+
if not hasattr(request.state, "token"):
|
|
111
|
+
raise HTTPException(
|
|
112
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
113
|
+
detail=_NOT_AUTHENTICATED_MSG,
|
|
114
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
return request.state.token
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# Annotated type aliases for FastAPI dependency injection (avoids B008)
|
|
121
|
+
CurrentUser = Annotated[ClaimsPrincipal, Depends(get_current_user)]
|
|
122
|
+
Claims = Annotated[dict, Depends(get_claims)]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def get_claim_value(claim_type: str) -> Callable[..., str | None]:
|
|
126
|
+
"""
|
|
127
|
+
Factory function to create a dependency that extracts a specific claim value.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
claim_type: The claim type to extract
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
callable: A dependency function that extracts the claim value
|
|
134
|
+
|
|
135
|
+
Example:
|
|
136
|
+
```python
|
|
137
|
+
# Create a dependency for the 'sub' claim
|
|
138
|
+
get_user_id = get_claim_value("sub")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@app.get("/user-data")
|
|
142
|
+
async def get_user_data(user_id: str = Depends(get_user_id)):
|
|
143
|
+
return {"user_id": user_id}
|
|
144
|
+
```
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
def _get_claim(
|
|
148
|
+
user: CurrentUser,
|
|
149
|
+
) -> str | None:
|
|
150
|
+
if user.identity is None:
|
|
151
|
+
return None
|
|
152
|
+
claim = user.identity.find_first(claim_type)
|
|
153
|
+
if claim:
|
|
154
|
+
return claim.value
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
return _get_claim
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def get_claim_values(claim_type: str) -> Callable[..., list[str]]:
|
|
161
|
+
"""
|
|
162
|
+
Factory function to create a dependency that extracts all values for a specific claim type.
|
|
163
|
+
|
|
164
|
+
Useful for claims that can have multiple values (like roles).
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
claim_type: The claim type to extract
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
callable: A dependency function that extracts all claim values
|
|
171
|
+
|
|
172
|
+
Example:
|
|
173
|
+
```python
|
|
174
|
+
# Create a dependency for the 'role' claim
|
|
175
|
+
get_user_roles = get_claim_values("role")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@app.get("/roles")
|
|
179
|
+
async def get_roles(roles: List[str] = Depends(get_user_roles)):
|
|
180
|
+
return {"roles": roles}
|
|
181
|
+
```
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
def _get_claims(
|
|
185
|
+
user: CurrentUser,
|
|
186
|
+
) -> list[str]:
|
|
187
|
+
if user.identity is None:
|
|
188
|
+
return []
|
|
189
|
+
claims = user.identity.find_all(claim_type)
|
|
190
|
+
return [claim.value for claim in claims]
|
|
191
|
+
|
|
192
|
+
return _get_claims
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def require_claim(
|
|
196
|
+
claim_type: str,
|
|
197
|
+
claim_value: str | None = None,
|
|
198
|
+
) -> Callable[..., None]:
|
|
199
|
+
"""
|
|
200
|
+
Factory function to create a dependency that requires a specific claim.
|
|
201
|
+
|
|
202
|
+
This can be used to enforce authorization based on claims.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
claim_type: The required claim type
|
|
206
|
+
claim_value: Optional specific value the claim must have
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
callable: A dependency function that validates the claim
|
|
210
|
+
|
|
211
|
+
Raises:
|
|
212
|
+
HTTPException: If the required claim is not present or doesn't match the value
|
|
213
|
+
|
|
214
|
+
Example:
|
|
215
|
+
```python
|
|
216
|
+
# Require that the user has a 'role' claim with value 'admin'
|
|
217
|
+
require_admin = require_claim("role", "admin")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@app.delete("/users/{user_id}")
|
|
221
|
+
async def delete_user(user_id: str, _: None = Depends(require_admin)):
|
|
222
|
+
# Only users with admin role can access this
|
|
223
|
+
return {"message": f"User {user_id} deleted"}
|
|
224
|
+
```
|
|
225
|
+
"""
|
|
226
|
+
|
|
227
|
+
def _check_claim(
|
|
228
|
+
user: CurrentUser,
|
|
229
|
+
) -> None:
|
|
230
|
+
if not user.has_claim(claim_type, claim_value):
|
|
231
|
+
if claim_value is None:
|
|
232
|
+
raise HTTPException(
|
|
233
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
234
|
+
detail=f"Required claim '{claim_type}' not found",
|
|
235
|
+
)
|
|
236
|
+
raise HTTPException(
|
|
237
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
238
|
+
detail=f"Required claim '{claim_type}' with value '{claim_value}' not found",
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
return _check_claim
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def require_scope(scope: str) -> Callable[..., None]:
|
|
245
|
+
"""
|
|
246
|
+
Factory function to create a dependency that requires a specific OAuth scope.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
scope: The required scope
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
callable: A dependency function that validates the scope
|
|
253
|
+
|
|
254
|
+
Raises:
|
|
255
|
+
HTTPException: If the required scope is not present
|
|
256
|
+
|
|
257
|
+
Example:
|
|
258
|
+
```python
|
|
259
|
+
require_read_scope = require_scope("api.read")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@app.get("/data")
|
|
263
|
+
async def get_data(_: None = Depends(require_read_scope)):
|
|
264
|
+
return {"data": "sensitive information"}
|
|
265
|
+
```
|
|
266
|
+
"""
|
|
267
|
+
|
|
268
|
+
def _check_scope(claims: Claims) -> None:
|
|
269
|
+
# Scopes live in 'scope' (space-separated string) or 'scp' (array).
|
|
270
|
+
raw = claims.get("scope") or claims.get("scp")
|
|
271
|
+
if isinstance(raw, str):
|
|
272
|
+
scopes: list = raw.split()
|
|
273
|
+
elif isinstance(raw, (list, tuple)):
|
|
274
|
+
scopes = [s for s in raw if isinstance(s, str)]
|
|
275
|
+
else:
|
|
276
|
+
# A dict/number/None or any unexpected type is not a usable scope
|
|
277
|
+
# claim — fail closed rather than letting `in` match dict keys or
|
|
278
|
+
# raise a TypeError.
|
|
279
|
+
scopes = []
|
|
280
|
+
|
|
281
|
+
if scope not in scopes:
|
|
282
|
+
raise HTTPException(
|
|
283
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
284
|
+
detail=f"Required scope '{scope}' not found",
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
return _check_scope
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI middleware for OAuth2/OIDC token validation.
|
|
3
|
+
|
|
4
|
+
This module provides middleware components for validating Bearer tokens
|
|
5
|
+
in FastAPI applications using py-identity-model.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
import logging
|
|
10
|
+
|
|
11
|
+
from fastapi import Request, status # type: ignore[attr-defined]
|
|
12
|
+
from jwt import InvalidTokenError
|
|
13
|
+
from starlette.middleware.base import (
|
|
14
|
+
BaseHTTPMiddleware, # type: ignore[attr-defined]
|
|
15
|
+
)
|
|
16
|
+
from starlette.responses import ( # type: ignore[attr-defined]
|
|
17
|
+
JSONResponse,
|
|
18
|
+
Response,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from py_identity_model import (
|
|
22
|
+
NetworkException,
|
|
23
|
+
PyIdentityModelException,
|
|
24
|
+
TokenValidationConfig,
|
|
25
|
+
to_principal,
|
|
26
|
+
)
|
|
27
|
+
from py_identity_model.aio import validate_token
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger("fastapi_identity_model")
|
|
31
|
+
|
|
32
|
+
# Expected number of parts in "Bearer <token>" authorization header
|
|
33
|
+
_BEARER_HEADER_PART_COUNT = 2
|
|
34
|
+
|
|
35
|
+
# Claims that only ever appear in an ID token (OIDC Core 1.0 §2, §3.1.3.6).
|
|
36
|
+
# Their presence means an ID token was presented where an access token is
|
|
37
|
+
# expected — reject it to prevent token-substitution at the resource server.
|
|
38
|
+
_ID_TOKEN_ONLY_CLAIMS = ("nonce", "at_hash", "c_hash")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TokenValidationMiddleware(BaseHTTPMiddleware):
|
|
42
|
+
"""
|
|
43
|
+
Middleware for validating Bearer tokens on incoming requests.
|
|
44
|
+
|
|
45
|
+
This middleware automatically validates JWT tokens from the Authorization header
|
|
46
|
+
and attaches the validated claims to the request state.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
app: The FastAPI application
|
|
50
|
+
discovery_url: The OpenID Connect discovery document URL
|
|
51
|
+
audience: Expected audience claim in the token. Required — a ``None``
|
|
52
|
+
audience does not enforce ``aud`` for tokens that omit the claim,
|
|
53
|
+
which on a shared multi-tenant issuer accepts tokens minted for
|
|
54
|
+
other clients.
|
|
55
|
+
excluded_paths: Paths that skip token validation. A path matches if it
|
|
56
|
+
equals an entry or is a subpath of one (``/docs`` also covers
|
|
57
|
+
``/docs/oauth2-redirect``). Pass ``[]`` to exclude nothing. When
|
|
58
|
+
omitted, defaults to ``/docs``, ``/openapi.json``, ``/health``.
|
|
59
|
+
custom_claims_validator: Optional custom function to validate additional claims
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
app,
|
|
65
|
+
discovery_url: str,
|
|
66
|
+
audience: str | None = None,
|
|
67
|
+
excluded_paths: list[str] | None = None,
|
|
68
|
+
custom_claims_validator: Callable | None = None,
|
|
69
|
+
):
|
|
70
|
+
super().__init__(app)
|
|
71
|
+
if not audience:
|
|
72
|
+
raise ValueError(
|
|
73
|
+
"TokenValidationMiddleware requires a non-empty 'audience'; a "
|
|
74
|
+
"None/empty audience skips aud enforcement for aud-less tokens."
|
|
75
|
+
)
|
|
76
|
+
self.discovery_url = discovery_url
|
|
77
|
+
self.audience = audience
|
|
78
|
+
# ``is not None`` (not truthiness) so an explicit [] means "exclude
|
|
79
|
+
# nothing" instead of silently re-enabling the defaults.
|
|
80
|
+
self.excluded_paths = (
|
|
81
|
+
excluded_paths
|
|
82
|
+
if excluded_paths is not None
|
|
83
|
+
else ["/docs", "/openapi.json", "/health"]
|
|
84
|
+
)
|
|
85
|
+
self.custom_claims_validator = custom_claims_validator
|
|
86
|
+
|
|
87
|
+
def _is_excluded(self, path: str) -> bool:
|
|
88
|
+
"""Whether *path* equals or is a subpath of an excluded entry.
|
|
89
|
+
|
|
90
|
+
A bare ``/`` entry matches only the root, never as a subpath prefix
|
|
91
|
+
(otherwise it would exclude every path).
|
|
92
|
+
"""
|
|
93
|
+
for entry in self.excluded_paths:
|
|
94
|
+
if path == entry:
|
|
95
|
+
return True
|
|
96
|
+
prefix = entry.rstrip("/")
|
|
97
|
+
if prefix and path.startswith(prefix + "/"):
|
|
98
|
+
return True
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def _unauthorized(detail: str) -> JSONResponse:
|
|
103
|
+
return JSONResponse(
|
|
104
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
105
|
+
content={"detail": detail},
|
|
106
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def _extract_bearer_token(self, request: Request) -> str | JSONResponse:
|
|
110
|
+
"""Return the bearer token, or an error ``JSONResponse`` if absent/malformed."""
|
|
111
|
+
authorization = request.headers.get("Authorization")
|
|
112
|
+
if not authorization:
|
|
113
|
+
return self._unauthorized("Missing Authorization header")
|
|
114
|
+
parts = authorization.split()
|
|
115
|
+
if len(parts) != _BEARER_HEADER_PART_COUNT or parts[0].lower() != "bearer":
|
|
116
|
+
return self._unauthorized(
|
|
117
|
+
"Invalid Authorization header format. Expected: Bearer <token>"
|
|
118
|
+
)
|
|
119
|
+
return parts[1]
|
|
120
|
+
|
|
121
|
+
async def _authenticate(self, request: Request, token: str) -> JSONResponse | None:
|
|
122
|
+
"""Validate *token* and attach claims; return an error response or None."""
|
|
123
|
+
try:
|
|
124
|
+
claims = await validate_token(
|
|
125
|
+
jwt=token,
|
|
126
|
+
token_validation_config=TokenValidationConfig(
|
|
127
|
+
perform_disco=True,
|
|
128
|
+
audience=self.audience,
|
|
129
|
+
claims_validator=self.custom_claims_validator,
|
|
130
|
+
),
|
|
131
|
+
disco_doc_address=self.discovery_url,
|
|
132
|
+
)
|
|
133
|
+
# Reject an ID token presented as an access token. With audience
|
|
134
|
+
# defaulted to client_id, an ID token's aud matches, so type must
|
|
135
|
+
# be discriminated on ID-token-only claims.
|
|
136
|
+
if any(c in claims for c in _ID_TOKEN_ONLY_CLAIMS):
|
|
137
|
+
return self._unauthorized("ID token cannot be used as an access token")
|
|
138
|
+
request.state.user = to_principal(claims)
|
|
139
|
+
request.state.claims = claims
|
|
140
|
+
request.state.token = token
|
|
141
|
+
return None
|
|
142
|
+
except NetworkException:
|
|
143
|
+
# Discovery/JWKS/network fetch failure is a transient server fault,
|
|
144
|
+
# not an authentication decision — surface 5xx so callers retry
|
|
145
|
+
# instead of treating a provider outage as a bad token.
|
|
146
|
+
logger.exception("Network error during token validation")
|
|
147
|
+
return JSONResponse(
|
|
148
|
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
149
|
+
content={"detail": "Authentication temporarily unavailable"},
|
|
150
|
+
)
|
|
151
|
+
except PyIdentityModelException as e:
|
|
152
|
+
return self._unauthorized(f"Token validation failed: {e!s}")
|
|
153
|
+
except InvalidTokenError as e:
|
|
154
|
+
# A malformed/undecodable token (e.g. raw pyjwt DecodeError from
|
|
155
|
+
# header parsing during key lookup) is a client error, not a 500.
|
|
156
|
+
return self._unauthorized(f"Invalid token: {e!s}")
|
|
157
|
+
except Exception:
|
|
158
|
+
# A genuinely unexpected (non-library) failure is a server fault,
|
|
159
|
+
# not an auth decision. Surface a 500 without leaking internals.
|
|
160
|
+
logger.exception("Unexpected error during token validation")
|
|
161
|
+
return JSONResponse(
|
|
162
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
163
|
+
content={"detail": "Internal server error during authentication"},
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
async def dispatch(self, request: Request, call_next) -> Response:
|
|
167
|
+
"""Process the request and validate the token if required."""
|
|
168
|
+
# CORS preflight carries no Authorization; excluded paths skip auth.
|
|
169
|
+
if request.method == "OPTIONS" or self._is_excluded(request.url.path):
|
|
170
|
+
return await call_next(request)
|
|
171
|
+
|
|
172
|
+
token = self._extract_bearer_token(request)
|
|
173
|
+
if isinstance(token, JSONResponse):
|
|
174
|
+
return token
|
|
175
|
+
|
|
176
|
+
auth_error = await self._authenticate(request, token)
|
|
177
|
+
if auth_error is not None:
|
|
178
|
+
return auth_error
|
|
179
|
+
|
|
180
|
+
return await call_next(request)
|
|
File without changes
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"""Relying-Party (OIDC login) router for FastAPI.
|
|
2
|
+
|
|
3
|
+
``build_oidc_router`` returns a mountable :class:`fastapi.APIRouter` implementing
|
|
4
|
+
the authorization-code + PKCE login flow against any OpenID Connect provider,
|
|
5
|
+
using the async ``py_identity_model.aio`` API for all protocol work:
|
|
6
|
+
|
|
7
|
+
from fastapi import FastAPI
|
|
8
|
+
from starlette.middleware.sessions import SessionMiddleware
|
|
9
|
+
from fastapi_identity_model import OIDCSettings, build_oidc_router
|
|
10
|
+
|
|
11
|
+
app = FastAPI()
|
|
12
|
+
app.add_middleware(SessionMiddleware, secret_key="...") # required
|
|
13
|
+
app.include_router(build_oidc_router(settings), prefix="/auth")
|
|
14
|
+
|
|
15
|
+
Routes: ``GET /login`` → provider, ``GET/POST /callback`` (exchange + validate
|
|
16
|
+
+ nonce + UserInfo; the POST route accepts the OAuth 2.0 ``form_post`` response
|
|
17
|
+
mode), ``POST /logout``. The transient PKCE/state/nonce are kept
|
|
18
|
+
under a separate ``<session_key>.flow`` entry and popped on callback (single
|
|
19
|
+
use); the resulting identity is written to ``session[session_key]`` only after
|
|
20
|
+
a verified ID token, so reading ``session[session_key]`` never observes an
|
|
21
|
+
in-flight login. Logout is POST-only so a cross-site GET cannot force it.
|
|
22
|
+
|
|
23
|
+
Security note: browsers only send the session cookie on the cross-site POST a
|
|
24
|
+
``form_post`` provider issues if it was set with ``same_site="none"`` (which
|
|
25
|
+
requires ``https_only=True``); with the default ``lax`` the POST callback
|
|
26
|
+
arrives without the flow cookie and fails with "No active login flow".
|
|
27
|
+
``SessionMiddleware`` signs but does **not** encrypt the cookie.
|
|
28
|
+
Identity claims stored there are tamper-proof but readable by the client. Raw
|
|
29
|
+
tokens are stored only when ``store_tokens=True`` — enable that only with an
|
|
30
|
+
encrypted or server-side session store. Logout clears the local session only;
|
|
31
|
+
it does not call the provider's ``end_session_endpoint``.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import logging
|
|
37
|
+
import secrets
|
|
38
|
+
from typing import TYPE_CHECKING
|
|
39
|
+
from urllib.parse import parse_qsl, urlencode
|
|
40
|
+
|
|
41
|
+
from fastapi import APIRouter, HTTPException, Request, status
|
|
42
|
+
from starlette.responses import RedirectResponse
|
|
43
|
+
|
|
44
|
+
from py_identity_model import PyIdentityModelException
|
|
45
|
+
from py_identity_model.aio import (
|
|
46
|
+
AuthorizationCodeTokenRequest,
|
|
47
|
+
DiscoveryDocumentRequest,
|
|
48
|
+
DiscoveryDocumentResponse,
|
|
49
|
+
TokenValidationConfig,
|
|
50
|
+
UserInfoRequest,
|
|
51
|
+
build_authorization_url,
|
|
52
|
+
generate_pkce_pair,
|
|
53
|
+
get_discovery_document,
|
|
54
|
+
get_userinfo,
|
|
55
|
+
parse_authorize_callback_response,
|
|
56
|
+
request_authorization_code_token,
|
|
57
|
+
validate_authorize_callback_state,
|
|
58
|
+
validate_token,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if TYPE_CHECKING:
|
|
63
|
+
from .config import OIDCSettings
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
logger = logging.getLogger("fastapi_identity_model")
|
|
67
|
+
|
|
68
|
+
_STATE_TOKEN_BYTES = 32
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _flow_key(session_key: str) -> str:
|
|
72
|
+
"""Session key holding transient login-flow state (state/nonce/verifier).
|
|
73
|
+
|
|
74
|
+
Kept separate from *session_key* (which holds the final identity) so a
|
|
75
|
+
consumer reading ``session[session_key]`` never observes an in-flight
|
|
76
|
+
login as an authenticated identity, and never sees the ``code_verifier``.
|
|
77
|
+
"""
|
|
78
|
+
return f"{session_key}.flow"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _require_session(request: Request) -> None:
|
|
82
|
+
"""Fail with a clear error when ``SessionMiddleware`` is not installed."""
|
|
83
|
+
if "session" not in request.scope:
|
|
84
|
+
raise RuntimeError(
|
|
85
|
+
"SessionMiddleware is required by the OIDC router but is not "
|
|
86
|
+
"installed. Add: app.add_middleware(SessionMiddleware, secret_key=...)"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
_WELL_KNOWN_SUFFIX = "/.well-known/openid-configuration"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _expected_issuer(discovery_url: str) -> str:
|
|
94
|
+
"""Issuer implied by the discovery URL (OIDC Discovery 1.0 §4.1)."""
|
|
95
|
+
base = discovery_url.rstrip("/")
|
|
96
|
+
if base.endswith(_WELL_KNOWN_SUFFIX):
|
|
97
|
+
base = base[: -len(_WELL_KNOWN_SUFFIX)]
|
|
98
|
+
return base.rstrip("/")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
async def _discover(settings: OIDCSettings) -> DiscoveryDocumentResponse:
|
|
102
|
+
disco = await get_discovery_document(
|
|
103
|
+
DiscoveryDocumentRequest(address=settings.discovery_url),
|
|
104
|
+
)
|
|
105
|
+
if not disco.is_successful:
|
|
106
|
+
raise HTTPException(
|
|
107
|
+
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
108
|
+
detail=f"OIDC discovery failed: {disco.error}",
|
|
109
|
+
)
|
|
110
|
+
# OIDC Discovery 1.0 §4.3: the document's issuer MUST match the URL the
|
|
111
|
+
# document was retrieved from (issuer mix-up defense). The library only
|
|
112
|
+
# validates the issuer's *format*; the equality check is the RP's job.
|
|
113
|
+
# Trailing slashes are normalized — issuer "https://op/" serves its
|
|
114
|
+
# document at "https://op/.well-known/openid-configuration".
|
|
115
|
+
expected = _expected_issuer(settings.discovery_url)
|
|
116
|
+
if (disco.issuer or "").rstrip("/") != expected:
|
|
117
|
+
raise HTTPException(
|
|
118
|
+
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
119
|
+
detail=(
|
|
120
|
+
f"Discovery document issuer mismatch: expected '{expected}', "
|
|
121
|
+
f"got '{disco.issuer}'"
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
return disco
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _require_endpoint(value: str | None, name: str) -> str:
|
|
128
|
+
if not value:
|
|
129
|
+
raise HTTPException(
|
|
130
|
+
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
131
|
+
detail=f"Provider discovery is missing '{name}'",
|
|
132
|
+
)
|
|
133
|
+
return value
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
async def _login(
|
|
137
|
+
request: Request, settings: OIDCSettings, session_key: str
|
|
138
|
+
) -> RedirectResponse:
|
|
139
|
+
_require_session(request)
|
|
140
|
+
disco = await _discover(settings)
|
|
141
|
+
authorization_endpoint = _require_endpoint(
|
|
142
|
+
disco.authorization_endpoint, "authorization_endpoint"
|
|
143
|
+
)
|
|
144
|
+
code_verifier, code_challenge = generate_pkce_pair()
|
|
145
|
+
state = secrets.token_urlsafe(_STATE_TOKEN_BYTES)
|
|
146
|
+
nonce = secrets.token_urlsafe(_STATE_TOKEN_BYTES)
|
|
147
|
+
request.session[_flow_key(session_key)] = {
|
|
148
|
+
"state": state,
|
|
149
|
+
"nonce": nonce,
|
|
150
|
+
"code_verifier": code_verifier,
|
|
151
|
+
}
|
|
152
|
+
url = build_authorization_url(
|
|
153
|
+
authorization_endpoint=authorization_endpoint,
|
|
154
|
+
client_id=settings.client_id,
|
|
155
|
+
redirect_uri=settings.redirect_uri,
|
|
156
|
+
scope=settings.scope,
|
|
157
|
+
state=state,
|
|
158
|
+
nonce=nonce,
|
|
159
|
+
code_challenge=code_challenge,
|
|
160
|
+
code_challenge_method="S256",
|
|
161
|
+
)
|
|
162
|
+
return RedirectResponse(url, status_code=status.HTTP_302_FOUND)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
async def _exchange_code(
|
|
166
|
+
settings: OIDCSettings,
|
|
167
|
+
disco: DiscoveryDocumentResponse,
|
|
168
|
+
code: str,
|
|
169
|
+
code_verifier: str,
|
|
170
|
+
) -> dict:
|
|
171
|
+
"""Exchange the authorization code and return the token dict."""
|
|
172
|
+
token_endpoint = _require_endpoint(disco.token_endpoint, "token_endpoint")
|
|
173
|
+
tok = await request_authorization_code_token(
|
|
174
|
+
AuthorizationCodeTokenRequest(
|
|
175
|
+
address=token_endpoint,
|
|
176
|
+
client_id=settings.client_id,
|
|
177
|
+
code=code,
|
|
178
|
+
redirect_uri=settings.redirect_uri,
|
|
179
|
+
code_verifier=code_verifier,
|
|
180
|
+
client_secret=settings.client_secret or None,
|
|
181
|
+
),
|
|
182
|
+
)
|
|
183
|
+
if not tok.is_successful or not tok.token:
|
|
184
|
+
raise HTTPException(
|
|
185
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
186
|
+
detail=f"Token exchange failed: {tok.error}",
|
|
187
|
+
)
|
|
188
|
+
return tok.token
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
async def _validate_id_token(
|
|
192
|
+
settings: OIDCSettings,
|
|
193
|
+
disco: DiscoveryDocumentResponse,
|
|
194
|
+
id_token: str,
|
|
195
|
+
expected_nonce: str,
|
|
196
|
+
) -> dict:
|
|
197
|
+
"""Validate the ID token (signature/iss/aud/exp/required claims) and bind the nonce."""
|
|
198
|
+
# Guard the issuer explicitly: a discovery doc missing ``issuer`` would
|
|
199
|
+
# otherwise pass ``issuer=None`` and silently disable issuer verification.
|
|
200
|
+
issuer = _require_endpoint(disco.issuer, "issuer")
|
|
201
|
+
try:
|
|
202
|
+
claims = await validate_token(
|
|
203
|
+
jwt=id_token,
|
|
204
|
+
token_validation_config=TokenValidationConfig(
|
|
205
|
+
perform_disco=True,
|
|
206
|
+
audience=settings.client_id,
|
|
207
|
+
issuer=issuer,
|
|
208
|
+
options={"verify_exp": True, "require": ["sub", "iat", "exp"]},
|
|
209
|
+
),
|
|
210
|
+
disco_doc_address=settings.discovery_url,
|
|
211
|
+
)
|
|
212
|
+
except PyIdentityModelException as exc:
|
|
213
|
+
raise HTTPException(
|
|
214
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
215
|
+
detail=f"ID token validation failed: {exc}",
|
|
216
|
+
) from exc
|
|
217
|
+
|
|
218
|
+
# Nonce binding is the RP's responsibility — the library does not check it.
|
|
219
|
+
if claims.get("nonce") != expected_nonce:
|
|
220
|
+
raise HTTPException(
|
|
221
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
222
|
+
detail="Nonce mismatch",
|
|
223
|
+
)
|
|
224
|
+
return claims
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
async def _fetch_userinfo(
|
|
228
|
+
disco: DiscoveryDocumentResponse,
|
|
229
|
+
access_token: str | None,
|
|
230
|
+
expected_sub: str | None,
|
|
231
|
+
) -> dict:
|
|
232
|
+
"""Fetch UserInfo, enforcing the OIDC Core 5.3.2 ``sub`` match as a hard gate.
|
|
233
|
+
|
|
234
|
+
An unavailable UserInfo endpoint (no endpoint, or a transient fetch
|
|
235
|
+
failure) is tolerated — identity is anchored on the validated ID token.
|
|
236
|
+
But a *successful* fetch whose ``sub`` disagrees with the ID token is a
|
|
237
|
+
token-substitution signal and must abort the login, not be swallowed.
|
|
238
|
+
"""
|
|
239
|
+
userinfo_endpoint = disco.userinfo_endpoint
|
|
240
|
+
if not access_token or not userinfo_endpoint:
|
|
241
|
+
return {}
|
|
242
|
+
# Fetch without expected_sub so is_successful reflects only the fetch;
|
|
243
|
+
# the sub comparison is enforced here so a mismatch fails the login.
|
|
244
|
+
ui = await get_userinfo(
|
|
245
|
+
UserInfoRequest(address=userinfo_endpoint, token=access_token),
|
|
246
|
+
)
|
|
247
|
+
if not ui.is_successful:
|
|
248
|
+
logger.warning("UserInfo fetch failed, continuing on ID token: %s", ui.error)
|
|
249
|
+
return {}
|
|
250
|
+
claims = ui.claims or {}
|
|
251
|
+
if expected_sub is not None and claims.get("sub") != expected_sub:
|
|
252
|
+
raise HTTPException(
|
|
253
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
254
|
+
detail="UserInfo subject does not match ID token subject",
|
|
255
|
+
)
|
|
256
|
+
return claims
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
async def _callback( # noqa: PLR0913 # request-scoped args + the router's build-time options
|
|
260
|
+
request: Request,
|
|
261
|
+
settings: OIDCSettings,
|
|
262
|
+
session_key: str,
|
|
263
|
+
callback_url: str,
|
|
264
|
+
*,
|
|
265
|
+
store_tokens: bool,
|
|
266
|
+
fetch_userinfo: bool,
|
|
267
|
+
) -> RedirectResponse:
|
|
268
|
+
_require_session(request)
|
|
269
|
+
# Pop the flow state so it is single-use: a failed or replayed callback
|
|
270
|
+
# cannot reuse the same state/verifier for another attempt.
|
|
271
|
+
flow = request.session.pop(_flow_key(session_key), None)
|
|
272
|
+
if not flow or not {"state", "nonce", "code_verifier"} <= flow.keys():
|
|
273
|
+
raise HTTPException(
|
|
274
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
275
|
+
detail="No active login flow in session",
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
cb = parse_authorize_callback_response(callback_url)
|
|
280
|
+
except PyIdentityModelException as exc:
|
|
281
|
+
raise HTTPException(
|
|
282
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
283
|
+
detail=f"Malformed authorization callback: {exc}",
|
|
284
|
+
) from exc
|
|
285
|
+
if not cb.is_successful:
|
|
286
|
+
raise HTTPException(
|
|
287
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
288
|
+
detail=f"Authorization error: {cb.error}",
|
|
289
|
+
)
|
|
290
|
+
if not validate_authorize_callback_state(cb, flow["state"]).is_valid:
|
|
291
|
+
raise HTTPException(
|
|
292
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
293
|
+
detail="State mismatch (possible CSRF)",
|
|
294
|
+
)
|
|
295
|
+
if cb.code is None:
|
|
296
|
+
raise HTTPException(
|
|
297
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
298
|
+
detail="Authorization response missing code",
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
disco = await _discover(settings)
|
|
302
|
+
token = await _exchange_code(settings, disco, cb.code, flow["code_verifier"])
|
|
303
|
+
id_token = token.get("id_token")
|
|
304
|
+
access_token = token.get("access_token")
|
|
305
|
+
|
|
306
|
+
# An OIDC login flow (openid scope, nonce sent) requires a verified ID
|
|
307
|
+
# token. Refuse to establish a session on a token response that omits it
|
|
308
|
+
# rather than degrading to an unauthenticated "logged-in" session.
|
|
309
|
+
if not id_token:
|
|
310
|
+
raise HTTPException(
|
|
311
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
312
|
+
detail="Provider did not return an ID token",
|
|
313
|
+
)
|
|
314
|
+
claims = await _validate_id_token(settings, disco, id_token, flow["nonce"])
|
|
315
|
+
|
|
316
|
+
userinfo: dict = {}
|
|
317
|
+
if fetch_userinfo:
|
|
318
|
+
userinfo = await _fetch_userinfo(disco, access_token, claims.get("sub"))
|
|
319
|
+
|
|
320
|
+
session_data: dict = {
|
|
321
|
+
"sub": claims.get("sub"),
|
|
322
|
+
"claims": claims,
|
|
323
|
+
"userinfo": userinfo,
|
|
324
|
+
}
|
|
325
|
+
if store_tokens:
|
|
326
|
+
session_data["tokens"] = {
|
|
327
|
+
"access_token": access_token,
|
|
328
|
+
"refresh_token": token.get("refresh_token"),
|
|
329
|
+
"id_token": id_token,
|
|
330
|
+
}
|
|
331
|
+
request.session[session_key] = session_data
|
|
332
|
+
|
|
333
|
+
return RedirectResponse(
|
|
334
|
+
settings.post_login_redirect,
|
|
335
|
+
status_code=status.HTTP_302_FOUND,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def build_oidc_router(
|
|
340
|
+
settings: OIDCSettings,
|
|
341
|
+
*,
|
|
342
|
+
session_key: str = "oidc",
|
|
343
|
+
store_tokens: bool = False,
|
|
344
|
+
fetch_userinfo: bool = True,
|
|
345
|
+
) -> APIRouter:
|
|
346
|
+
"""Build an OIDC relying-party router.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
settings: Provider/client configuration.
|
|
350
|
+
session_key: Key under which flow + identity state is kept in the session.
|
|
351
|
+
store_tokens: When True, also persist the access/refresh/id tokens in the
|
|
352
|
+
session (requires an encrypted or server-side session store).
|
|
353
|
+
fetch_userinfo: When False, skip the UserInfo request after ID-token
|
|
354
|
+
validation and anchor identity on the ID token claims alone —
|
|
355
|
+
for providers without a usable UserInfo endpoint, or to avoid
|
|
356
|
+
the extra round-trip per login.
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
An ``APIRouter`` with ``/login``, ``/callback`` and ``/logout`` routes.
|
|
360
|
+
"""
|
|
361
|
+
if not settings.redirect_uri:
|
|
362
|
+
raise ValueError(
|
|
363
|
+
"build_oidc_router requires settings.redirect_uri (the provider "
|
|
364
|
+
"callback URL); it is only optional for resource-server-only setups."
|
|
365
|
+
)
|
|
366
|
+
router = APIRouter()
|
|
367
|
+
|
|
368
|
+
@router.get("/login")
|
|
369
|
+
async def login(request: Request) -> RedirectResponse:
|
|
370
|
+
return await _login(request, settings, session_key)
|
|
371
|
+
|
|
372
|
+
@router.get("/callback")
|
|
373
|
+
async def callback(request: Request) -> RedirectResponse:
|
|
374
|
+
return await _callback(
|
|
375
|
+
request,
|
|
376
|
+
settings,
|
|
377
|
+
session_key,
|
|
378
|
+
str(request.url),
|
|
379
|
+
store_tokens=store_tokens,
|
|
380
|
+
fetch_userinfo=fetch_userinfo,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
@router.post("/callback")
|
|
384
|
+
async def callback_form_post(request: Request) -> RedirectResponse:
|
|
385
|
+
# form_post response mode: the provider delivers the authorization
|
|
386
|
+
# response as an application/x-www-form-urlencoded POST body instead
|
|
387
|
+
# of query parameters. Rebuild a callback URL carrying the fields as
|
|
388
|
+
# a query string so GET and POST share the exact same validation path
|
|
389
|
+
# (parse → state → code → exchange). Parsed with stdlib parse_qsl —
|
|
390
|
+
# form_post is urlencoded by definition, so this avoids taking a
|
|
391
|
+
# python-multipart dependency for starlette's request.form().
|
|
392
|
+
body = (await request.body()).decode("utf-8", errors="replace")
|
|
393
|
+
params = urlencode(parse_qsl(body, keep_blank_values=True))
|
|
394
|
+
callback_url = str(request.url.replace(query=params))
|
|
395
|
+
return await _callback(
|
|
396
|
+
request,
|
|
397
|
+
settings,
|
|
398
|
+
session_key,
|
|
399
|
+
callback_url,
|
|
400
|
+
store_tokens=store_tokens,
|
|
401
|
+
fetch_userinfo=fetch_userinfo,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
@router.post("/logout")
|
|
405
|
+
async def logout(request: Request) -> RedirectResponse:
|
|
406
|
+
# POST-only: a state-changing session mutation must not be triggerable
|
|
407
|
+
# by a cross-site GET (e.g. an <img> tag) under a Lax cookie.
|
|
408
|
+
_require_session(request)
|
|
409
|
+
request.session.pop(session_key, None)
|
|
410
|
+
request.session.pop(_flow_key(session_key), None)
|
|
411
|
+
return RedirectResponse(
|
|
412
|
+
settings.post_logout_redirect,
|
|
413
|
+
status_code=status.HTTP_303_SEE_OTHER,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
return router
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Access-token lifecycle management for FastAPI applications.
|
|
2
|
+
|
|
3
|
+
``TokenManager`` keeps an access/refresh token pair and transparently refreshes
|
|
4
|
+
the access token (via the library's native async refresh grant) shortly before
|
|
5
|
+
it expires. It is a thin convenience layer over
|
|
6
|
+
:func:`py_identity_model.aio.refresh_token` — all protocol work lives in the
|
|
7
|
+
core library.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from datetime import UTC, datetime, timedelta
|
|
12
|
+
|
|
13
|
+
from py_identity_model import PyIdentityModelException
|
|
14
|
+
from py_identity_model.aio import (
|
|
15
|
+
DiscoveryDocumentRequest,
|
|
16
|
+
RefreshTokenRequest,
|
|
17
|
+
get_discovery_document,
|
|
18
|
+
refresh_token,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TokenManager:
|
|
23
|
+
"""Manage an access token, refreshing it automatically before expiry.
|
|
24
|
+
|
|
25
|
+
Example:
|
|
26
|
+
```python
|
|
27
|
+
manager = TokenManager(
|
|
28
|
+
discovery_url="https://auth.example.com/.well-known/openid-configuration",
|
|
29
|
+
client_id="my-client-id",
|
|
30
|
+
client_secret="my-client-secret",
|
|
31
|
+
)
|
|
32
|
+
manager.set_tokens(
|
|
33
|
+
access_token="initial_access_token",
|
|
34
|
+
refresh_token="initial_refresh_token",
|
|
35
|
+
expires_in=3600,
|
|
36
|
+
)
|
|
37
|
+
token = await manager.get_access_token() # auto-refreshes if near expiry
|
|
38
|
+
```
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
discovery_url: str,
|
|
44
|
+
client_id: str,
|
|
45
|
+
client_secret: str | None = None,
|
|
46
|
+
refresh_before_seconds: int = 300,
|
|
47
|
+
):
|
|
48
|
+
"""Initialize the token manager.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
discovery_url: The OpenID Connect discovery document URL.
|
|
52
|
+
client_id: The OAuth2 client ID.
|
|
53
|
+
client_secret: The OAuth2 client secret (omit for public clients).
|
|
54
|
+
refresh_before_seconds: Refresh this many seconds before expiry.
|
|
55
|
+
"""
|
|
56
|
+
self.discovery_url = discovery_url
|
|
57
|
+
self.client_id = client_id
|
|
58
|
+
self.client_secret = client_secret
|
|
59
|
+
self.refresh_before_seconds = refresh_before_seconds
|
|
60
|
+
|
|
61
|
+
self._access_token: str | None = None
|
|
62
|
+
self._refresh_token: str | None = None
|
|
63
|
+
self._expires_at: datetime | None = None
|
|
64
|
+
# Serialize refreshes so concurrent callers don't each fire the grant
|
|
65
|
+
# (which, with refresh-token rotation, would invalidate the family).
|
|
66
|
+
self._refresh_lock = asyncio.Lock()
|
|
67
|
+
|
|
68
|
+
def set_tokens(
|
|
69
|
+
self,
|
|
70
|
+
access_token: str,
|
|
71
|
+
refresh_token: str | None = None,
|
|
72
|
+
expires_in: int | None = None,
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Store the current token pair and (optionally) its expiry."""
|
|
75
|
+
self._access_token = access_token
|
|
76
|
+
self._refresh_token = refresh_token
|
|
77
|
+
# ``is not None`` (not truthiness) so expires_in=0 means "already
|
|
78
|
+
# expired" rather than "no expiry / never refresh".
|
|
79
|
+
self._expires_at = (
|
|
80
|
+
datetime.now(UTC) + timedelta(seconds=expires_in)
|
|
81
|
+
if expires_in is not None
|
|
82
|
+
else None
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def is_token_expired(self) -> bool:
|
|
86
|
+
"""Return True if the access token is expired or within the refresh window."""
|
|
87
|
+
if not self._expires_at:
|
|
88
|
+
return False
|
|
89
|
+
return datetime.now(UTC) >= (
|
|
90
|
+
self._expires_at - timedelta(seconds=self.refresh_before_seconds)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
async def _resolve_token_endpoint(self) -> str:
|
|
94
|
+
"""Resolve the token endpoint from discovery (per-process cached by the library)."""
|
|
95
|
+
disco = await get_discovery_document(
|
|
96
|
+
DiscoveryDocumentRequest(address=self.discovery_url),
|
|
97
|
+
)
|
|
98
|
+
if not disco.is_successful or not disco.token_endpoint:
|
|
99
|
+
raise PyIdentityModelException(
|
|
100
|
+
f"Discovery failed while refreshing token: {disco.error or 'no token endpoint'}",
|
|
101
|
+
)
|
|
102
|
+
return disco.token_endpoint
|
|
103
|
+
|
|
104
|
+
async def get_access_token(self) -> str | None:
|
|
105
|
+
"""Return the current access token, refreshing it first if necessary.
|
|
106
|
+
|
|
107
|
+
Raises:
|
|
108
|
+
PyIdentityModelException: If a refresh is required but no refresh
|
|
109
|
+
token is available, or the refresh grant fails.
|
|
110
|
+
"""
|
|
111
|
+
if self._access_token and not self.is_token_expired():
|
|
112
|
+
return self._access_token
|
|
113
|
+
|
|
114
|
+
async with self._refresh_lock:
|
|
115
|
+
# Re-check under the lock: a concurrent caller may have already
|
|
116
|
+
# refreshed while this one waited to acquire it.
|
|
117
|
+
if self._access_token and not self.is_token_expired():
|
|
118
|
+
return self._access_token
|
|
119
|
+
|
|
120
|
+
if not self._refresh_token:
|
|
121
|
+
raise PyIdentityModelException(
|
|
122
|
+
"Token expired and no refresh token available",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
token_endpoint = await self._resolve_token_endpoint()
|
|
126
|
+
response = await refresh_token(
|
|
127
|
+
RefreshTokenRequest(
|
|
128
|
+
address=token_endpoint,
|
|
129
|
+
client_id=self.client_id,
|
|
130
|
+
refresh_token=self._refresh_token,
|
|
131
|
+
client_secret=self.client_secret,
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if not response.is_successful or not response.token:
|
|
136
|
+
raise PyIdentityModelException(
|
|
137
|
+
f"Token refresh failed: {response.error or 'no token in response'}",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
new_access_token = response.token.get("access_token")
|
|
141
|
+
if not new_access_token:
|
|
142
|
+
raise PyIdentityModelException(
|
|
143
|
+
"Token refresh succeeded but no access token returned",
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
self.set_tokens(
|
|
147
|
+
access_token=new_access_token,
|
|
148
|
+
refresh_token=response.token.get("refresh_token")
|
|
149
|
+
or self._refresh_token,
|
|
150
|
+
expires_in=response.token.get("expires_in"),
|
|
151
|
+
)
|
|
152
|
+
return self._access_token
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def access_token(self) -> str | None:
|
|
156
|
+
"""The current access token, without triggering a refresh."""
|
|
157
|
+
return self._access_token
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def refresh_token(self) -> str | None:
|
|
161
|
+
"""The current refresh token."""
|
|
162
|
+
return self._refresh_token
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastapi-identity-model
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: FastAPI OIDC/OAuth2 middleware and relying-party router, built on py-identity-model
|
|
5
|
+
Project-URL: Homepage, https://github.com/jamescrowley321/py-identity-model
|
|
6
|
+
Project-URL: Repository, https://github.com/jamescrowley321/py-identity-model
|
|
7
|
+
Project-URL: Issues, https://github.com/jamescrowley321/py-identity-model/issues
|
|
8
|
+
Author-email: jamescrowley321 <jamescrowley151@gmail.com>
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
Keywords: authentication,fastapi,jwt,middleware,oauth2,oidc,openid-connect
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Framework :: FastAPI
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Security
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.12
|
|
23
|
+
Requires-Dist: fastapi>=0.115.0
|
|
24
|
+
Requires-Dist: itsdangerous>=2.0
|
|
25
|
+
Requires-Dist: py-identity-model>=3.1.0
|
|
26
|
+
Requires-Dist: starlette>=0.49.1
|
|
27
|
+
Provides-Extra: server
|
|
28
|
+
Requires-Dist: uvicorn[standard]>=0.23.0; extra == 'server'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# fastapi-identity-model
|
|
32
|
+
|
|
33
|
+
OIDC/OAuth2 integration for [FastAPI](https://fastapi.tiangolo.com/), built on
|
|
34
|
+
[`py-identity-model`](https://pypi.org/project/py-identity-model/) — an OpenID
|
|
35
|
+
Foundation–certified relying-party library.
|
|
36
|
+
|
|
37
|
+
It gives you two composable pieces:
|
|
38
|
+
|
|
39
|
+
| Piece | Use case |
|
|
40
|
+
|-------|----------|
|
|
41
|
+
| **`TokenValidationMiddleware`** + `Depends` helpers | Protect an **API / resource server** that receives `Authorization: Bearer <token>` |
|
|
42
|
+
| **`build_oidc_router`** | Add a browser **login flow** (authorization code + PKCE) to your app (relying party) |
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install fastapi-identity-model
|
|
48
|
+
# with a server for the demo:
|
|
49
|
+
pip install "fastapi-identity-model[server]"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Resource server — validate incoming Bearer tokens
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from fastapi import FastAPI, Depends
|
|
56
|
+
from fastapi_identity_model import (
|
|
57
|
+
TokenValidationMiddleware, OIDCSettings, get_current_user, require_scope,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
settings = OIDCSettings.from_env() # OIDC_DISCOVERY_URL, OIDC_CLIENT_ID, OIDC_REDIRECT_URI, ...
|
|
61
|
+
app = FastAPI()
|
|
62
|
+
app.add_middleware(
|
|
63
|
+
TokenValidationMiddleware,
|
|
64
|
+
discovery_url=settings.discovery_url,
|
|
65
|
+
audience=settings.audience,
|
|
66
|
+
excluded_paths=settings.excluded_paths,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@app.get("/api/me")
|
|
70
|
+
async def me(user = Depends(get_current_user)):
|
|
71
|
+
return {"sub": user.identity.name, "authenticated": user.identity.is_authenticated}
|
|
72
|
+
|
|
73
|
+
@app.get("/api/data", dependencies=[Depends(require_scope("api.read"))])
|
|
74
|
+
async def data():
|
|
75
|
+
return {"data": "protected"}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The middleware validates the token (signature, issuer, audience, expiry) via
|
|
79
|
+
`py-identity-model` and attaches a `ClaimsPrincipal` to `request.state.user`.
|
|
80
|
+
Invalid tokens → **401**; an unexpected server-side failure → **500** (never
|
|
81
|
+
masked as a 401).
|
|
82
|
+
|
|
83
|
+
## Relying party — browser login flow
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
import os
|
|
87
|
+
|
|
88
|
+
from fastapi import FastAPI, Request
|
|
89
|
+
from starlette.middleware.sessions import SessionMiddleware
|
|
90
|
+
from fastapi_identity_model import OIDCSettings, build_oidc_router
|
|
91
|
+
|
|
92
|
+
settings = OIDCSettings(
|
|
93
|
+
discovery_url="https://api.descope.com/v1/apps/<project_id>/.well-known/openid-configuration",
|
|
94
|
+
client_id="<client_id>",
|
|
95
|
+
redirect_uri="http://localhost:8000/auth/callback",
|
|
96
|
+
scope="openid profile email",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
app = FastAPI()
|
|
100
|
+
# Use a strong secret from the environment — never a committed literal, or
|
|
101
|
+
# anyone can forge a session cookie. same_site="lax" is required so the
|
|
102
|
+
# provider's redirect back to /auth/callback carries the session cookie.
|
|
103
|
+
app.add_middleware(
|
|
104
|
+
SessionMiddleware,
|
|
105
|
+
secret_key=os.environ["SESSION_SECRET"],
|
|
106
|
+
same_site="lax",
|
|
107
|
+
https_only=True, # behind TLS
|
|
108
|
+
)
|
|
109
|
+
app.include_router(build_oidc_router(settings), prefix="/auth")
|
|
110
|
+
|
|
111
|
+
@app.get("/me")
|
|
112
|
+
async def me(request: Request):
|
|
113
|
+
return request.session.get("oidc", {}) # {"sub", "claims", "userinfo"} after login
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Routes added: `GET /auth/login` → provider, `GET /auth/callback` (code exchange,
|
|
117
|
+
ID-token validation, **nonce check**, UserInfo `sub` verification), `POST /auth/logout`.
|
|
118
|
+
|
|
119
|
+
### Session & security
|
|
120
|
+
|
|
121
|
+
The router uses Starlette's `SessionMiddleware`, which **signs but does not
|
|
122
|
+
encrypt** the cookie. Stored identity claims are tamper-proof but readable by the
|
|
123
|
+
client. Raw tokens are stored **only** when you pass `build_oidc_router(settings,
|
|
124
|
+
store_tokens=True)` — enable that only with an encrypted or server-side session
|
|
125
|
+
store. For production, back the session with a server-side store.
|
|
126
|
+
|
|
127
|
+
## Token refresh
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from fastapi_identity_model import TokenManager
|
|
131
|
+
|
|
132
|
+
tm = TokenManager(discovery_url=..., client_id=..., client_secret=...)
|
|
133
|
+
tm.set_tokens(access_token=..., refresh_token=..., expires_in=3600)
|
|
134
|
+
access = await tm.get_access_token() # auto-refreshes shortly before expiry
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
Apache-2.0 — see the repository `LICENSE`.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
fastapi_identity_model/__init__.py,sha256=bDj_xyo9OBF2pkQQlUtxQ8Zo9mvmfgW23-ei6Hg06Qo,1308
|
|
2
|
+
fastapi_identity_model/config.py,sha256=fLiY1lBQ6DaFtpbJOz05kAd8UxQzox5M21ep8DTiAYA,3616
|
|
3
|
+
fastapi_identity_model/dependencies.py,sha256=SevwWcCyxtO-IspmLJWykBuz6eiQncDMk435-sxbEmI,7854
|
|
4
|
+
fastapi_identity_model/middleware.py,sha256=_hszDh2Cg6DcL4AAbvVwC8JkZSU6v9ss3BT3veutj8w,7558
|
|
5
|
+
fastapi_identity_model/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
fastapi_identity_model/rp.py,sha256=ETY2dbqW9WXoeG8zo1ZtcuCP6sClAYsyVUAlKhj3NGk,15703
|
|
7
|
+
fastapi_identity_model/token_manager.py,sha256=IO12GNoZdNMScgV1J7B7ZIPxVOI7Riz1S5OTcDpV0do,6025
|
|
8
|
+
fastapi_identity_model-0.1.0.dist-info/METADATA,sha256=eMBUjtBdC46b23SIFeaK4u4Zy_lI0sNHZb98salwsXc,5121
|
|
9
|
+
fastapi_identity_model-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
10
|
+
fastapi_identity_model-0.1.0.dist-info/RECORD,,
|