fastapi-identity-model 0.1.0__tar.gz
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-0.1.0/.gitignore +166 -0
- fastapi_identity_model-0.1.0/CHANGELOG.md +50 -0
- fastapi_identity_model-0.1.0/PKG-INFO +139 -0
- fastapi_identity_model-0.1.0/README.md +109 -0
- fastapi_identity_model-0.1.0/fastapi_identity_model/__init__.py +49 -0
- fastapi_identity_model-0.1.0/fastapi_identity_model/config.py +83 -0
- fastapi_identity_model-0.1.0/fastapi_identity_model/dependencies.py +287 -0
- fastapi_identity_model-0.1.0/fastapi_identity_model/middleware.py +180 -0
- fastapi_identity_model-0.1.0/fastapi_identity_model/py.typed +0 -0
- fastapi_identity_model-0.1.0/fastapi_identity_model/rp.py +416 -0
- fastapi_identity_model-0.1.0/fastapi_identity_model/token_manager.py +162 -0
- fastapi_identity_model-0.1.0/pyproject.toml +57 -0
- fastapi_identity_model-0.1.0/tests/test_config.py +76 -0
- fastapi_identity_model-0.1.0/tests/test_dependencies.py +107 -0
- fastapi_identity_model-0.1.0/tests/test_middleware.py +154 -0
- fastapi_identity_model-0.1.0/tests/test_rp.py +329 -0
- fastapi_identity_model-0.1.0/tests/test_token_manager.py +150 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
pip-wheel-metadata/
|
|
24
|
+
share/python-wheels/
|
|
25
|
+
*.egg-info/
|
|
26
|
+
.installed.cfg
|
|
27
|
+
*.egg
|
|
28
|
+
MANIFEST
|
|
29
|
+
|
|
30
|
+
# PyInstaller
|
|
31
|
+
# Usually these files are written by a python script from a template
|
|
32
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
33
|
+
*.manifest
|
|
34
|
+
*.spec
|
|
35
|
+
|
|
36
|
+
# Installer logs
|
|
37
|
+
pip-log.txt
|
|
38
|
+
pip-delete-this-directory.txt
|
|
39
|
+
|
|
40
|
+
# Unit test / coverage reports
|
|
41
|
+
htmlcov/
|
|
42
|
+
.tox/
|
|
43
|
+
.nox/
|
|
44
|
+
.coverage
|
|
45
|
+
.coverage.*
|
|
46
|
+
.cache
|
|
47
|
+
nosetests.xml
|
|
48
|
+
coverage.xml
|
|
49
|
+
*.cover
|
|
50
|
+
*.py,cover
|
|
51
|
+
.hypothesis/
|
|
52
|
+
.pytest_cache/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
target/
|
|
76
|
+
|
|
77
|
+
# Jupyter Notebook
|
|
78
|
+
.ipynb_checkpoints
|
|
79
|
+
|
|
80
|
+
# IPython
|
|
81
|
+
profile_default/
|
|
82
|
+
ipython_config.py
|
|
83
|
+
|
|
84
|
+
# pyenv
|
|
85
|
+
.python-version
|
|
86
|
+
|
|
87
|
+
# pipenv
|
|
88
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
89
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
90
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
91
|
+
# install all needed dependencies.
|
|
92
|
+
#Pipfile.lock
|
|
93
|
+
|
|
94
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
|
95
|
+
__pypackages__/
|
|
96
|
+
|
|
97
|
+
# Celery stuff
|
|
98
|
+
celerybeat-schedule
|
|
99
|
+
celerybeat.pid
|
|
100
|
+
|
|
101
|
+
# SageMath parsed files
|
|
102
|
+
*.sage.py
|
|
103
|
+
|
|
104
|
+
# Environments
|
|
105
|
+
.env
|
|
106
|
+
.venv
|
|
107
|
+
env/
|
|
108
|
+
venv/
|
|
109
|
+
ENV/
|
|
110
|
+
env.bak/
|
|
111
|
+
venv.bak/
|
|
112
|
+
|
|
113
|
+
# Spyder project settings
|
|
114
|
+
.spyderproject
|
|
115
|
+
.spyproject
|
|
116
|
+
|
|
117
|
+
# Rope project settings
|
|
118
|
+
.ropeproject
|
|
119
|
+
|
|
120
|
+
# mkdocs documentation
|
|
121
|
+
/site
|
|
122
|
+
|
|
123
|
+
# mypy
|
|
124
|
+
.mypy_cache/
|
|
125
|
+
.dmypy.json
|
|
126
|
+
dmypy.json
|
|
127
|
+
|
|
128
|
+
# Pyre type checker
|
|
129
|
+
.pyre/
|
|
130
|
+
|
|
131
|
+
# PyCharm
|
|
132
|
+
.idea
|
|
133
|
+
|
|
134
|
+
*.env*
|
|
135
|
+
# Allow test fixture env files (no secrets — local Docker only)
|
|
136
|
+
!.env.node-oidc
|
|
137
|
+
*.crt
|
|
138
|
+
*.key
|
|
139
|
+
*.pfx
|
|
140
|
+
.secrets.baseline
|
|
141
|
+
|
|
142
|
+
# Claude Code runtime state
|
|
143
|
+
.claude/mcp.json
|
|
144
|
+
.claude/worktrees/
|
|
145
|
+
.claude/task-state.md
|
|
146
|
+
.claude/task-state*.md
|
|
147
|
+
.claude/review-*.md
|
|
148
|
+
.claude/review-*.patch
|
|
149
|
+
.claude/pre-review-sha.txt
|
|
150
|
+
|
|
151
|
+
# Conformance plan export zips + per-test RP logs (binary evidence artifacts;
|
|
152
|
+
# preserved via CI upload-artifact, not committed)
|
|
153
|
+
conformance/results/hosted/*.zip
|
|
154
|
+
conformance/results/hosted/rp-logs/
|
|
155
|
+
|
|
156
|
+
# Ralph orchestrator runtime state
|
|
157
|
+
.ralph/
|
|
158
|
+
PROMPT.md
|
|
159
|
+
|
|
160
|
+
# Terraform
|
|
161
|
+
infra/**/.terraform/
|
|
162
|
+
infra/**/*.tfstate*
|
|
163
|
+
infra/**/*.tfstate.backup
|
|
164
|
+
infra/**/*.tfvars
|
|
165
|
+
infra/**/access_key.json
|
|
166
|
+
infra/**/expired_token.txt
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `fastapi-identity-model` are documented here. This project
|
|
4
|
+
adheres to [Semantic Versioning](https://semver.org/) and is released
|
|
5
|
+
independently of the core `py-identity-model` library.
|
|
6
|
+
|
|
7
|
+
## 0.1.0 (2026-07-08)
|
|
8
|
+
|
|
9
|
+
Initial release. Extracted and hardened from the `py-identity-model` FastAPI
|
|
10
|
+
example, then driven through the OIDF conformance suite: the RP router passes
|
|
11
|
+
the same local Basic RP, Config RP, and Form Post RP plans the core library is
|
|
12
|
+
certified against (13/13, 5/5, 13/13 — regression stage, see #437).
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
- `TokenValidationMiddleware` — resource-server Bearer-token validation that
|
|
16
|
+
attaches a `ClaimsPrincipal` to `request.state`.
|
|
17
|
+
- `Depends` helpers: `get_current_user`, `get_claims`, `get_token`,
|
|
18
|
+
`get_claim_value`, `get_claim_values`, `require_claim`, `require_scope`.
|
|
19
|
+
- `build_oidc_router` — mountable relying-party login flow (authorization code +
|
|
20
|
+
PKCE) with state/nonce, ID-token validation, and UserInfo `sub` verification.
|
|
21
|
+
- `POST /callback` on the RP router — OAuth 2.0 `form_post` response mode,
|
|
22
|
+
sharing the exact GET validation path (parsed with stdlib `parse_qsl`; no
|
|
23
|
+
`python-multipart` dependency). Requires the session cookie to be issued
|
|
24
|
+
with `same_site="none"` in real browsers.
|
|
25
|
+
- `build_oidc_router(fetch_userinfo=False)` — skip the UserInfo round-trip and
|
|
26
|
+
anchor identity on the validated ID token.
|
|
27
|
+
- `OIDCSettings` — typed configuration with `from_env()`.
|
|
28
|
+
- `TokenManager` — access-token refresh lifecycle over the native async refresh grant.
|
|
29
|
+
|
|
30
|
+
### Changed vs. the former example
|
|
31
|
+
- Middleware returns **503** for a discovery/JWKS/network fault and **500** for a
|
|
32
|
+
genuinely unexpected error, instead of masking either as a 401.
|
|
33
|
+
- `TokenManager` uses the async discovery + native `aio.refresh_token` grant
|
|
34
|
+
(removes a blocking sync call in async context and a hand-rolled token POST).
|
|
35
|
+
|
|
36
|
+
### Security
|
|
37
|
+
- The RP router rejects a discovery document whose issuer does not match the
|
|
38
|
+
URL it was retrieved from (OIDC Discovery 1.0 §4.3 issuer mix-up defense).
|
|
39
|
+
- The RP router refuses to establish a session when the token response has no
|
|
40
|
+
ID token, and enforces the UserInfo `sub` match as a hard gate (a mismatch
|
|
41
|
+
fails the login instead of being swallowed).
|
|
42
|
+
- Transient login-flow state (state/nonce/PKCE verifier) is kept under a
|
|
43
|
+
separate session key from the identity and is single-use (popped on callback),
|
|
44
|
+
so an in-flight login is never read as an authenticated identity.
|
|
45
|
+
- `TokenValidationMiddleware` rejects an ID token presented as an access token,
|
|
46
|
+
requires a non-empty `audience` (a `None` audience skips `aud` enforcement for
|
|
47
|
+
aud-less tokens), lets CORS preflight through, and matches excluded subpaths.
|
|
48
|
+
- `POST /auth/logout` (was `GET`) so a cross-site request cannot force logout.
|
|
49
|
+
- `TokenManager` refreshes under an `asyncio.Lock` (no concurrent-refresh
|
|
50
|
+
token-family invalidation) and treats `expires_in=0` as already expired.
|
|
@@ -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,109 @@
|
|
|
1
|
+
# fastapi-identity-model
|
|
2
|
+
|
|
3
|
+
OIDC/OAuth2 integration for [FastAPI](https://fastapi.tiangolo.com/), built on
|
|
4
|
+
[`py-identity-model`](https://pypi.org/project/py-identity-model/) — an OpenID
|
|
5
|
+
Foundation–certified relying-party library.
|
|
6
|
+
|
|
7
|
+
It gives you two composable pieces:
|
|
8
|
+
|
|
9
|
+
| Piece | Use case |
|
|
10
|
+
|-------|----------|
|
|
11
|
+
| **`TokenValidationMiddleware`** + `Depends` helpers | Protect an **API / resource server** that receives `Authorization: Bearer <token>` |
|
|
12
|
+
| **`build_oidc_router`** | Add a browser **login flow** (authorization code + PKCE) to your app (relying party) |
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install fastapi-identity-model
|
|
18
|
+
# with a server for the demo:
|
|
19
|
+
pip install "fastapi-identity-model[server]"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Resource server — validate incoming Bearer tokens
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from fastapi import FastAPI, Depends
|
|
26
|
+
from fastapi_identity_model import (
|
|
27
|
+
TokenValidationMiddleware, OIDCSettings, get_current_user, require_scope,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
settings = OIDCSettings.from_env() # OIDC_DISCOVERY_URL, OIDC_CLIENT_ID, OIDC_REDIRECT_URI, ...
|
|
31
|
+
app = FastAPI()
|
|
32
|
+
app.add_middleware(
|
|
33
|
+
TokenValidationMiddleware,
|
|
34
|
+
discovery_url=settings.discovery_url,
|
|
35
|
+
audience=settings.audience,
|
|
36
|
+
excluded_paths=settings.excluded_paths,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
@app.get("/api/me")
|
|
40
|
+
async def me(user = Depends(get_current_user)):
|
|
41
|
+
return {"sub": user.identity.name, "authenticated": user.identity.is_authenticated}
|
|
42
|
+
|
|
43
|
+
@app.get("/api/data", dependencies=[Depends(require_scope("api.read"))])
|
|
44
|
+
async def data():
|
|
45
|
+
return {"data": "protected"}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The middleware validates the token (signature, issuer, audience, expiry) via
|
|
49
|
+
`py-identity-model` and attaches a `ClaimsPrincipal` to `request.state.user`.
|
|
50
|
+
Invalid tokens → **401**; an unexpected server-side failure → **500** (never
|
|
51
|
+
masked as a 401).
|
|
52
|
+
|
|
53
|
+
## Relying party — browser login flow
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
import os
|
|
57
|
+
|
|
58
|
+
from fastapi import FastAPI, Request
|
|
59
|
+
from starlette.middleware.sessions import SessionMiddleware
|
|
60
|
+
from fastapi_identity_model import OIDCSettings, build_oidc_router
|
|
61
|
+
|
|
62
|
+
settings = OIDCSettings(
|
|
63
|
+
discovery_url="https://api.descope.com/v1/apps/<project_id>/.well-known/openid-configuration",
|
|
64
|
+
client_id="<client_id>",
|
|
65
|
+
redirect_uri="http://localhost:8000/auth/callback",
|
|
66
|
+
scope="openid profile email",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
app = FastAPI()
|
|
70
|
+
# Use a strong secret from the environment — never a committed literal, or
|
|
71
|
+
# anyone can forge a session cookie. same_site="lax" is required so the
|
|
72
|
+
# provider's redirect back to /auth/callback carries the session cookie.
|
|
73
|
+
app.add_middleware(
|
|
74
|
+
SessionMiddleware,
|
|
75
|
+
secret_key=os.environ["SESSION_SECRET"],
|
|
76
|
+
same_site="lax",
|
|
77
|
+
https_only=True, # behind TLS
|
|
78
|
+
)
|
|
79
|
+
app.include_router(build_oidc_router(settings), prefix="/auth")
|
|
80
|
+
|
|
81
|
+
@app.get("/me")
|
|
82
|
+
async def me(request: Request):
|
|
83
|
+
return request.session.get("oidc", {}) # {"sub", "claims", "userinfo"} after login
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Routes added: `GET /auth/login` → provider, `GET /auth/callback` (code exchange,
|
|
87
|
+
ID-token validation, **nonce check**, UserInfo `sub` verification), `POST /auth/logout`.
|
|
88
|
+
|
|
89
|
+
### Session & security
|
|
90
|
+
|
|
91
|
+
The router uses Starlette's `SessionMiddleware`, which **signs but does not
|
|
92
|
+
encrypt** the cookie. Stored identity claims are tamper-proof but readable by the
|
|
93
|
+
client. Raw tokens are stored **only** when you pass `build_oidc_router(settings,
|
|
94
|
+
store_tokens=True)` — enable that only with an encrypted or server-side session
|
|
95
|
+
store. For production, back the session with a server-side store.
|
|
96
|
+
|
|
97
|
+
## Token refresh
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from fastapi_identity_model import TokenManager
|
|
101
|
+
|
|
102
|
+
tm = TokenManager(discovery_url=..., client_id=..., client_secret=...)
|
|
103
|
+
tm.set_tokens(access_token=..., refresh_token=..., expires_in=3600)
|
|
104
|
+
access = await tm.get_access_token() # auto-refreshes shortly before expiry
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
Apache-2.0 — see the repository `LICENSE`.
|
|
@@ -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
|
+
)
|