fastapi-identity-model 0.2.2__tar.gz → 0.2.3__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.2 → fastapi_identity_model-0.2.3}/.gitignore +3 -0
  2. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.2.3}/CHANGELOG.md +15 -0
  3. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.2.3}/PKG-INFO +1 -1
  4. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.2.3}/fastapi_identity_model/rp.py +22 -9
  5. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.2.3}/pyproject.toml +1 -1
  6. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.2.3}/tests/test_rp.py +84 -15
  7. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.2.3}/README.md +0 -0
  8. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.2.3}/fastapi_identity_model/__init__.py +0 -0
  9. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.2.3}/fastapi_identity_model/config.py +0 -0
  10. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.2.3}/fastapi_identity_model/dependencies.py +0 -0
  11. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.2.3}/fastapi_identity_model/middleware.py +0 -0
  12. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.2.3}/fastapi_identity_model/py.typed +0 -0
  13. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.2.3}/fastapi_identity_model/token_manager.py +0 -0
  14. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.2.3}/tests/test_config.py +0 -0
  15. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.2.3}/tests/test_dependencies.py +0 -0
  16. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.2.3}/tests/test_middleware.py +0 -0
  17. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.2.3}/tests/test_security_cnf_bound_as_bearer.py +0 -0
  18. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.2.3}/tests/test_security_error_oracle.py +0 -0
  19. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.2.3}/tests/test_security_id_token_substitution.py +0 -0
  20. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.2.3}/tests/test_token_manager.py +0 -0
@@ -3,6 +3,9 @@ __pycache__/
3
3
  *.py[cod]
4
4
  *$py.class
5
5
 
6
+ # Local scratch/working artifacts — never committed
7
+ scratch_artifacts/
8
+
6
9
  # C extensions
7
10
  *.so
8
11
 
@@ -8,6 +8,21 @@ inserted below the marker.
8
8
 
9
9
  <!-- psr: next release -->
10
10
 
