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.
Files changed (20) hide show
  1. {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/.gitignore +14 -0
  2. {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/CHANGELOG.md +18 -1
  3. {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/PKG-INFO +5 -5
  4. {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/fastapi_identity_model/middleware.py +51 -12
  5. {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/pyproject.toml +10 -9
  6. {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/tests/test_middleware.py +35 -3
  7. {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/tests/test_security_error_oracle.py +0 -5
  8. {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/README.md +0 -0
  9. {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/fastapi_identity_model/__init__.py +0 -0
  10. {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/fastapi_identity_model/config.py +0 -0
  11. {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/fastapi_identity_model/dependencies.py +0 -0
  12. {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/fastapi_identity_model/py.typed +0 -0
  13. {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/fastapi_identity_model/rp.py +0 -0
  14. {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/fastapi_identity_model/token_manager.py +0 -0
  15. {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/tests/test_config.py +0 -0
  16. {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/tests/test_dependencies.py +0 -0
  17. {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/tests/test_rp.py +0 -0
  18. {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/tests/test_security_cnf_bound_as_bearer.py +0 -0
  19. {fastapi_identity_model-0.2.0 → fastapi_identity_model-0.2.2}/tests/test_security_id_token_substitution.py +0 -0
  20. {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/py-identity-model/commit/4e0eb10aea9c6451614b5b6cfaa4b2c410f3496b))
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.4
1
+ Metadata-Version: 2.5
2
2
  Name: fastapi-identity-model
3
- Version: 0.2.0
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/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
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
@@ -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
- return self._unauthorized(f"Token validation failed: {e!s}")
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
- return self._unauthorized(f"Invalid token: {e!s}")
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.0"
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/py-identity-model"
46
- Repository = "https://github.com/jamescrowley321/py-identity-model"
47
- Issues = "https://github.com/jamescrowley321/py-identity-model/issues"
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 the REPO ROOT via
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
- # repo-root-relative. Only commits scoped "(fastapi)" drive this version;
64
- # every other commit belongs to the root py-identity-model pipeline.
65
- # See tools/release_parsers.py for the scope routing.
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
- assert "Token validation failed" in resp.json()["detail"]
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 test_network_error_returns_503(monkeypatch):
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
- assert "Invalid token" in resp.json()["detail"]
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):
@@ -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 = [