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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any