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.
- {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/.gitignore +3 -0
- {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/CHANGELOG.md +38 -0
- {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/PKG-INFO +1 -1
- {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/fastapi_identity_model/__init__.py +4 -1
- {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/fastapi_identity_model/config.py +4 -1
- {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/fastapi_identity_model/middleware.py +69 -22
- {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/fastapi_identity_model/rp.py +67 -9
- fastapi_identity_model-0.3.0/fastapi_identity_model/ws.py +236 -0
- {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/pyproject.toml +1 -1
- {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/tests/test_config.py +12 -2
- {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/tests/test_middleware.py +60 -4
- {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/tests/test_rp.py +162 -16
- fastapi_identity_model-0.3.0/tests/test_ws.py +239 -0
- {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/README.md +0 -0
- {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/fastapi_identity_model/dependencies.py +0 -0
- {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/fastapi_identity_model/py.typed +0 -0
- {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/fastapi_identity_model/token_manager.py +0 -0
- {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/tests/test_dependencies.py +0 -0
- {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/tests/test_security_cnf_bound_as_bearer.py +0 -0
- {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/tests/test_security_error_oracle.py +0 -0
- {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/tests/test_security_id_token_substitution.py +0 -0
- {fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/tests/test_token_manager.py +0 -0
|
@@ -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.
|
|
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
|
{fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/fastapi_identity_model/__init__.py
RENAMED
|
@@ -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
|
]
|
{fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/fastapi_identity_model/config.py
RENAMED
|
@@ -7,7 +7,10 @@ import os
|
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
def _default_excluded_paths() -> list[str]:
|
|
10
|
-
|
|
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
|
{fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/fastapi_identity_model/middleware.py
RENAMED
|
@@ -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.
|
|
96
|
-
|
|
97
|
-
``/
|
|
98
|
-
|
|
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*
|
|
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
|
-
|
|
160
|
-
|
|
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
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
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 == [
|
|
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 == [
|
|
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
|
|
149
|
-
#
|
|
150
|
-
# (
|
|
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
|
|
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
|
|
11
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
)
|
|
File without changes
|
{fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/fastapi_identity_model/dependencies.py
RENAMED
|
File without changes
|
{fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/fastapi_identity_model/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{fastapi_identity_model-0.2.2 → fastapi_identity_model-0.3.0}/tests/test_security_error_oracle.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|