fastapi-identity-model 0.2.0__tar.gz → 0.2.2__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.2.0 → fastapi_identity_model-0.2.2}/.gitignore +14 -0
- {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/CHANGELOG.md +18 -1
- {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/PKG-INFO +5 -5
- {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/fastapi_identity_model/middleware.py +51 -12
- {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/pyproject.toml +10 -9
- {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/tests/test_middleware.py +35 -3
- {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/tests/test_security_error_oracle.py +0 -5
- {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/README.md +0 -0
- {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/fastapi_identity_model/__init__.py +0 -0
- {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/fastapi_identity_model/config.py +0 -0
- {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/fastapi_identity_model/dependencies.py +0 -0
- {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/fastapi_identity_model/py.typed +0 -0
- {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/fastapi_identity_model/rp.py +0 -0
- {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/fastapi_identity_model/token_manager.py +0 -0
- {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/tests/test_config.py +0 -0
- {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/tests/test_dependencies.py +0 -0
- {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/tests/test_rp.py +0 -0
- {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/tests/test_security_cnf_bound_as_bearer.py +0 -0
- {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/tests/test_security_id_token_substitution.py +0 -0
- {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/tests/test_token_manager.py +0 -0
|
@@ -135,6 +135,11 @@ dmypy.json
|
|
|
135
135
|
# Allow test fixture env files (no secrets — local Docker only)
|
|
136
136
|
!.env.node-oidc
|
|
137
137
|
!.env.keycloak
|
|
138
|
+
!.env.identityserver
|
|
139
|
+
|
|
140
|
+
# Token characterization reports — generated by the integration suites,
|
|
141
|
+
# uploaded as CI artifacts, never committed.
|
|
142
|
+
token_characterization_*.md
|
|
138
143
|
*.crt
|
|
139
144
|
*.key
|
|
140
145
|
*.pfx
|
|
@@ -158,6 +163,12 @@ conformance/results/hosted/rp-logs/
|
|
|
158
163
|
.ralph/
|
|
159
164
|
PROMPT.md
|
|
160
165
|
|
|
166
|
+
# Load-report artifacts — written by the load-capacity / load-soak / load-smoke
|
|
167
|
+
# runs, uploaded as CI artifacts, never committed.
|
|
168
|
+
capacity-report.txt
|
|
169
|
+
soak-report.txt
|
|
170
|
+
load-smoke-report.txt
|
|
171
|
+
|
|
161
172
|
# Terraform
|
|
162
173
|
infra/**/.terraform/
|
|
163
174
|
infra/**/*.tfstate*
|
|
@@ -170,3 +181,6 @@ infra/**/expired_token.txt
|
|
|
170
181
|
mutants/
|
|
171
182
|
mutmut-stats.json
|
|
172
183
|
.mutmut-cache
|
|
184
|
+
|
|
185
|
+
# moon build cache — never commit (tracked cache poisons affected-detection)
|
|
186
|
+
.moon/cache/
|
|
@@ -8,12 +8,29 @@ inserted below the marker.
|
|
|
8
8
|
|
|
9
9
|
<!-- psr: next release -->
|
|
10
10
|
|
|
11
|
+
## v0.2.2 (2026-08-22)
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
- **fastapi**: Return 503 (not 401) when the OIDC provider is unreachable
|
|
16
|
+
([`26f425d`](https://github.com/jamescrowley321/identity-model/commit/26f425d61ea5e21a365d9e71fbdac0292224d5b5))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
## v0.2.1 (2026-08-10)
|
|
20
|
+
|
|
21
|
+
### Bug Fixes
|
|
22
|
+
|
|
23
|
+
- **fastapi**: Uniform 401 body across validation failures (F-18, CWE-209)
|
|
24
|
+
([#508](https://github.com/jamescrowley321/identity-model/pull/508),
|
|
25
|
+
[`d4e8aed`](https://github.com/jamescrowley321/identity-model/commit/d4e8aedcd9e2622aab9139ae5f6b09e2e2c1eaf0))
|
|
26
|
+
|
|
27
|
+
|
|
11
28
|
## v0.2.0 (2026-08-09)
|
|
12
29
|
|
|
13
30
|
### Features
|
|
14
31
|
|
|
15
32
|
- **fastapi**: Opt-in access-token-marker enforcement (F-07)
|
|
16
|
-
([`4e0eb10`](https://github.com/jamescrowley321/
|
|
33
|
+
([`4e0eb10`](https://github.com/jamescrowley321/identity-model/commit/4e0eb10aea9c6451614b5b6cfaa4b2c410f3496b))
|
|
17
34
|
|
|
18
35
|
|
|
19
36
|
## 0.1.0 (2026-07-08)
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: fastapi-identity-model
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: FastAPI OIDC/OAuth2 middleware and relying-party router, built on py-identity-model
|
|
5
|
-
Project-URL: Homepage, https://github.com/jamescrowley321/
|
|
6
|
-
Project-URL: Repository, https://github.com/jamescrowley321/
|
|
7
|
-
Project-URL: Issues, https://github.com/jamescrowley321/
|
|
5
|
+
Project-URL: Homepage, https://github.com/jamescrowley321/identity-model
|
|
6
|
+
Project-URL: Repository, https://github.com/jamescrowley321/identity-model
|
|
7
|
+
Project-URL: Issues, https://github.com/jamescrowley321/identity-model/issues
|
|
8
8
|
Author-email: jamescrowley321 <jamescrowley151@gmail.com>
|
|
9
9
|
License-Expression: Apache-2.0
|
|
10
10
|
Keywords: authentication,fastapi,jwt,middleware,oauth2,oidc,openid-connect
|
{fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/fastapi_identity_model/middleware.py
RENAMED
|
@@ -50,6 +50,33 @@ _ID_TOKEN_ONLY_CLAIMS = ("nonce", "at_hash", "c_hash")
|
|
|
50
50
|
# a client_credentials token minted with no scopes) legitimately carry neither.
|
|
51
51
|
_DEFAULT_ACCESS_TOKEN_MARKER_CLAIMS = ("scope", "scp")
|
|
52
52
|
|
|
53
|
+
# F-18 (CWE-209): every token-validation failure returns this single generic 401
|
|
54
|
+
# body so the response cannot be used as an oracle to distinguish the rejection
|
|
55
|
+
# cause (invalid signature vs wrong audience vs expired). The specific cause is
|
|
56
|
+
# logged server-side for operators, never returned to the caller.
|
|
57
|
+
_GENERIC_401_DETAIL = "Invalid or unauthorized token"
|
|
58
|
+
|
|
59
|
+
# Stable message prefixes the core library uses for discovery/JWKS *fetch*
|
|
60
|
+
# failures (py_identity_model.core.error_handlers). The core wraps a failed
|
|
61
|
+
# fetch in an unsuccessful response, so validate_token surfaces it as a
|
|
62
|
+
# TokenValidationException — NOT a NetworkException — which would otherwise be
|
|
63
|
+
# reported as a 401 "invalid token" for what is really a transient provider
|
|
64
|
+
# outage. We match these prefixes to map such failures to 503 instead, so
|
|
65
|
+
# callers back off and 5xx monitoring fires. This is a pragmatic bridge: the
|
|
66
|
+
# clean fix is in py-identity-model (raise a NetworkException subtype from
|
|
67
|
+
# validate_disco_response / validate_jwks_response), after which the
|
|
68
|
+
# `except NetworkException` branch below handles it directly.
|
|
69
|
+
_UPSTREAM_FETCH_FAILURE_PREFIXES = (
|
|
70
|
+
"Network error during discovery",
|
|
71
|
+
"Network error during JWKS",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _is_upstream_fetch_failure(exc: Exception) -> bool:
|
|
76
|
+
"""Whether a library exception is a transient upstream fetch failure."""
|
|
77
|
+
message = str(exc)
|
|
78
|
+
return any(message.startswith(p) for p in _UPSTREAM_FETCH_FAILURE_PREFIXES)
|
|
79
|
+
|
|
53
80
|
|
|
54
81
|
class TokenValidationMiddleware(BaseHTTPMiddleware):
|
|
55
82
|
"""
|
|
@@ -207,21 +234,33 @@ class TokenValidationMiddleware(BaseHTTPMiddleware):
|
|
|
207
234
|
request.state.claims = claims
|
|
208
235
|
request.state.token = token
|
|
209
236
|
return None
|
|
210
|
-
except NetworkException:
|
|
211
|
-
# Discovery/JWKS/network fetch failure is a transient server fault,
|
|
212
|
-
# not an authentication decision — surface 5xx so callers retry
|
|
213
|
-
# instead of treating a provider outage as a bad token.
|
|
214
|
-
logger.exception("Network error during token validation")
|
|
215
|
-
return JSONResponse(
|
|
216
|
-
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
217
|
-
content={"detail": "Authentication temporarily unavailable"},
|
|
218
|
-
)
|
|
219
237
|
except PyIdentityModelException as e:
|
|
220
|
-
|
|
238
|
+
# A provider outage is a transient server fault, not an auth
|
|
239
|
+
# decision: surface 503 so callers retry instead of treating it as a
|
|
240
|
+
# bad token. This covers both a real NetworkException (a
|
|
241
|
+
# PyIdentityModelException subclass — the clean path if the core
|
|
242
|
+
# raises one) and a discovery/JWKS fetch failure the core currently
|
|
243
|
+
# surfaces as a TokenValidationException (_UPSTREAM_FETCH_FAILURE_
|
|
244
|
+
# PREFIXES). Checked here rather than a separate `except
|
|
245
|
+
# NetworkException` block so both share one 503 path.
|
|
246
|
+
if isinstance(e, NetworkException) or _is_upstream_fetch_failure(e):
|
|
247
|
+
logger.exception("Upstream fetch failure during token validation")
|
|
248
|
+
return JSONResponse(
|
|
249
|
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
250
|
+
content={"detail": "Authentication temporarily unavailable"},
|
|
251
|
+
)
|
|
252
|
+
# Uniform 401 body — do NOT echo the stage-specific cause
|
|
253
|
+
# (signature/audience/expiry), which would form a CWE-209 oracle
|
|
254
|
+
# (F-18). Log the real cause server-side for operators.
|
|
255
|
+
logger.info("Token rejected during validation: %s", e)
|
|
256
|
+
return self._unauthorized(_GENERIC_401_DETAIL)
|
|
221
257
|
except InvalidTokenError as e:
|
|
222
258
|
# A malformed/undecodable token (e.g. raw pyjwt DecodeError from
|
|
223
|
-
# header parsing during key lookup) is a client error, not a 500
|
|
224
|
-
|
|
259
|
+
# header parsing during key lookup) is a client error, not a 500 —
|
|
260
|
+
# and returns the same generic body as any other rejection so the
|
|
261
|
+
# response can't distinguish "malformed" from "invalid".
|
|
262
|
+
logger.info("Malformed token rejected: %s", e)
|
|
263
|
+
return self._unauthorized(_GENERIC_401_DETAIL)
|
|
225
264
|
except Exception:
|
|
226
265
|
# A genuinely unexpected (non-library) failure is a server fault,
|
|
227
266
|
# not an auth decision. Surface a 500 without leaking internals.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "fastapi-identity-model"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.2"
|
|
4
4
|
description = "FastAPI OIDC/OAuth2 middleware and relying-party router, built on py-identity-model"
|
|
5
5
|
authors = [{ name = "jamescrowley321", email = "jamescrowley151@gmail.com" }]
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -42,9 +42,9 @@ dependencies = [
|
|
|
42
42
|
server = ["uvicorn[standard]>=0.23.0"]
|
|
43
43
|
|
|
44
44
|
[project.urls]
|
|
45
|
-
Homepage = "https://github.com/jamescrowley321/
|
|
46
|
-
Repository = "https://github.com/jamescrowley321/
|
|
47
|
-
Issues = "https://github.com/jamescrowley321/
|
|
45
|
+
Homepage = "https://github.com/jamescrowley321/identity-model"
|
|
46
|
+
Repository = "https://github.com/jamescrowley321/identity-model"
|
|
47
|
+
Issues = "https://github.com/jamescrowley321/identity-model/issues"
|
|
48
48
|
|
|
49
49
|
[tool.uv.sources]
|
|
50
50
|
py-identity-model = { workspace = true }
|
|
@@ -57,12 +57,13 @@ requires = ["hatchling"]
|
|
|
57
57
|
build-backend = "hatchling.build"
|
|
58
58
|
|
|
59
59
|
# ── Release automation (python-semantic-release) ──────────────────────
|
|
60
|
-
# Invoked from
|
|
61
|
-
# semantic-release -c packages/fastapi-identity-model/pyproject.toml version
|
|
60
|
+
# Invoked from py/ (CONS-2.1) via
|
|
61
|
+
# cd py && semantic-release -c packages/fastapi-identity-model/pyproject.toml version
|
|
62
62
|
# (release.yml, after the root release job) — all paths below are
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
63
|
+
# py/-relative (semantic-release resolves commit_parser + version_toml relative
|
|
64
|
+
# to the CWD, not to this config file's directory). Only commits scoped
|
|
65
|
+
# "(fastapi)" drive this version; every other commit belongs to the root
|
|
66
|
+
# py-identity-model pipeline. See tools/release_parsers.py for the scope routing.
|
|
66
67
|
[tool.semantic_release]
|
|
67
68
|
commit_parser = "tools/release_parsers.py:FastapiCommitParser"
|
|
68
69
|
tag_format = "fastapi-identity-model-v{version}"
|
|
@@ -74,7 +74,8 @@ async def test_invalid_token_returns_401(monkeypatch):
|
|
|
74
74
|
async with _client(_app(monkeypatch, validate)) as client:
|
|
75
75
|
resp = await client.get("/me", headers={"Authorization": "Bearer bad"})
|
|
76
76
|
assert resp.status_code == 401
|
|
77
|
-
|
|
77
|
+
# F-18: uniform, non-oracular 401 body (does not echo the validation cause).
|
|
78
|
+
assert resp.json()["detail"] == mw._GENERIC_401_DETAIL
|
|
78
79
|
|
|
79
80
|
|
|
80
81
|
async def test_unexpected_error_returns_500(monkeypatch):
|
|
@@ -85,13 +86,43 @@ async def test_unexpected_error_returns_500(monkeypatch):
|
|
|
85
86
|
assert resp.json()["detail"] == "Internal server error during authentication"
|
|
86
87
|
|
|
87
88
|
|
|
88
|
-
async def
|
|
89
|
+
async def test_network_exception_returns_503(monkeypatch):
|
|
90
|
+
# The `except NetworkException` branch: if the core raises a NetworkException
|
|
91
|
+
# subtype, a provider outage is a 503.
|
|
89
92
|
validate = AsyncMock(side_effect=DiscoveryException("provider unreachable"))
|
|
90
93
|
async with _client(_app(monkeypatch, validate)) as client:
|
|
91
94
|
resp = await client.get("/me", headers={"Authorization": "Bearer x"})
|
|
92
95
|
assert resp.status_code == 503
|
|
93
96
|
|
|
94
97
|
|
|
98
|
+
async def test_upstream_fetch_failure_returns_503(monkeypatch):
|
|
99
|
+
# The real path: a discovery/JWKS fetch failure surfaces from the core as a
|
|
100
|
+
# TokenValidationException carrying the "Network error during ..." message
|
|
101
|
+
# (NOT a NetworkException), so the middleware must still map it to 503 — a
|
|
102
|
+
# transient provider outage, not an "invalid token". (The prior test mocked
|
|
103
|
+
# DiscoveryException, which the real disco/JWKS path never raises, so this
|
|
104
|
+
# 401->503 gap went uncaught.)
|
|
105
|
+
validate = AsyncMock(
|
|
106
|
+
side_effect=TokenValidationException(
|
|
107
|
+
"Network error during discovery document request: connection refused"
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
async with _client(_app(monkeypatch, validate)) as client:
|
|
111
|
+
resp = await client.get("/me", headers={"Authorization": "Bearer x"})
|
|
112
|
+
assert resp.status_code == 503
|
|
113
|
+
assert resp.json()["detail"] == "Authentication temporarily unavailable"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def test_generic_validation_failure_still_returns_401(monkeypatch):
|
|
117
|
+
# The 503 mapping must be narrow: a genuinely invalid token (a non-network
|
|
118
|
+
# TokenValidationException) stays a 401.
|
|
119
|
+
validate = AsyncMock(side_effect=TokenValidationException("Invalid signature"))
|
|
120
|
+
async with _client(_app(monkeypatch, validate)) as client:
|
|
121
|
+
resp = await client.get("/me", headers={"Authorization": "Bearer x"})
|
|
122
|
+
assert resp.status_code == 401
|
|
123
|
+
assert resp.json()["detail"] == mw._GENERIC_401_DETAIL
|
|
124
|
+
|
|
125
|
+
|
|
95
126
|
async def test_malformed_token_returns_401_not_500(monkeypatch):
|
|
96
127
|
# A non-JWT ("invalid-token-123") makes the library's header parsing raise
|
|
97
128
|
# a raw pyjwt DecodeError before validation wraps it; that is a client
|
|
@@ -100,7 +131,8 @@ async def test_malformed_token_returns_401_not_500(monkeypatch):
|
|
|
100
131
|
async with _client(_app(monkeypatch, validate)) as client:
|
|
101
132
|
resp = await client.get("/me", headers={"Authorization": "Bearer invalid"})
|
|
102
133
|
assert resp.status_code == 401
|
|
103
|
-
|
|
134
|
+
# F-18: malformed tokens return the same uniform body as any other rejection.
|
|
135
|
+
assert resp.json()["detail"] == mw._GENERIC_401_DETAIL
|
|
104
136
|
|
|
105
137
|
|
|
106
138
|
async def test_id_token_rejected_as_access_token(monkeypatch):
|
{fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/tests/test_security_error_oracle.py
RENAMED
|
@@ -54,11 +54,6 @@ def _client(app: FastAPI) -> httpx.AsyncClient:
|
|
|
54
54
|
)
|
|
55
55
|
|
|
56
56
|
|
|
57
|
-
@pytest.mark.xfail(
|
|
58
|
-
strict=True,
|
|
59
|
-
reason="F-18: 401 body echoes the stage-specific validation error, forming a "
|
|
60
|
-
"token-validation oracle (CWE-209)",
|
|
61
|
-
)
|
|
62
57
|
async def test_401_body_is_uniform_across_failure_stages(monkeypatch):
|
|
63
58
|
# Three different validation failures, each a PyIdentityModelException.
|
|
64
59
|
failures = [
|
|
File without changes
|
{fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/fastapi_identity_model/__init__.py
RENAMED
|
File without changes
|
{fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/fastapi_identity_model/config.py
RENAMED
|
File without changes
|
{fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/fastapi_identity_model/dependencies.py
RENAMED
|
File without changes
|
{fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/fastapi_identity_model/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|