11
+ ## v0.2.3 (2026-09-04)
12
+
13
+ ### Bug Fixes
14
+
15
+ - **fastapi**: Don't reflect provider/library error internals from the RP callback
16
+ ([#622](https://github.com/jamescrowley321/identity-model/pull/622),
17
+ [`2480587`](https://github.com/jamescrowley321/identity-model/commit/2480587a1ef28837ce9b36f928dcd8cef26e07cd))
18
+
19
+ ### Testing
20
+
21
+ - **fastapi**: Cover the two genericized error branches
22
+ ([#622](https://github.com/jamescrowley321/identity-model/pull/622),
23
+ [`2480587`](https://github.com/jamescrowley321/identity-model/commit/2480587a1ef28837ce9b36f928dcd8cef26e07cd))
24
+
25
+
11
26
  ## v0.2.2 (2026-08-22)
12
27
 
13
28
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: fastapi-identity-model
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: FastAPI OIDC/OAuth2 middleware and relying-party router, built on py-identity-model
5
5
  Project-URL: Homepage, https://github.com/jamescrowley321/identity-model
6
6
  Project-URL: Repository, https://github.com/jamescrowley321/identity-model
@@ -103,9 +103,13 @@ async def _discover(settings: OIDCSettings) -> DiscoveryDocumentResponse:
103
103
  DiscoveryDocumentRequest(address=settings.discovery_url),
104
104
  )
105
105
  if not disco.is_successful:
106
+ # Log the provider's specific error for operators; return a generic
107
+ # detail so the callback endpoint cannot be probed for upstream
108
+ # internals (mirrors the resource-server middleware's F-18 posture).
109
+ logger.warning("OIDC discovery failed: %s", disco.error)
106
110
  raise HTTPException(
107
111
  status_code=status.HTTP_502_BAD_GATEWAY,
108
- detail=f"OIDC discovery failed: {disco.error}",
112
+ detail="Identity provider discovery failed",
109
113
  )
110
114
  # OIDC Discovery 1.0 §4.3: the document's issuer MUST match the URL the
111
115
  # document was retrieved from (issuer mix-up defense). The library only
@@ -114,12 +118,14 @@ async def _discover(settings: OIDCSettings) -> DiscoveryDocumentResponse:
114
118
  # document at "https://op/.well-known/openid-configuration".
115
119
  expected = _expected_issuer(settings.discovery_url)
116
120
  if (disco.issuer or "").rstrip("/") != expected:
121
+ logger.warning(
122
+ "Discovery document issuer mismatch: expected %r, got %r",
123
+ expected,
124
+ disco.issuer,
125
+ )
117
126
  raise HTTPException(
118
127
  status_code=status.HTTP_502_BAD_GATEWAY,
119
- detail=(
120
- f"Discovery document issuer mismatch: expected '{expected}', "
121
- f"got '{disco.issuer}'"
122
- ),
128
+ detail="Identity provider configuration error",
123
129
  )
124
130
  return disco
125
131
 
@@ -181,9 +187,10 @@ async def _exchange_code(
181
187
  ),
182
188
  )
183
189
  if not tok.is_successful or not tok.token:
190
+ logger.warning("Token exchange failed: %s", tok.error)
184
191
  raise HTTPException(
185
192
  status_code=status.HTTP_400_BAD_REQUEST,
186
- detail=f"Token exchange failed: {tok.error}",
193
+ detail="Authorization code exchange failed",
187
194
  )
188
195
  return tok.token
189
196
 
@@ -210,9 +217,10 @@ async def _validate_id_token(
210
217
  disco_doc_address=settings.discovery_url,
211
218
  )
212
219
  except PyIdentityModelException as exc:
220
+ logger.info("ID token validation failed: %s", exc)
213
221
  raise HTTPException(
214
222
  status_code=status.HTTP_401_UNAUTHORIZED,
215
- detail=f"ID token validation failed: {exc}",
223
+ detail="ID token validation failed",
216
224
  ) from exc
217
225
 
218
226
  # Nonce binding is the RP's responsibility — the library does not check it.
@@ -278,14 +286,19 @@ async def _callback( # noqa: PLR0913 # request-scoped args + the router's buil
278
286
  try:
279
287
  cb = parse_authorize_callback_response(callback_url)
280
288
  except PyIdentityModelException as exc:
289
+ logger.info("Malformed authorization callback: %s", exc)
281
290
  raise HTTPException(
282
291
  status_code=status.HTTP_400_BAD_REQUEST,
283
- detail=f"Malformed authorization callback: {exc}",
292
+ detail="Malformed authorization response",
284
293
  ) from exc
285
294
  if not cb.is_successful:
295
+ # ``cb.error`` is the provider's OAuth 2.0 ``error`` param, but it can
296
+ # carry an attacker-influenced ``error_description``; log it, return a
297
+ # generic detail rather than reflecting it to the browser.
298
+ logger.info("Authorization error from provider: %s", cb.error)
286
299
  raise HTTPException(
287
300
  status_code=status.HTTP_400_BAD_REQUEST,
288
- detail=f"Authorization error: {cb.error}",
301
+ detail="Authorization request failed",
289
302
  )
290
303
  if not validate_authorize_callback_state(cb, flow["state"]).is_valid:
291
304
  raise HTTPException(
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi-identity-model"
3
- version = "0.2.2"
3
+ version = "0.2.3"
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"
@@ -1,3 +1,4 @@
1
+ import logging
1
2
  from types import SimpleNamespace
2
3
  from unittest.mock import AsyncMock
3
4
  from urllib.parse import parse_qs, urlparse
@@ -8,7 +9,7 @@ import pytest
8
9
  from starlette.middleware.sessions import SessionMiddleware
9
10
 
10
11
  from fastapi_identity_model import OIDCSettings, build_oidc_router, rp
11
- from py_identity_model import TokenValidationException
12
+ from py_identity_model import AuthorizeCallbackException, TokenValidationException
12
13
 
13
14
 
14
15
  pytestmark = pytest.mark.unit
@@ -183,15 +184,20 @@ async def test_callback_missing_code(monkeypatch):
183
184
  assert "missing code" in resp.json()["detail"]
184
185
 
185
186
 
186
- async def test_login_discovery_failure(monkeypatch):
187
+ async def test_login_discovery_failure(monkeypatch, caplog):
187
188
  _patch(monkeypatch, disco=SimpleNamespace(is_successful=False, error="down"))
188
- async with _client(_app()) as client:
189
- resp = await client.get("/auth/login")
189
+ with caplog.at_level(logging.WARNING, logger="fastapi_identity_model"):
190
+ async with _client(_app()) as client:
191
+ resp = await client.get("/auth/login")
190
192
  assert resp.status_code == 502
191
- assert "discovery failed" in resp.json()["detail"].lower()
193
+ # Generic client detail — the provider's specific error must not be echoed
194
+ # to the browser (#601), but IS logged server-side for operators.
195
+ assert resp.json()["detail"] == "Identity provider discovery failed"
196
+ assert "down" not in resp.json()["detail"]
197
+ assert "down" in caplog.text
192
198
 
193
199
 
194
- async def test_login_rejects_discovery_issuer_mismatch(monkeypatch):
200
+ async def test_login_rejects_discovery_issuer_mismatch(monkeypatch, caplog):
195
201
  # OIDC Discovery 1.0 §4.3: a document whose issuer does not match the
196
202
  # URL it was retrieved from must be rejected (issuer mix-up defense).
197
203
  mismatched = SimpleNamespace(
@@ -203,10 +209,15 @@ async def test_login_rejects_discovery_issuer_mismatch(monkeypatch):
203
209
  issuer="https://op/INVALID",
204
210
  )
205
211
  _patch(monkeypatch, disco=mismatched)
206
- async with _client(_app()) as client:
207
- resp = await client.get("/auth/login")
212
+ with caplog.at_level(logging.WARNING, logger="fastapi_identity_model"):
213
+ async with _client(_app()) as client:
214
+ resp = await client.get("/auth/login")
208
215
  assert resp.status_code == 502
209
- assert "issuer mismatch" in resp.json()["detail"].lower()
216
+ # The mismatched issuer value is not reflected to the client (#601)...
217
+ assert resp.json()["detail"] == "Identity provider configuration error"
218
+ assert "INVALID" not in resp.json()["detail"]
219
+ # ...but the expected/got pair is logged for operators.
220
+ assert "INVALID" in caplog.text
210
221
 
211
222
 
212
223
  async def test_fetch_userinfo_disabled_skips_userinfo(monkeypatch):
@@ -224,26 +235,84 @@ async def test_fetch_userinfo_disabled_skips_userinfo(monkeypatch):
224
235
  rp.get_userinfo.assert_not_called()
225
236
 
226
237
 
227
- async def test_callback_token_exchange_failure(monkeypatch):
238
+ async def test_callback_token_exchange_failure(monkeypatch, caplog):
228
239
  _patch(monkeypatch)
229
240
  async with _client(_app()) as client:
230
241
  state, _ = await _login(client)
231
242
  rp.request_authorization_code_token.return_value = SimpleNamespace(
232
243
  is_successful=False, error="invalid_grant", token=None
233
244
  )
234
- resp = await client.get(f"/auth/callback?code=abc&state={state}")
245
+ with caplog.at_level(logging.WARNING, logger="fastapi_identity_model"):
246
+ resp = await client.get(f"/auth/callback?code=abc&state={state}")
235
247
  assert resp.status_code == 400
236
- assert "Token exchange failed" in resp.json()["detail"]
248
+ # The provider's grant error is logged, not reflected to the client (#601).
249
+ assert resp.json()["detail"] == "Authorization code exchange failed"
250
+ assert "invalid_grant" not in resp.json()["detail"]
251
+ assert "invalid_grant" in caplog.text
237
252
 
238
253
 
239
- async def test_callback_invalid_id_token(monkeypatch):
254
+ async def test_callback_invalid_id_token(monkeypatch, caplog):
240
255
  _patch(monkeypatch)
241
256
  async with _client(_app()) as client:
242
257
  state, _ = await _login(client)
243
258
  rp.validate_token.side_effect = TokenValidationException("bad iss")
244
- resp = await client.get(f"/auth/callback?code=abc&state={state}")
259
+ with caplog.at_level(logging.INFO, logger="fastapi_identity_model"):
260
+ resp = await client.get(f"/auth/callback?code=abc&state={state}")
245
261
  assert resp.status_code == 401
246
- assert "ID token validation failed" in resp.json()["detail"]
262
+ # Generic detail — the specific validation cause ("bad iss") is logged, not
263
+ # returned, so the callback can't be used as a validation-stage oracle (#601).
264
+ assert resp.json()["detail"] == "ID token validation failed"
265
+ assert "bad iss" not in resp.json()["detail"]
266
+ assert "bad iss" in caplog.text
267
+
268
+
269
+ async def test_callback_malformed_response_detail_is_generic(monkeypatch, caplog):
270
+ # A parse failure must return a generic detail: the library's exception
271
+ # text (which can carry attacker-influenced callback contents) is logged
272
+ # server-side, never reflected to the browser (#601).
273
+ secret = "parse-cause-4c2e9a-do-not-leak"
274
+
275
+ def _raise(_url):
276
+ raise AuthorizeCallbackException(secret)
277
+
278
+ _patch(monkeypatch)
279
+ monkeypatch.setattr(rp, "parse_authorize_callback_response", _raise)
280
+ async with _client(_app()) as client:
281
+ state, _ = await _login(client)
282
+ with caplog.at_level(logging.INFO, logger="fastapi_identity_model"):
283
+ resp = await client.get(f"/auth/callback?code=abc&state={state}")
284
+ assert resp.status_code == 400
285
+ # Generic detail — pre-#622 this reflected f"...: {exc}"; the parser's
286
+ # exception text must not appear anywhere in the response body.
287
+ assert resp.json()["detail"] == "Malformed authorization response"
288
+ assert secret not in resp.text
289
+ # ...but the real cause IS logged for operators.
290
+ assert secret in caplog.text
291
+
292
+
293
+ async def test_callback_provider_error_detail_is_generic(monkeypatch, caplog):
294
+ # A provider error response (?error=...) must return a generic detail:
295
+ # neither the provider's error code nor the attacker-controllable
296
+ # error_description is reflected to the browser (#601).
297
+ secret = "err-desc-secret-8b7c1d-do-not-leak"
298
+ _patch(monkeypatch)
299
+ async with _client(_app()) as client:
300
+ state, _ = await _login(client)
301
+ with caplog.at_level(logging.INFO, logger="fastapi_identity_model"):
302
+ resp = await client.get(
303
+ f"/auth/callback?state={state}"
304
+ f"&error=access_denied&error_description={secret}"
305
+ )
306
+ assert resp.status_code == 400
307
+ # Generic detail — pre-#622 this reflected f"Authorization error: {cb.error}".
308
+ assert resp.json()["detail"] == "Authorization request failed"
309
+ # Neither the error code nor the attacker-influenced description leaks to
310
+ # the client...
311
+ assert "access_denied" not in resp.text
312
+ assert secret not in resp.text
313
+ # ...but the error code IS logged for operators (the description is not).
314
+ assert "access_denied" in caplog.text
315
+ assert secret not in caplog.text
247
316
 
248
317
 
249
318
  async def test_callback_nonce_mismatch(monkeypatch):