fastapi-identity-model 0.2.2__tar.gz → 0.3.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.
Files changed (22) hide show
  1. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/.gitignore +3 -0
  2. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/CHANGELOG.md +38 -0
  3. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/PKG-INFO +1 -1
  4. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/fastapi_identity_model/__init__.py +4 -1
  5. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/fastapi_identity_model/config.py +4 -1
  6. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/fastapi_identity_model/middleware.py +69 -22
  7. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/fastapi_identity_model/rp.py +67 -9
  8. fastapi_identity_model-0.3.0/fastapi_identity_model/ws.py +236 -0
  9. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/pyproject.toml +1 -1
  10. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/tests/test_config.py +12 -2
  11. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/tests/test_middleware.py +60 -4
  12. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/tests/test_rp.py +162 -16
  13. fastapi_identity_model-0.3.0/tests/test_ws.py +239 -0
  14. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/README.md +0 -0
  15. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/fastapi_identity_model/dependencies.py +0 -0
  16. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/fastapi_identity_model/py.typed +0 -0
  17. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/fastapi_identity_model/token_manager.py +0 -0
  18. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/tests/test_dependencies.py +0 -0
  19. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/tests/test_security_cnf_bound_as_bearer.py +0 -0
  20. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/tests/test_security_error_oracle.py +0 -0
  21. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/tests/test_security_id_token_substitution.py +0 -0
  22. {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/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,44 @@ inserted below the marker.
8
8
 
9
9
  <!-- psr: next release -->
10
10
 
11
+ ## v0.3.0 (2026-09-05)
12
+
13
+ ### Features
14
+
15
+ - **fastapi**: Authenticate WebSockets, fix exclusion foot-guns and login self-block
16
+ ([#604](https://github.com/jamescrowley321/identity-model/pull/604),
17
+ [`6a88c56`](https://github.com/jamescrowley321/identity-model/commit/6a88c56f5798f2dbfda6a7c2c5f19535eea817ab))
18
+
19
+ ### Testing
20
+
21
+ - **fastapi**: Address fresh-context adversarial review findings
22
+ ([#604](https://github.com/jamescrowley321/identity-model/pull/604),
23
+ [`6a88c56`](https://github.com/jamescrowley321/identity-model/commit/6a88c56f5798f2dbfda6a7c2c5f19535eea817ab))
24
+
25
+ - **fastapi**: Integration + red-team coverage for WS auth, exclusion, RP composition
26
+ ([#604](https://github.com/jamescrowley321/identity-model/pull/604),
27
+ [`6a88c56`](https://github.com/jamescrowley321/identity-model/commit/6a88c56f5798f2dbfda6a7c2c5f19535eea817ab))
28
+
29
+ - **fastapi**: Wire test_ws_correctness into a gated target
30
+ ([#604](https://github.com/jamescrowley321/identity-model/pull/604),
31
+ [`6a88c56`](https://github.com/jamescrowley321/identity-model/commit/6a88c56f5798f2dbfda6a7c2c5f19535eea817ab))
32
+
33
+
34
+ ## v0.2.3 (2026-09-04)
35
+
36
+ ### Bug Fixes
37
+
38
+ - **fastapi**: Don't reflect provider/library error internals from the RP callback
39
+ ([#622](https://github.com/jamescrowley321/identity-model/pull/622),
40
+ [`2480587`](https://github.com/jamescrowley321/identity-model/commit/2480587a1ef28837ce9b36f928dcd8cef26e07cd))
41
+
42
+ ### Testing
43
+
44
+ - **fastapi**: Cover the two genericized error branches
45
+ ([#622](https://github.com/jamescrowley321/identity-model/pull/622),
46
+ [`2480587`](https://github.com/jamescrowley321/identity-model/commit/2480587a1ef28837ce9b36f928dcd8cef26e07cd))
47
+
48
+
11
49
  ## v0.2.2 (2026-08-22)
12
50
 
13
51
  ### 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.3.0
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
@@ -23,8 +23,9 @@ from .dependencies import (
23
23
  require_scope,
24
24
  )
25
25
  from .middleware import TokenValidationMiddleware
26
- from .rp import build_oidc_router
26
+ from .rp import build_oidc_router, oidc_public_paths
27
27
  from .token_manager import TokenManager
28
+ from .ws import build_ws_authenticator
28
29
 
29
30
 
30
31
  try:
@@ -39,11 +40,13 @@ __all__ = [
39
40
  "TokenManager",
40
41
  "TokenValidationMiddleware",
41
42
  "build_oidc_router",
43
+ "build_ws_authenticator",
42
44
  "get_claim_value",
43
45
  "get_claim_values",
44
46
  "get_claims",
45
47
  "get_current_user",
46
48
  "get_token",
49
+ "oidc_public_paths",
47
50
  "require_claim",
48
51
  "require_scope",
49
52
  ]
@@ -7,7 +7,10 @@ import os
7
7
 
8
8
 
9
9
  def _default_excluded_paths() -> list[str]:
10
- return ["/docs", "/openapi.json", "/health"]
10
+ # Exact paths (the middleware matches excluded_paths exactly by default;
11
+ # append ``/*`` for a subtree). ``/docs/oauth2-redirect`` is listed
12
+ # explicitly because exact matching no longer implies the ``/docs`` subtree.
13
+ return ["/docs", "/docs/oauth2-redirect", "/openapi.json", "/health"]
11
14
 
12
15
 
13
16
  @dataclass
@@ -3,6 +3,12 @@ FastAPI middleware for OAuth2/OIDC token validation.
3
3
 
4
4
  This module provides middleware components for validating Bearer tokens
5
5
  in FastAPI applications using py-identity-model.
6
+
7
+ Scope: this middleware validates **HTTP** requests only. It extends Starlette's
8
+ ``BaseHTTPMiddleware``, which forwards every non-HTTP ASGI scope (WebSocket,
9
+ lifespan) straight to the app without calling ``dispatch`` — so WebSocket
10
+ handshakes are **not** authenticated here (issue #598). Guard WebSocket routes
11
+ with :func:`fastapi_identity_model.build_ws_authenticator` instead.
6
12
  """
7
13
 
8
14
  from collections.abc import Callable
@@ -78,6 +84,32 @@ def _is_upstream_fetch_failure(exc: Exception) -> bool:
78
84
  return any(message.startswith(p) for p in _UPSTREAM_FETCH_FAILURE_PREFIXES)
79
85
 
80
86
 
87
+ def evaluate_token_type(
88
+ claims: dict,
89
+ *,
90
+ require_access_token_marker: bool,
91
+ access_token_marker_claims: tuple[str, ...],
92
+ ) -> str | None:
93
+ """Reason string if *claims* are the wrong token type for a resource server
94
+ (an ID token presented as an access token), else ``None``.
95
+
96
+ Pure and transport-agnostic so the HTTP middleware and the WebSocket
97
+ authenticator (:func:`fastapi_identity_model.build_ws_authenticator`) apply
98
+ a byte-for-byte identical check — the F-07 defence must not diverge between
99
+ the two entry points. See :meth:`TokenValidationMiddleware._wrong_token_type_error`
100
+ for the semantics of the two complementary checks.
101
+ """
102
+ if any(c in claims for c in _ID_TOKEN_ONLY_CLAIMS):
103
+ return "ID token cannot be used as an access token"
104
+ if require_access_token_marker and not any(
105
+ c in claims for c in access_token_marker_claims
106
+ ):
107
+ return (
108
+ "Access token required; presented token lacks an access-token marker claim"
109
+ )
110
+ return None
111
+
112
+
81
113
  class TokenValidationMiddleware(BaseHTTPMiddleware):
82
114
  """
83
115
  Middleware for validating Bearer tokens on incoming requests.
@@ -92,10 +124,15 @@ class TokenValidationMiddleware(BaseHTTPMiddleware):
92
124
  audience does not enforce ``aud`` for tokens that omit the claim,
93
125
  which on a shared multi-tenant issuer accepts tokens minted for
94
126
  other clients.
95
- excluded_paths: Paths that skip token validation. A path matches if it
96
- equals an entry or is a subpath of one (``/docs`` also covers
97
- ``/docs/oauth2-redirect``). Pass ``[]`` to exclude nothing. When
98
- omitted, defaults to ``/docs``, ``/openapi.json``, ``/health``.
127
+ excluded_paths: Paths that skip token validation. Matching is **exact**
128
+ by default: ``/health`` excludes only ``/health``, never
129
+ ``/health/db-dump`` (issue #600 — exact-match fails closed so a
130
+ nested route under an excluded prefix is not silently exposed). To
131
+ exclude a whole subtree, append ``/*`` to the entry: ``/docs/*``
132
+ excludes ``/docs`` and every path beneath it. Pass ``[]`` to
133
+ exclude nothing. When omitted, defaults to ``/docs``,
134
+ ``/docs/oauth2-redirect`` (the Swagger OAuth2 redirect),
135
+ ``/openapi.json``, ``/health``.
99
136
  custom_claims_validator: Optional custom function to validate additional claims
100
137
  require_access_token_marker: Opt-in ID-token-substitution defence
101
138
  (F-07), default ``False`` (behaviour unchanged). When ``True``, a
@@ -149,21 +186,36 @@ class TokenValidationMiddleware(BaseHTTPMiddleware):
149
186
  self.excluded_paths = (
150
187
  excluded_paths
151
188
  if excluded_paths is not None
152
- else ["/docs", "/openapi.json", "/health"]
189
+ else ["/docs", "/docs/oauth2-redirect", "/openapi.json", "/health"]
153
190
  )
154
191
  self.custom_claims_validator = custom_claims_validator
155
192
 
156
193
  def _is_excluded(self, path: str) -> bool:
157
- """Whether *path* equals or is a subpath of an excluded entry.
194
+ """Whether *path* is excluded from token validation.
195
+
196
+ Matching is **exact** by default: an entry ``/health`` excludes only
197
+ ``/health``, never ``/health/db-dump``. To exclude a whole subtree,
198
+ append ``/*`` to the entry — ``/docs/*`` excludes ``/docs`` and every
199
+ path beneath it. Exact-by-default fails closed: adding a nested route
200
+ under an excluded prefix does not silently expose it (issue #600).
201
+
202
+ A bare ``/*`` (root subtree) is ignored rather than excluding every
203
+ path — disabling auth wholesale must be an explicit choice (don't add
204
+ the middleware), never a one-character typo.
158
205
 
159
- A bare ``/`` entry matches only the root, never as a subpath prefix
160
- (otherwise it would exclude every path).
206
+ Caution: subtree (``/*``) matching runs on the raw ASGI request path,
207
+ not a normalized one. If you place a ``..``-resolving handler (e.g. a
208
+ ``StaticFiles`` mount) under an excluded ``/*`` prefix, a request whose
209
+ path *string* starts with that prefix but resolves elsewhere is treated
210
+ as excluded. Keep ``/*`` exclusions off subtrees that normalize paths;
211
+ the default (exact match) is immune.
161
212
  """
162
213
  for entry in self.excluded_paths:
163
- if path == entry:
164
- return True
165
- prefix = entry.rstrip("/")
166
- if prefix and path.startswith(prefix + "/"):
214
+ if entry.endswith("/*"):
215
+ prefix = entry[:-2].rstrip("/")
216
+ if prefix and (path == prefix or path.startswith(prefix + "/")):
217
+ return True
218
+ elif path == entry:
167
219
  return True
168
220
  return False
169
221
 
@@ -202,16 +254,11 @@ class TokenValidationMiddleware(BaseHTTPMiddleware):
202
254
  access-token marker (``scope``/``scp`` by default). Off by default —
203
255
  behaviour unchanged unless explicitly enabled.
204
256
  """
205
- if any(c in claims for c in _ID_TOKEN_ONLY_CLAIMS):
206
- return "ID token cannot be used as an access token"
207
- if self.require_access_token_marker and not any(
208
- c in claims for c in self.access_token_marker_claims
209
- ):
210
- return (
211
- "Access token required; presented token lacks an "
212
- "access-token marker claim"
213
- )
214
- return None
257
+ return evaluate_token_type(
258
+ claims,
259
+ require_access_token_marker=self.require_access_token_marker,
260
+ access_token_marker_claims=self.access_token_marker_claims,
261
+ )
215
262
 
216
263
  async def _authenticate(self, request: Request, token: str) -> JSONResponse | None:
217
264
  """Validate *token* and attach claims; return an error response or None."""
@@ -67,6 +67,51 @@ logger = logging.getLogger("fastapi_identity_model")
67
67
 
68
68
  _STATE_TOKEN_BYTES = 32
69
69
 
70
+ # Paths registered by build_oidc_router, relative to the router's mount prefix.
71
+ # /callback is shared by the GET and POST (form_post) handlers.
72
+ _OIDC_ROUTER_PATHS = ("/login", "/callback", "/logout")
73
+
74
+
75
+ def oidc_public_paths(prefix: str = "") -> list[str]:
76
+ """Return the paths ``build_oidc_router`` registers, for the RS middleware.
77
+
78
+ A globally-installed :class:`TokenValidationMiddleware` would otherwise
79
+ demand a Bearer token on ``/login`` and ``/callback`` — routes that by
80
+ definition have no token yet — making login impossible (issue #599). Feed
81
+ these into the middleware's ``excluded_paths`` so the login flow is
82
+ reachable::
83
+
84
+ router_prefix = "/auth"
85
+ app.include_router(build_oidc_router(settings), prefix=router_prefix)
86
+ app.add_middleware(
87
+ TokenValidationMiddleware,
88
+ discovery_url=settings.discovery_url,
89
+ audience=settings.audience,
90
+ excluded_paths=[
91
+ *settings.excluded_paths,
92
+ *oidc_public_paths(router_prefix),
93
+ ],
94
+ )
95
+
96
+ Args:
97
+ prefix: The same ``prefix`` passed to ``include_router`` (``""`` when
98
+ the router is mounted at the app root). A trailing slash is
99
+ normalized away.
100
+
101
+ Returns:
102
+ Absolute, exact paths (no ``/*`` subtree wildcard) — the login,
103
+ callback, and logout endpoints.
104
+ """
105
+ base = prefix.rstrip("/")
106
+ # Normalize a missing leading slash: request paths are always absolute, so a
107
+ # relative ``base`` ("auth" instead of "/auth") would produce entries that
108
+ # can never match and silently fail to exclude the login routes. FastAPI's
109
+ # include_router already requires a leading-slash prefix; this just makes the
110
+ # helper robust if called directly.
111
+ if base and not base.startswith("/"):
112
+ base = f"/{base}"
113
+ return [f"{base}{route}" for route in _OIDC_ROUTER_PATHS]
114
+
70
115
 
71
116
  def _flow_key(session_key: str) -> str:
72
117
  """Session key holding transient login-flow state (state/nonce/verifier).
@@ -103,9 +148,13 @@ async def _discover(settings: OIDCSettings) -> DiscoveryDocumentResponse:
103
148
  DiscoveryDocumentRequest(address=settings.discovery_url),
104
149
  )
105
150
  if not disco.is_successful:
151
+ # Log the provider's specific error for operators; return a generic
152
+ # detail so the callback endpoint cannot be probed for upstream
153
+ # internals (mirrors the resource-server middleware's F-18 posture).
154
+ logger.warning("OIDC discovery failed: %s", disco.error)
106
155
  raise HTTPException(
107
156
  status_code=status.HTTP_502_BAD_GATEWAY,
108
- detail=f"OIDC discovery failed: {disco.error}",
157
+ detail="Identity provider discovery failed",
109
158
  )
110
159
  # OIDC Discovery 1.0 §4.3: the document's issuer MUST match the URL the
111
160
  # document was retrieved from (issuer mix-up defense). The library only
@@ -114,12 +163,14 @@ async def _discover(settings: OIDCSettings) -> DiscoveryDocumentResponse:
114
163
  # document at "https://op/.well-known/openid-configuration".
115
164
  expected = _expected_issuer(settings.discovery_url)
116
165
  if (disco.issuer or "").rstrip("/") != expected:
166
+ logger.warning(
167
+ "Discovery document issuer mismatch: expected %r, got %r",
168
+ expected,
169
+ disco.issuer,
170
+ )
117
171
  raise HTTPException(
118
172
  status_code=status.HTTP_502_BAD_GATEWAY,
119
- detail=(
120
- f"Discovery document issuer mismatch: expected '{expected}', "
121
- f"got '{disco.issuer}'"
122
- ),
173
+ detail="Identity provider configuration error",
123
174
  )
124
175
  return disco
125
176
 
@@ -181,9 +232,10 @@ async def _exchange_code(
181
232
  ),
182
233
  )
183
234
  if not tok.is_successful or not tok.token:
235
+ logger.warning("Token exchange failed: %s", tok.error)
184
236
  raise HTTPException(
185
237
  status_code=status.HTTP_400_BAD_REQUEST,
186
- detail=f"Token exchange failed: {tok.error}",
238
+ detail="Authorization code exchange failed",
187
239
  )
188
240
  return tok.token
189
241
 
@@ -210,9 +262,10 @@ async def _validate_id_token(
210
262
  disco_doc_address=settings.discovery_url,
211
263
  )
212
264
  except PyIdentityModelException as exc:
265
+ logger.info("ID token validation failed: %s", exc)
213
266
  raise HTTPException(
214
267
  status_code=status.HTTP_401_UNAUTHORIZED,
215
- detail=f"ID token validation failed: {exc}",
268
+ detail="ID token validation failed",
216
269
  ) from exc
217
270
 
218
271
  # Nonce binding is the RP's responsibility — the library does not check it.
@@ -278,14 +331,19 @@ async def _callback( # noqa: PLR0913 # request-scoped args + the router's buil
278
331
  try:
279
332
  cb = parse_authorize_callback_response(callback_url)
280
333
  except PyIdentityModelException as exc:
334
+ logger.info("Malformed authorization callback: %s", exc)
281
335
  raise HTTPException(
282
336
  status_code=status.HTTP_400_BAD_REQUEST,
283
- detail=f"Malformed authorization callback: {exc}",
337
+ detail="Malformed authorization response",
284
338
  ) from exc
285
339
  if not cb.is_successful:
340
+ # ``cb.error`` is the provider's OAuth 2.0 ``error`` param, but it can
341
+ # carry an attacker-influenced ``error_description``; log it, return a
342
+ # generic detail rather than reflecting it to the browser.
343
+ logger.info("Authorization error from provider: %s", cb.error)
286
344
  raise HTTPException(
287
345
  status_code=status.HTTP_400_BAD_REQUEST,
288
- detail=f"Authorization error: {cb.error}",
346
+ detail="Authorization request failed",
289
347
  )
290
348
  if not validate_authorize_callback_state(cb, flow["state"]).is_valid:
291
349
  raise HTTPException(
@@ -0,0 +1,236 @@
1
+ """WebSocket authentication for FastAPI.
2
+
3
+ :class:`TokenValidationMiddleware` extends Starlette's ``BaseHTTPMiddleware``,
4
+ which forwards every non-HTTP ASGI scope to the app untouched — so WebSocket
5
+ handshakes bypass it entirely and a globally-installed middleware does **not**
6
+ authenticate WebSocket routes (issue #598). Browsers also cannot set an
7
+ ``Authorization`` header on the WebSocket handshake, so bearer-in-header auth is
8
+ not transparently available for WebSockets the way it is for HTTP.
9
+
10
+ :func:`build_ws_authenticator` returns a FastAPI dependency that authenticates a
11
+ WebSocket by validating a Bearer token taken from (in order) the
12
+ ``Authorization`` header (non-browser clients) or a query parameter (browsers).
13
+ It runs the **same** core ``validate_token`` path and the **same**
14
+ ID-token-substitution guard (:func:`fastapi_identity_model.middleware.evaluate_token_type`,
15
+ F-07) as the HTTP middleware, and closes the socket with a policy-violation code
16
+ on failure. The close *reason* is generic (mirroring the middleware's F-18
17
+ non-oracle 401); the specific cause is logged server-side.
18
+
19
+ from fastapi import Depends, FastAPI, WebSocket
20
+ from fastapi_identity_model import build_ws_authenticator
21
+
22
+ app = FastAPI()
23
+ ws_auth = build_ws_authenticator(
24
+ discovery_url="https://op/.well-known/openid-configuration",
25
+ audience="my-api",
26
+ )
27
+
28
+ @app.websocket("/ws")
29
+ async def ws(websocket: WebSocket, claims: dict = Depends(ws_auth)):
30
+ await websocket.accept()
31
+ await websocket.send_json({"sub": claims.get("sub")})
32
+
33
+ The browser opens it as ``new WebSocket("wss://host/ws?access_token=<token>")``.
34
+ Prefer a short-lived token in the query string; query strings can land in logs.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import logging
40
+ from typing import TYPE_CHECKING
41
+
42
+ from fastapi import WebSocket, WebSocketException, status # type: ignore[attr-defined]
43
+ from jwt import InvalidTokenError
44
+
45
+ from py_identity_model import (
46
+ NetworkException,
47
+ PyIdentityModelException,
48
+ TokenValidationConfig,
49
+ to_principal,
50
+ )
51
+ from py_identity_model.aio import validate_token
52
+
53
+ from .middleware import (
54
+ _DEFAULT_ACCESS_TOKEN_MARKER_CLAIMS,
55
+ _is_upstream_fetch_failure,
56
+ evaluate_token_type,
57
+ )
58
+
59
+
60
+ if TYPE_CHECKING:
61
+ from collections.abc import Callable
62
+
63
+
64
+ logger = logging.getLogger("fastapi_identity_model")
65
+
66
+ # Expected number of parts in "Bearer <token>".
67
+ _BEARER_HEADER_PART_COUNT = 2
68
+
69
+ # WebSocket close codes (RFC 6455 §7.4.1). Auth rejection → policy violation;
70
+ # transient upstream (discovery/JWKS fetch) failure → try again later so the
71
+ # client backs off rather than treating it as a bad token; an unexpected fault
72
+ # → internal error.
73
+ _WS_POLICY_VIOLATION = status.WS_1008_POLICY_VIOLATION
74
+ _WS_TRY_AGAIN_LATER = status.WS_1013_TRY_AGAIN_LATER
75
+ _WS_INTERNAL_ERROR = status.WS_1011_INTERNAL_ERROR
76
+
77
+ # Generic, non-oracle close reason for every auth rejection cause (signature /
78
+ # audience / expiry / wrong-type), mirroring the middleware's F-18 401 body so
79
+ # the close frame cannot distinguish rejection causes. WebSocket close reasons
80
+ # must be <= 123 UTF-8 bytes (RFC 6455 §5.5.1); this is well under.
81
+ _GENERIC_REJECT_REASON = "Invalid or unauthorized token"
82
+
83
+
84
+ def _extract_ws_token(websocket: WebSocket, query_param: str) -> str | None:
85
+ """Return the bearer token from the Authorization header or query param.
86
+
87
+ Header wins when present (non-browser clients). A present-but-malformed
88
+ Authorization header returns ``None`` rather than falling through to the
89
+ query param, so a client cannot smuggle a second token past a broken header.
90
+ """
91
+ header = websocket.headers.get("Authorization")
92
+ if header is not None:
93
+ parts = header.split()
94
+ if len(parts) == _BEARER_HEADER_PART_COUNT and parts[0].lower() == "bearer":
95
+ return parts[1]
96
+ return None
97
+ token = websocket.query_params.get(query_param)
98
+ return token or None
99
+
100
+
101
+ def build_ws_authenticator( # noqa: PLR0913 # mirrors the middleware's opt-in F-07 marker config + a WS token-source knob
102
+ discovery_url: str,
103
+ audience: str,
104
+ *,
105
+ query_param: str = "access_token",
106
+ custom_claims_validator: Callable | None = None,
107
+ require_access_token_marker: bool = False,
108
+ access_token_marker_claims: tuple[str, ...] = _DEFAULT_ACCESS_TOKEN_MARKER_CLAIMS,
109
+ ) -> Callable:
110
+ """Build a FastAPI WebSocket dependency that validates a Bearer token.
111
+
112
+ Mirrors :class:`TokenValidationMiddleware` for WebSocket routes, which the
113
+ HTTP middleware cannot reach (issue #598).
114
+
115
+ Args:
116
+ discovery_url: The OpenID Connect discovery document URL.
117
+ audience: Expected ``aud`` claim. Required — a ``None`` audience does
118
+ not enforce ``aud`` for tokens that omit it, which on a shared
119
+ multi-tenant issuer accepts tokens minted for other clients.
120
+ query_param: Query-string parameter carrying the token when no
121
+ ``Authorization`` header is present (browsers). Default
122
+ ``access_token``.
123
+ custom_claims_validator: Optional injectable claims validator, threaded
124
+ into ``TokenValidationConfig`` exactly as the HTTP middleware does.
125
+ require_access_token_marker: Opt-in ID-token-substitution defence (F-07),
126
+ default ``False``. See :class:`TokenValidationMiddleware`.
127
+ access_token_marker_claims: Positive access-token marker claims for
128
+ ``require_access_token_marker``. Defaults to ``("scope", "scp")``.
129
+
130
+ Returns:
131
+ An async dependency ``(websocket) -> dict`` returning the validated
132
+ claims. On failure it raises :class:`fastapi.WebSocketException`, which
133
+ FastAPI turns into a handshake close with the given code — the socket is
134
+ never accepted. The validated claims/principal/token are also attached
135
+ to ``websocket.state`` (``.claims`` / ``.user`` / ``.token``).
136
+
137
+ Note:
138
+ This performs **authentication only** — it validates the token and
139
+ rejects the wrong token type. It does **not** enforce scopes or any
140
+ authorization, and there is no WebSocket equivalent of
141
+ ``require_scope``. Check ``claims`` (or ``websocket.state.claims``) for
142
+ the scopes/roles a route requires yourself, mirroring what
143
+ ``require_scope`` does for HTTP routes.
144
+
145
+ The close *code* (1008 policy / 1013 try-again / 1011 internal) is the
146
+ WebSocket-native rejection signal — visible to programmatic WS clients
147
+ and in server logs. A **browser** sees a rejection as an HTTP 403
148
+ handshake denial (all pre-accept close codes surface the same way), so
149
+ do not rely on the code being distinguishable browser-side.
150
+ """
151
+ if not audience:
152
+ raise ValueError(
153
+ "build_ws_authenticator requires a non-empty 'audience'; a "
154
+ "None/empty audience skips aud enforcement for aud-less tokens."
155
+ )
156
+ if require_access_token_marker and not access_token_marker_claims:
157
+ raise ValueError(
158
+ "require_access_token_marker=True needs a non-empty "
159
+ "access_token_marker_claims; an empty set rejects every token."
160
+ )
161
+
162
+ async def authenticate_websocket(websocket: WebSocket) -> dict:
163
+ token = _extract_ws_token(websocket, query_param)
164
+ if not token:
165
+ # Structural failure (no/!bearer token) — distinct from a validation
166
+ # rejection, exactly as the HTTP middleware distinguishes "Missing
167
+ # Authorization header" from the generic 401.
168
+ raise WebSocketException(
169
+ code=_WS_POLICY_VIOLATION,
170
+ reason="Missing or malformed bearer token",
171
+ )
172
+ try:
173
+ claims = await validate_token(
174
+ jwt=token,
175
+ token_validation_config=TokenValidationConfig(
176
+ perform_disco=True,
177
+ audience=audience,
178
+ claims_validator=custom_claims_validator,
179
+ ),
180
+ disco_doc_address=discovery_url,
181
+ )
182
+ except PyIdentityModelException as e:
183
+ if isinstance(e, NetworkException) or _is_upstream_fetch_failure(e):
184
+ logger.exception("Upstream fetch failure during WS token validation")
185
+ raise WebSocketException(
186
+ code=_WS_TRY_AGAIN_LATER,
187
+ reason="Authentication temporarily unavailable",
188
+ ) from e
189
+ logger.info("WS token rejected during validation: %s", e)
190
+ raise WebSocketException(
191
+ code=_WS_POLICY_VIOLATION, reason=_GENERIC_REJECT_REASON
192
+ ) from e
193
+ except InvalidTokenError as e:
194
+ logger.info("Malformed WS token rejected: %s", e)
195
+ raise WebSocketException(
196
+ code=_WS_POLICY_VIOLATION, reason=_GENERIC_REJECT_REASON
197
+ ) from e
198
+ except WebSocketException:
199
+ raise
200
+ except Exception as e:
201
+ logger.exception("Unexpected error during WS token validation")
202
+ raise WebSocketException(
203
+ code=_WS_INTERNAL_ERROR,
204
+ reason="Internal error during authentication",
205
+ ) from e
206
+
207
+ wrong_type = evaluate_token_type(
208
+ claims,
209
+ require_access_token_marker=require_access_token_marker,
210
+ access_token_marker_claims=access_token_marker_claims,
211
+ )
212
+ if wrong_type is not None:
213
+ # Log the specific reason; return the generic one to the client.
214
+ logger.info("WS token rejected (wrong type): %s", wrong_type)
215
+ raise WebSocketException(
216
+ code=_WS_POLICY_VIOLATION, reason=_GENERIC_REJECT_REASON
217
+ )
218
+
219
+ # Build the principal under the same fail-closed guard as the HTTP
220
+ # middleware (which runs to_principal inside its try → 500): an
221
+ # unexpected failure here becomes a mapped 1011 close, never an
222
+ # unhandled exception that escapes after the token already validated.
223
+ try:
224
+ principal = to_principal(claims)
225
+ except Exception as e:
226
+ logger.exception("Unexpected error building principal during WS auth")
227
+ raise WebSocketException(
228
+ code=_WS_INTERNAL_ERROR,
229
+ reason="Internal error during authentication",
230
+ ) from e
231
+ websocket.state.user = principal
232
+ websocket.state.claims = claims
233
+ websocket.state.token = token
234
+ return claims
235
+
236
+ return authenticate_websocket
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "fastapi-identity-model"
3
- version = "0.2.2"
3
+ version = "0.3.0"
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"
@@ -14,7 +14,12 @@ def test_audience_defaults_to_client_id():
14
14
  )
15
15
  assert s.audience == "cid"
16
16
  assert s.scope == "openid profile email"
17
- assert s.excluded_paths == ["/docs", "/openapi.json", "/health"]
17
+ assert s.excluded_paths == [
18
+ "/docs",
19
+ "/docs/oauth2-redirect",
20
+ "/openapi.json",
21
+ "/health",
22
+ ]
18
23
 
19
24
 
20
25
  def test_explicit_audience_preserved():
@@ -57,7 +62,12 @@ def test_from_env_default_excluded_paths(monkeypatch):
57
62
  monkeypatch.setenv("OIDC_REDIRECT_URI", "r")
58
63
  monkeypatch.delenv("OIDC_EXCLUDED_PATHS", raising=False)
59
64
  s = OIDCSettings.from_env()
60
- assert s.excluded_paths == ["/docs", "/openapi.json", "/health"]
65
+ assert s.excluded_paths == [
66
+ "/docs",
67
+ "/docs/oauth2-redirect",
68
+ "/openapi.json",
69
+ "/health",
70
+ ]
61
71
 
62
72
 
63
73
  def test_from_env_redirect_uri_optional_for_resource_server(monkeypatch):
@@ -145,12 +145,13 @@ async def test_id_token_rejected_as_access_token(monkeypatch):
145
145
  assert "ID token" in resp.json()["detail"]
146
146
 
147
147
 
148
- async def test_excluded_subpath_skips_validation(monkeypatch):
149
- # A subpath of an excluded entry is also excluded: it reaches routing
150
- # (404, no such route) rather than being blocked with a 401.
148
+ async def test_excluded_subpath_is_validated_by_default(monkeypatch):
149
+ # Issue #600: matching is exact by default, so a subpath of an excluded
150
+ # entry ("/health") is NOT excluded — it is validated and, with no token,
151
+ # rejected 401 (fail closed) rather than silently reaching routing.
151
152
  async with _client(_app(monkeypatch, AsyncMock())) as client:
152
153
  resp = await client.get("/health/live")
153
- assert resp.status_code != 401
154
+ assert resp.status_code == 401
154
155
 
155
156
 
156
157
  async def test_options_preflight_passes_through(monkeypatch):
@@ -183,7 +184,62 @@ def test_root_excluded_path_is_not_a_catch_all():
183
184
  assert mw_obj._is_excluded("/") is True
184
185
  assert mw_obj._is_excluded("/api/me") is False
185
186
  assert mw_obj._is_excluded("/docs") is True
187
+
188
+
189
+ def test_exact_match_default_does_not_exclude_subpaths():
190
+ # Issue #600: matching is exact by default. A plain "/health" entry must NOT
191
+ # exclude a nested route — fail closed so a sensitive nested path under an
192
+ # excluded prefix is not silently exposed.
193
+ mw_obj = TokenValidationMiddleware(
194
+ FastAPI(),
195
+ discovery_url=DISCOVERY_URL,
196
+ audience="cid",
197
+ excluded_paths=["/health", "/docs"],
198
+ )
199
+ assert mw_obj._is_excluded("/health") is True
200
+ assert mw_obj._is_excluded("/health/db-dump") is False
201
+ assert mw_obj._is_excluded("/docs/oauth2-redirect") is False
202
+
203
+
204
+ def test_subtree_wildcard_opt_in_excludes_prefix_and_below():
205
+ # A trailing "/*" is the explicit opt-in for subtree exclusion.
206
+ mw_obj = TokenValidationMiddleware(
207
+ FastAPI(),
208
+ discovery_url=DISCOVERY_URL,
209
+ audience="cid",
210
+ excluded_paths=["/docs/*"],
211
+ )
212
+ assert mw_obj._is_excluded("/docs") is True
213
+ assert mw_obj._is_excluded("/docs/oauth2-redirect") is True
214
+ assert mw_obj._is_excluded("/docs/anything/deep") is True
215
+ # A sibling that merely shares the string prefix is not under the subtree.
216
+ assert mw_obj._is_excluded("/docsx") is False
217
+
218
+
219
+ def test_bare_star_is_not_a_catch_all():
220
+ # "/*" (root subtree) must not disable auth wholesale via a one-char typo.
221
+ mw_obj = TokenValidationMiddleware(
222
+ FastAPI(),
223
+ discovery_url=DISCOVERY_URL,
224
+ audience="cid",
225
+ excluded_paths=["/*"],
226
+ )
227
+ assert mw_obj._is_excluded("/anything") is False
228
+ assert mw_obj._is_excluded("/") is False
229
+
230
+
231
+ def test_default_excluded_paths_cover_swagger_oauth2_redirect():
232
+ # Exact matching no longer implies the /docs subtree, so the Swagger OAuth2
233
+ # redirect must be listed explicitly in the defaults.
234
+ mw_obj = TokenValidationMiddleware(
235
+ FastAPI(), discovery_url=DISCOVERY_URL, audience="cid"
236
+ )
237
+ assert mw_obj._is_excluded("/docs") is True
186
238
  assert mw_obj._is_excluded("/docs/oauth2-redirect") is True
239
+ assert mw_obj._is_excluded("/openapi.json") is True
240
+ assert mw_obj._is_excluded("/health") is True
241
+ # But not an arbitrary nested route under the excluded docs path.
242
+ assert mw_obj._is_excluded("/docs/secret") is False
187
243
 
188
244
 
189
245
  def test_access_token_marker_defaults_off():
@@ -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
@@ -7,8 +8,14 @@ import httpx
7
8
  import pytest
8
9
  from starlette.middleware.sessions import SessionMiddleware
9
10
 
10
- from fastapi_identity_model import OIDCSettings, build_oidc_router, rp
11
- from py_identity_model import TokenValidationException
11
+ from fastapi_identity_model import (
12
+ OIDCSettings,
13
+ TokenValidationMiddleware,
14
+ build_oidc_router,
15
+ oidc_public_paths,
16
+ rp,
17
+ )
18
+ from py_identity_model import AuthorizeCallbackException, TokenValidationException
12
19
 
13
20
 
14
21
  pytestmark = pytest.mark.unit
@@ -183,15 +190,20 @@ async def test_callback_missing_code(monkeypatch):
183
190
  assert "missing code" in resp.json()["detail"]
184
191
 
185
192
 
186
- async def test_login_discovery_failure(monkeypatch):
193
+ async def test_login_discovery_failure(monkeypatch, caplog):
187
194
  _patch(monkeypatch, disco=SimpleNamespace(is_successful=False, error="down"))
188
- async with _client(_app()) as client:
189
- resp = await client.get("/auth/login")
195
+ with caplog.at_level(logging.WARNING, logger="fastapi_identity_model"):
196
+ async with _client(_app()) as client:
197
+ resp = await client.get("/auth/login")
190
198
  assert resp.status_code == 502
191
- assert "discovery failed" in resp.json()["detail"].lower()
199
+ # Generic client detail — the provider's specific error must not be echoed
200
+ # to the browser (#601), but IS logged server-side for operators.
201
+ assert resp.json()["detail"] == "Identity provider discovery failed"
202
+ assert "down" not in resp.json()["detail"]
203
+ assert "down" in caplog.text
192
204
 
193
205
 
194
- async def test_login_rejects_discovery_issuer_mismatch(monkeypatch):
206
+ async def test_login_rejects_discovery_issuer_mismatch(monkeypatch, caplog):
195
207
  # OIDC Discovery 1.0 §4.3: a document whose issuer does not match the
196
208
  # URL it was retrieved from must be rejected (issuer mix-up defense).
197
209
  mismatched = SimpleNamespace(
@@ -203,10 +215,15 @@ async def test_login_rejects_discovery_issuer_mismatch(monkeypatch):
203
215
  issuer="https://op/INVALID",
204
216
  )
205
217
  _patch(monkeypatch, disco=mismatched)
206
- async with _client(_app()) as client:
207
- resp = await client.get("/auth/login")
218
+ with caplog.at_level(logging.WARNING, logger="fastapi_identity_model"):
219
+ async with _client(_app()) as client:
220
+ resp = await client.get("/auth/login")
208
221
  assert resp.status_code == 502
209
- assert "issuer mismatch" in resp.json()["detail"].lower()
222
+ # The mismatched issuer value is not reflected to the client (#601)...
223
+ assert resp.json()["detail"] == "Identity provider configuration error"
224
+ assert "INVALID" not in resp.json()["detail"]
225
+ # ...but the expected/got pair is logged for operators.
226
+ assert "INVALID" in caplog.text
210
227
 
211
228
 
212
229
  async def test_fetch_userinfo_disabled_skips_userinfo(monkeypatch):
@@ -224,26 +241,84 @@ async def test_fetch_userinfo_disabled_skips_userinfo(monkeypatch):
224
241
  rp.get_userinfo.assert_not_called()
225
242
 
226
243
 
227
- async def test_callback_token_exchange_failure(monkeypatch):
244
+ async def test_callback_token_exchange_failure(monkeypatch, caplog):
228
245
  _patch(monkeypatch)
229
246
  async with _client(_app()) as client:
230
247
  state, _ = await _login(client)
231
248
  rp.request_authorization_code_token.return_value = SimpleNamespace(
232
249
  is_successful=False, error="invalid_grant", token=None
233
250
  )
234
- resp = await client.get(f"/auth/callback?code=abc&state={state}")
251
+ with caplog.at_level(logging.WARNING, logger="fastapi_identity_model"):
252
+ resp = await client.get(f"/auth/callback?code=abc&state={state}")
235
253
  assert resp.status_code == 400
236
- assert "Token exchange failed" in resp.json()["detail"]
254
+ # The provider's grant error is logged, not reflected to the client (#601).
255
+ assert resp.json()["detail"] == "Authorization code exchange failed"
256
+ assert "invalid_grant" not in resp.json()["detail"]
257
+ assert "invalid_grant" in caplog.text
237
258
 
238
259
 
239
- async def test_callback_invalid_id_token(monkeypatch):
260
+ async def test_callback_invalid_id_token(monkeypatch, caplog):
240
261
  _patch(monkeypatch)
241
262
  async with _client(_app()) as client:
242
263
  state, _ = await _login(client)
243
264
  rp.validate_token.side_effect = TokenValidationException("bad iss")
244
- resp = await client.get(f"/auth/callback?code=abc&state={state}")
265
+ with caplog.at_level(logging.INFO, logger="fastapi_identity_model"):
266
+ resp = await client.get(f"/auth/callback?code=abc&state={state}")
245
267
  assert resp.status_code == 401
246
- assert "ID token validation failed" in resp.json()["detail"]
268
+ # Generic detail — the specific validation cause ("bad iss") is logged, not
269
+ # returned, so the callback can't be used as a validation-stage oracle (#601).
270
+ assert resp.json()["detail"] == "ID token validation failed"
271
+ assert "bad iss" not in resp.json()["detail"]
272
+ assert "bad iss" in caplog.text
273
+
274
+
275
+ async def test_callback_malformed_response_detail_is_generic(monkeypatch, caplog):
276
+ # A parse failure must return a generic detail: the library's exception
277
+ # text (which can carry attacker-influenced callback contents) is logged
278
+ # server-side, never reflected to the browser (#601).
279
+ secret = "parse-cause-4c2e9a-do-not-leak"
280
+
281
+ def _raise(_url):
282
+ raise AuthorizeCallbackException(secret)
283
+
284
+ _patch(monkeypatch)
285
+ monkeypatch.setattr(rp, "parse_authorize_callback_response", _raise)
286
+ async with _client(_app()) as client:
287
+ state, _ = await _login(client)
288
+ with caplog.at_level(logging.INFO, logger="fastapi_identity_model"):
289
+ resp = await client.get(f"/auth/callback?code=abc&state={state}")
290
+ assert resp.status_code == 400
291
+ # Generic detail — pre-#622 this reflected f"...: {exc}"; the parser's
292
+ # exception text must not appear anywhere in the response body.
293
+ assert resp.json()["detail"] == "Malformed authorization response"
294
+ assert secret not in resp.text
295
+ # ...but the real cause IS logged for operators.
296
+ assert secret in caplog.text
297
+
298
+
299
+ async def test_callback_provider_error_detail_is_generic(monkeypatch, caplog):
300
+ # A provider error response (?error=...) must return a generic detail:
301
+ # neither the provider's error code nor the attacker-controllable
302
+ # error_description is reflected to the browser (#601).
303
+ secret = "err-desc-secret-8b7c1d-do-not-leak"
304
+ _patch(monkeypatch)
305
+ async with _client(_app()) as client:
306
+ state, _ = await _login(client)
307
+ with caplog.at_level(logging.INFO, logger="fastapi_identity_model"):
308
+ resp = await client.get(
309
+ f"/auth/callback?state={state}"
310
+ f"&error=access_denied&error_description={secret}"
311
+ )
312
+ assert resp.status_code == 400
313
+ # Generic detail — pre-#622 this reflected f"Authorization error: {cb.error}".
314
+ assert resp.json()["detail"] == "Authorization request failed"
315
+ # Neither the error code nor the attacker-influenced description leaks to
316
+ # the client...
317
+ assert "access_denied" not in resp.text
318
+ assert secret not in resp.text
319
+ # ...but the error code IS logged for operators (the description is not).
320
+ assert "access_denied" in caplog.text
321
+ assert secret not in caplog.text
247
322
 
248
323
 
249
324
  async def test_callback_nonce_mismatch(monkeypatch):
@@ -327,3 +402,74 @@ async def test_logout_clears_session(monkeypatch):
327
402
  resp = await client.post("/auth/logout")
328
403
  assert resp.status_code == 303
329
404
  assert (await client.get("/me")).json() == {}
405
+
406
+
407
+ def _composed_app(excluded_paths) -> FastAPI:
408
+ """App with the login router AND the RS middleware installed together —
409
+ the combined RP+RS deployment from issue #599."""
410
+ app = FastAPI()
411
+ app.add_middleware(SessionMiddleware, secret_key="test-secret")
412
+ app.include_router(build_oidc_router(SETTINGS), prefix="/auth")
413
+ app.add_middleware(
414
+ TokenValidationMiddleware,
415
+ discovery_url=SETTINGS.discovery_url,
416
+ audience=SETTINGS.audience,
417
+ excluded_paths=excluded_paths,
418
+ )
419
+
420
+ @app.get("/protected")
421
+ async def protected():
422
+ return {"ok": True}
423
+
424
+ return app
425
+
426
+
427
+ async def test_middleware_composition_excludes_login_routes(monkeypatch):
428
+ # Issue #599: with oidc_public_paths wired into excluded_paths, the login
429
+ # route is reachable THROUGH a globally-installed middleware (302), while a
430
+ # non-excluded route still requires a token.
431
+ _patch(monkeypatch)
432
+ app = _composed_app(oidc_public_paths("/auth"))
433
+ async with _client(app) as client:
434
+ login = await client.get("/auth/login")
435
+ assert login.status_code == 302
436
+ assert login.headers["location"].startswith("https://op/authorize")
437
+ assert (await client.get("/protected")).status_code == 401
438
+
439
+
440
+ async def test_middleware_without_exclusion_blocks_login(monkeypatch):
441
+ # The bug #599 fixes: without the exclusion the middleware demands a bearer
442
+ # token on /auth/login itself, making login impossible.
443
+ _patch(monkeypatch)
444
+ app = _composed_app([])
445
+ async with _client(app) as client:
446
+ assert (await client.get("/auth/login")).status_code == 401
447
+
448
+
449
+ def test_oidc_public_paths_with_prefix():
450
+ # Issue #599: the RS middleware must be able to exclude the login routes.
451
+ assert oidc_public_paths("/auth") == [
452
+ "/auth/login",
453
+ "/auth/callback",
454
+ "/auth/logout",
455
+ ]
456
+
457
+
458
+ def test_oidc_public_paths_normalizes_missing_leading_slash():
459
+ # A relative prefix would yield paths that can never match request.url.path
460
+ # (always absolute), silently failing to exclude the login routes.
461
+ assert oidc_public_paths("auth") == [
462
+ "/auth/login",
463
+ "/auth/callback",
464
+ "/auth/logout",
465
+ ]
466
+
467
+
468
+ def test_oidc_public_paths_root_and_trailing_slash():
469
+ assert oidc_public_paths() == ["/login", "/callback", "/logout"]
470
+ # A trailing slash on the prefix is normalized away (no doubled slash).
471
+ assert oidc_public_paths("/auth/") == [
472
+ "/auth/login",
473
+ "/auth/callback",
474
+ "/auth/logout",
475
+ ]
@@ -0,0 +1,239 @@
1
+ """WebSocket authentication (issue #598).
2
+
3
+ WebSocket handshakes bypass ``BaseHTTPMiddleware`` entirely, so
4
+ ``TokenValidationMiddleware`` cannot guard them. ``build_ws_authenticator``
5
+ provides an equivalent FastAPI dependency that runs the same core
6
+ ``validate_token`` path and the same F-07 ID-token-substitution guard, closing
7
+ the socket on failure.
8
+
9
+ The dependency is exercised directly with a fake ``WebSocket`` (its only ASGI
10
+ surface here is ``.headers`` / ``.query_params`` / ``.state``), isolating the
11
+ authenticator's own logic — the same mock-``validate_token`` approach the HTTP
12
+ middleware tests use. Driving a real handshake via ``starlette.testclient`` is
13
+ avoided on purpose: it pulls in the deprecated httpx TestClient path.
14
+ """
15
+
16
+ from types import SimpleNamespace
17
+
18
+ from fastapi import WebSocketException
19
+ from jwt import DecodeError
20
+ import pytest
21
+
22
+ from fastapi_identity_model import build_ws_authenticator, ws
23
+ from py_identity_model import NetworkException, TokenValidationException
24
+
25
+
26
+ pytestmark = pytest.mark.unit
27
+
28
+ DISCOVERY_URL = "https://op/.well-known/openid-configuration"
29
+ _ACCESS_CLAIMS = {"sub": "u1", "scope": "read"}
30
+
31
+
32
+ class _FakeWebSocket:
33
+ """Minimal stand-in for starlette's WebSocket for the dependency under test."""
34
+
35
+ def __init__(self, *, headers=None, query_params=None):
36
+ self.headers = headers or {}
37
+ self.query_params = query_params or {}
38
+ self.state = SimpleNamespace()
39
+
40
+
41
+ def _authenticator(monkeypatch, *, returns=None, raises=None, **kwargs):
42
+ async def fake_validate_token(**_):
43
+ if raises is not None:
44
+ raise raises
45
+ return dict(returns)
46
+
47
+ monkeypatch.setattr(ws, "validate_token", fake_validate_token)
48
+ return build_ws_authenticator(discovery_url=DISCOVERY_URL, audience="cid", **kwargs)
49
+
50
+
51
+ async def test_valid_token_via_query_param(monkeypatch):
52
+ auth = _authenticator(monkeypatch, returns=_ACCESS_CLAIMS)
53
+ sock = _FakeWebSocket(query_params={"access_token": "good"})
54
+ claims = await auth(sock)
55
+ assert claims["sub"] == "u1"
56
+ # Validated identity is attached to websocket.state, mirroring request.state.
57
+ assert sock.state.claims["sub"] == "u1"
58
+ assert sock.state.token == "good"
59
+ assert sock.state.user is not None
60
+
61
+
62
+ async def test_valid_token_via_authorization_header(monkeypatch):
63
+ auth = _authenticator(monkeypatch, returns=_ACCESS_CLAIMS)
64
+ sock = _FakeWebSocket(headers={"Authorization": "Bearer good"})
65
+ claims = await auth(sock)
66
+ assert claims["sub"] == "u1"
67
+
68
+
69
+ async def test_missing_token_closes_policy_violation(monkeypatch):
70
+ auth = _authenticator(monkeypatch, returns=_ACCESS_CLAIMS)
71
+ with pytest.raises(WebSocketException) as exc:
72
+ await auth(_FakeWebSocket())
73
+ assert exc.value.code == 1008
74
+
75
+
76
+ async def test_malformed_authorization_header_does_not_fall_through(monkeypatch):
77
+ # A present-but-malformed header must not fall through to the query param;
78
+ # validate_token must never be reached.
79
+ called = {"n": 0}
80
+
81
+ async def fake_validate_token(**_):
82
+ called["n"] += 1
83
+ return dict(_ACCESS_CLAIMS)
84
+
85
+ monkeypatch.setattr(ws, "validate_token", fake_validate_token)
86
+ auth = build_ws_authenticator(discovery_url=DISCOVERY_URL, audience="cid")
87
+ sock = _FakeWebSocket(
88
+ headers={"Authorization": "Token abc"}, query_params={"access_token": "good"}
89
+ )
90
+ with pytest.raises(WebSocketException) as exc:
91
+ await auth(sock)
92
+ assert exc.value.code == 1008
93
+ assert called["n"] == 0
94
+
95
+
96
+ async def test_invalid_token_closes_generic_policy_violation(monkeypatch):
97
+ auth = _authenticator(monkeypatch, raises=TokenValidationException("bad signature"))
98
+ with pytest.raises(WebSocketException) as exc:
99
+ await auth(_FakeWebSocket(query_params={"access_token": "bad"}))
100
+ assert exc.value.code == 1008
101
+ # Generic reason — the close frame must not distinguish rejection causes.
102
+ assert exc.value.reason == "Invalid or unauthorized token"
103
+
104
+
105
+ async def test_malformed_jwt_closes_generic_policy_violation(monkeypatch):
106
+ # A raw pyjwt InvalidTokenError (e.g. DecodeError during header parsing) is a
107
+ # client error, closed 1008 with the same generic reason as any rejection.
108
+ auth = _authenticator(monkeypatch, raises=DecodeError("not a jwt"))
109
+ with pytest.raises(WebSocketException) as exc:
110
+ await auth(_FakeWebSocket(query_params={"access_token": "xxx"}))
111
+ assert exc.value.code == 1008
112
+ assert exc.value.reason == "Invalid or unauthorized token"
113
+
114
+
115
+ async def test_upstream_outage_closes_try_again_later(monkeypatch):
116
+ auth = _authenticator(monkeypatch, raises=NetworkException("provider down"))
117
+ with pytest.raises(WebSocketException) as exc:
118
+ await auth(_FakeWebSocket(query_params={"access_token": "good"}))
119
+ # 1013 Try Again Later, not a policy violation — a transient fault, not a
120
+ # bad token.
121
+ assert exc.value.code == 1013
122
+
123
+
124
+ async def test_upstream_fetch_failure_prefix_maps_to_try_again_later(monkeypatch):
125
+ # A discovery/JWKS fetch failure surfaces as a TokenValidationException whose
126
+ # message carries a known prefix; it must map to 1013, not a 1008 rejection.
127
+ auth = _authenticator(
128
+ monkeypatch,
129
+ raises=TokenValidationException("Network error during discovery: boom"),
130
+ )
131
+ with pytest.raises(WebSocketException) as exc:
132
+ await auth(_FakeWebSocket(query_params={"access_token": "good"}))
133
+ assert exc.value.code == 1013
134
+
135
+
136
+ async def test_unexpected_error_closes_internal_error(monkeypatch):
137
+ auth = _authenticator(monkeypatch, raises=RuntimeError("boom"))
138
+ with pytest.raises(WebSocketException) as exc:
139
+ await auth(_FakeWebSocket(query_params={"access_token": "good"}))
140
+ assert exc.value.code == 1011
141
+
142
+
143
+ async def test_id_token_substitution_rejected(monkeypatch):
144
+ # An ID-token-only claim (nonce) present → wrong type, rejected even with the
145
+ # positive-marker check off.
146
+ auth = _authenticator(
147
+ monkeypatch, returns={"sub": "u1", "nonce": "n", "scope": "read"}
148
+ )
149
+ with pytest.raises(WebSocketException) as exc:
150
+ await auth(_FakeWebSocket(query_params={"access_token": "idtok"}))
151
+ assert exc.value.code == 1008
152
+ assert exc.value.reason == "Invalid or unauthorized token"
153
+
154
+
155
+ async def test_require_access_token_marker_rejects_markerless_token(monkeypatch):
156
+ # Opt-in F-07 positive marker: a token with none of scope/scp is rejected.
157
+ auth = _authenticator(
158
+ monkeypatch, returns={"sub": "u1"}, require_access_token_marker=True
159
+ )
160
+ with pytest.raises(WebSocketException) as exc:
161
+ await auth(_FakeWebSocket(query_params={"access_token": "nomarker"}))
162
+ assert exc.value.code == 1008
163
+
164
+
165
+ async def test_marker_token_passes_when_required(monkeypatch):
166
+ auth = _authenticator(
167
+ monkeypatch,
168
+ returns={"sub": "u1", "scope": "read"},
169
+ require_access_token_marker=True,
170
+ )
171
+ claims = await auth(_FakeWebSocket(query_params={"access_token": "ok"}))
172
+ assert claims["sub"] == "u1"
173
+
174
+
175
+ async def test_custom_query_param(monkeypatch):
176
+ auth = _authenticator(monkeypatch, returns=_ACCESS_CLAIMS, query_param="tok")
177
+ claims = await auth(_FakeWebSocket(query_params={"tok": "good"}))
178
+ assert claims["sub"] == "u1"
179
+
180
+
181
+ async def test_threads_config_into_validate_token(monkeypatch):
182
+ # The authenticator must thread audience, discovery URL, perform_disco AND
183
+ # the injected custom_claims_validator into TokenValidationConfig — a
184
+ # regression dropping any of these would otherwise pass silently.
185
+ captured = {}
186
+
187
+ async def capturing_validate_token(
188
+ *, token_validation_config, disco_doc_address, **_
189
+ ):
190
+ captured["cfg"] = token_validation_config
191
+ captured["disco"] = disco_doc_address
192
+ return dict(_ACCESS_CLAIMS)
193
+
194
+ monkeypatch.setattr(ws, "validate_token", capturing_validate_token)
195
+
196
+ def my_validator(_claims):
197
+ return None
198
+
199
+ auth = build_ws_authenticator(
200
+ discovery_url=DISCOVERY_URL,
201
+ audience="cid",
202
+ custom_claims_validator=my_validator,
203
+ )
204
+ await auth(_FakeWebSocket(query_params={"access_token": "good"}))
205
+
206
+ cfg = captured["cfg"]
207
+ assert cfg.audience == "cid"
208
+ assert cfg.perform_disco is True
209
+ assert cfg.claims_validator is my_validator
210
+ assert captured["disco"] == DISCOVERY_URL
211
+
212
+
213
+ async def test_to_principal_failure_closes_internal_error(monkeypatch):
214
+ # A failure building the principal AFTER the token validated must fail closed
215
+ # with a mapped 1011, not escape as an unhandled exception (parity with the
216
+ # HTTP middleware's 500 guard).
217
+ def _raise(_claims):
218
+ raise RuntimeError("boom")
219
+
220
+ auth = _authenticator(monkeypatch, returns=_ACCESS_CLAIMS)
221
+ monkeypatch.setattr(ws, "to_principal", _raise)
222
+ with pytest.raises(WebSocketException) as exc:
223
+ await auth(_FakeWebSocket(query_params={"access_token": "good"}))
224
+ assert exc.value.code == 1011
225
+
226
+
227
+ def test_empty_audience_raises():
228
+ with pytest.raises(ValueError, match="audience"):
229
+ build_ws_authenticator(discovery_url=DISCOVERY_URL, audience="")
230
+
231
+
232
+ def test_require_marker_with_empty_claims_raises():
233
+ with pytest.raises(ValueError, match="access_token_marker_claims"):
234
+ build_ws_authenticator(
235
+ discovery_url=DISCOVERY_URL,
236
+ audience="cid",
237
+ require_access_token_marker=True,
238
+ access_token_marker_claims=(),
239
+ )