memydev-auth-sdk 0.2.0__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.
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/PKG-INFO +1 -1
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/pyproject.toml +1 -1
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/__init__.py +14 -1
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_client.py +1 -1
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_discovery.py +11 -1
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_errors.py +78 -2
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_idtoken.py +69 -3
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_oidc.py +161 -23
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_sync/_client.py +1 -1
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_sync/_discovery.py +11 -1
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_sync/_idtoken.py +69 -3
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_sync/_oidc.py +161 -23
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_sync/_transport.py +3 -3
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_transport.py +3 -3
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_types.py +26 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_idtoken.py +66 -2
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_oidc.py +184 -1
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/.gitignore +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/README.md +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/requirements-release.in +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/requirements-release.lock +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/scripts/gen_sync.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_caller.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_config.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_contract.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_headers.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_iam.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_iam_shapes.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_pkce.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_roles.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_session.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_state.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_sync/__init__.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_sync/_caller.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_sync/_iam.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_sync/_tenant_admin.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_sync/_tenant_keys.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_sync/_webhooks.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_tenant_admin.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_tenant_keys.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_webhook_receiver.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_webhooks.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/py.typed +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/sync/__init__.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/conftest.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_client.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_config.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_contract_parity.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_discovery.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_errors.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_iam.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_iam_lookups.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_pkce.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_roles.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_session.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_state.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_sync_functional.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_sync_parity.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_tenant_admin.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_tenant_keys.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_transport.py +0 -0
- {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_webhooks.py +0 -0
|
@@ -17,6 +17,8 @@ from ._client import MemyAuth, SDK_VERSION
|
|
|
17
17
|
from ._config import ResolvedConfig, RetryConfig, resolve_config
|
|
18
18
|
from ._discovery import DiscoveryService
|
|
19
19
|
from ._errors import (
|
|
20
|
+
INTERACTION_REQUIRED_ERRORS,
|
|
21
|
+
AuthorizationResponseError,
|
|
20
22
|
BadRequestError,
|
|
21
23
|
ConfigError,
|
|
22
24
|
ConflictError,
|
|
@@ -26,11 +28,13 @@ from ._errors import (
|
|
|
26
28
|
GoneError,
|
|
27
29
|
IdTokenError,
|
|
28
30
|
IssuerMismatchError,
|
|
31
|
+
LogoutTokenError,
|
|
29
32
|
MemyAuthError,
|
|
30
33
|
NetworkError,
|
|
31
34
|
NotFoundError,
|
|
32
35
|
ProtocolError,
|
|
33
36
|
RefreshRejectedError,
|
|
37
|
+
RevocationError,
|
|
34
38
|
ScopeForbiddenError,
|
|
35
39
|
ServiceUnavailableError,
|
|
36
40
|
SessionTokenError,
|
|
@@ -48,7 +52,7 @@ from ._errors import (
|
|
|
48
52
|
)
|
|
49
53
|
from ._contract import ADMIN_AUDIT_ACTIONS, IAM_API_RESOURCE, IAM_SCOPES, TENANT_EVENT_TYPES
|
|
50
54
|
from ._iam import IamService
|
|
51
|
-
from ._idtoken import IdTokenVerifier
|
|
55
|
+
from ._idtoken import BACKCHANNEL_LOGOUT_EVENT, IdTokenVerifier
|
|
52
56
|
from ._oidc import OidcService
|
|
53
57
|
from ._pkce import SystemRandomSource, generate_code_challenge, generate_code_verifier
|
|
54
58
|
from ._roles import CONSUMER_ROLES, DEFAULT_CONSUMER_ROLE, DEFAULT_ROLE_MAP, GLOBAL_ADMIN_IDP_ROLES, RoleMapper
|
|
@@ -65,6 +69,8 @@ from ._webhook_receiver import (
|
|
|
65
69
|
)
|
|
66
70
|
from ._webhooks import AppWebhookService
|
|
67
71
|
from ._types import (
|
|
72
|
+
AuthorizationResponse,
|
|
73
|
+
LogoutTokenClaims,
|
|
68
74
|
AdminAuditEntry,
|
|
69
75
|
AdminAuditList,
|
|
70
76
|
AppWebhookConfig,
|
|
@@ -133,6 +139,7 @@ __all__ = [
|
|
|
133
139
|
"ADMIN_AUDIT_ACTIONS",
|
|
134
140
|
"TENANT_EVENT_TYPES",
|
|
135
141
|
"IdTokenVerifier",
|
|
142
|
+
"BACKCHANNEL_LOGOUT_EVENT",
|
|
136
143
|
"RoleMapper",
|
|
137
144
|
"DEFAULT_ROLE_MAP",
|
|
138
145
|
"CONSUMER_ROLES",
|
|
@@ -165,8 +172,12 @@ __all__ = [
|
|
|
165
172
|
"DiscoveryEscapeError",
|
|
166
173
|
"TokenExchangeError",
|
|
167
174
|
"RefreshRejectedError",
|
|
175
|
+
"RevocationError",
|
|
176
|
+
"AuthorizationResponseError",
|
|
177
|
+
"INTERACTION_REQUIRED_ERRORS",
|
|
168
178
|
"UserInfoError",
|
|
169
179
|
"IdTokenError",
|
|
180
|
+
"LogoutTokenError",
|
|
170
181
|
"UnsupportedAlgorithmError",
|
|
171
182
|
"SessionTokenError",
|
|
172
183
|
"StateCookieError",
|
|
@@ -179,6 +190,8 @@ __all__ = [
|
|
|
179
190
|
"OidcDiscoveryDocument",
|
|
180
191
|
"DiscoveryHealth",
|
|
181
192
|
"AuthorizationRequest",
|
|
193
|
+
"AuthorizationResponse",
|
|
194
|
+
"LogoutTokenClaims",
|
|
182
195
|
"OidcTokenResponse",
|
|
183
196
|
"UserInfo",
|
|
184
197
|
"UserContext",
|
|
@@ -38,6 +38,8 @@ from ._transport import Transport
|
|
|
38
38
|
__all__ = ["DiscoveryService"]
|
|
39
39
|
|
|
40
40
|
_REQUIRED_KEYS = ("issuer", "authorization_endpoint", "token_endpoint", "userinfo_endpoint", "jwks_uri")
|
|
41
|
+
# Server-side endpoints a provider MAY publish: held to the internal-origin rule when present (revocation).
|
|
42
|
+
_OPTIONAL_SERVER_SIDE_KEYS = ("revocation_endpoint",)
|
|
41
43
|
_ABS_HTTP_RE = re.compile(r"^https?://")
|
|
42
44
|
_DEFAULT_TIMEOUT_MS = 10000
|
|
43
45
|
|
|
@@ -127,6 +129,12 @@ class DiscoveryService:
|
|
|
127
129
|
if not isinstance(value, str) or not _ABS_HTTP_RE.match(value):
|
|
128
130
|
raise DiscoveryError(f"OIDC discovery is missing {key}")
|
|
129
131
|
|
|
132
|
+
# Server-side endpoints a provider MAY publish (RFC 8414 §2): an absolute URL when present.
|
|
133
|
+
for key in _OPTIONAL_SERVER_SIDE_KEYS:
|
|
134
|
+
value = doc.get(key)
|
|
135
|
+
if value is not None and (not isinstance(value, str) or not _ABS_HTTP_RE.match(value)):
|
|
136
|
+
raise DiscoveryError(f"OIDC discovery {key} is not an absolute URL")
|
|
137
|
+
|
|
130
138
|
if _strip_trailing_slashes(doc["issuer"]) != self._public_issuer:
|
|
131
139
|
raise IssuerMismatchError(
|
|
132
140
|
f"OIDC discovery issuer mismatch: expected {self._public_issuer}, got {doc['issuer']}"
|
|
@@ -134,7 +142,9 @@ class DiscoveryService:
|
|
|
134
142
|
|
|
135
143
|
if self._fetch_issuer != self._public_issuer:
|
|
136
144
|
fetch_origin = _origin(self._fetch_issuer)
|
|
137
|
-
for key in ("token_endpoint", "userinfo_endpoint", "jwks_uri"):
|
|
145
|
+
for key in ("token_endpoint", "userinfo_endpoint", "jwks_uri", *_OPTIONAL_SERVER_SIDE_KEYS):
|
|
146
|
+
if doc.get(key) is None:
|
|
147
|
+
continue
|
|
138
148
|
if _origin(doc[key]) != fetch_origin:
|
|
139
149
|
raise DiscoveryEscapeError(
|
|
140
150
|
f"OIDC discovery {key} escaped the internal origin: {doc[key]}"
|
|
@@ -22,9 +22,15 @@
|
|
|
22
22
|
@created 2026-09-06
|
|
23
23
|
@updated 2026-10-03 ConflictError, TenantKeyConflictError, TenantAdminConflictError,
|
|
24
24
|
ScopeForbiddenError (SDK 0.2.0).
|
|
25
|
+
@updated 2026-10-18 SDK 0.3.0, mirroring js/src/errors.ts: `oauth_error` on `TokenExchangeError`/
|
|
26
|
+
`RefreshRejectedError`/`RevocationError` (RFC 6749 §5.2 `error`, read from the wire body); `RevocationError`
|
|
27
|
+
(RFC 7009); `AuthorizationResponseError` (RFC 6749 §4.1.2.1, `interaction_required` for a `prompt=none`
|
|
28
|
+
probe); `LogoutTokenError` (OIDC Back-Channel Logout 1.0 §2.6). A token endpoint answering 5xx/429 raises
|
|
29
|
+
`ServiceUnavailableError` (retry later; the session survives), never `RefreshRejectedError`.
|
|
25
30
|
"""
|
|
26
31
|
from __future__ import annotations
|
|
27
32
|
|
|
33
|
+
import re
|
|
28
34
|
from typing import Any, Optional
|
|
29
35
|
|
|
30
36
|
from ._contract import TENANT_ADMIN_CONFLICT_REASONS, TENANT_KEY_CONFLICT_REASONS
|
|
@@ -51,16 +57,32 @@ __all__ = [
|
|
|
51
57
|
"DiscoveryEscapeError",
|
|
52
58
|
"TokenExchangeError",
|
|
53
59
|
"RefreshRejectedError",
|
|
60
|
+
"RevocationError",
|
|
61
|
+
"AuthorizationResponseError",
|
|
62
|
+
"INTERACTION_REQUIRED_ERRORS",
|
|
54
63
|
"UserInfoError",
|
|
55
64
|
"IdTokenError",
|
|
65
|
+
"LogoutTokenError",
|
|
56
66
|
"UnsupportedAlgorithmError",
|
|
57
67
|
"SessionTokenError",
|
|
58
68
|
"StateCookieError",
|
|
59
69
|
"StateMismatchError",
|
|
60
70
|
"ERROR_CLASS_MAP",
|
|
61
71
|
"decode_error",
|
|
72
|
+
"oauth_error_of",
|
|
62
73
|
]
|
|
63
74
|
|
|
75
|
+
# RFC 6749 §5.2: `error` is ASCII without `"` or `\\`. Anything else in a body is not an OAuth error code.
|
|
76
|
+
_OAUTH_ERROR_CODE = re.compile(r'^[\x20-\x21\x23-\x5B\x5D-\x7E]{1,128}$')
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def oauth_error_of(body: Any) -> Optional[str]:
|
|
80
|
+
"""The RFC 6749 §5.2 `error` code of a wire body, when it carries a well-formed one."""
|
|
81
|
+
if not isinstance(body, dict):
|
|
82
|
+
return None
|
|
83
|
+
error = body.get("error")
|
|
84
|
+
return error if isinstance(error, str) and _OAUTH_ERROR_CODE.match(error) else None
|
|
85
|
+
|
|
64
86
|
|
|
65
87
|
class MemyAuthError(Exception):
|
|
66
88
|
"""Base for every MemyAuth SDK error. `status == 0` means "no wire status" (pre-wire guard,
|
|
@@ -217,14 +239,60 @@ class TokenExchangeError(MemyAuthError):
|
|
|
217
239
|
code = "token_exchange_failed"
|
|
218
240
|
status = 0
|
|
219
241
|
|
|
242
|
+
def __init__(self, message: str = "", **kwargs: Any) -> None:
|
|
243
|
+
super().__init__(message, **kwargs)
|
|
244
|
+
#: The RFC 6749 §5.2 `error` code (`invalid_grant`, `invalid_client`, ...), when the body carried one.
|
|
245
|
+
self.oauth_error: Optional[str] = oauth_error_of(self.body)
|
|
246
|
+
|
|
220
247
|
|
|
221
248
|
class RefreshRejectedError(MemyAuthError):
|
|
222
|
-
"""A
|
|
223
|
-
|
|
249
|
+
"""A 4xx refresh answer: the refresh token is invalid, expired or revoked, so the session is over —
|
|
250
|
+
modeled as 401 semantics, matching memybase's `UnauthorizedError`-on-refresh-rejection behavior. A 5xx
|
|
251
|
+
or 429 is NOT this class: it raises `ServiceUnavailableError`, because a provider outage must not sign
|
|
252
|
+
every user out."""
|
|
224
253
|
|
|
225
254
|
code = "refresh_rejected"
|
|
226
255
|
status = 401
|
|
227
256
|
|
|
257
|
+
def __init__(self, message: str = "", **kwargs: Any) -> None:
|
|
258
|
+
super().__init__(message, **kwargs)
|
|
259
|
+
self.oauth_error: Optional[str] = oauth_error_of(self.body)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class RevocationError(MemyAuthError):
|
|
263
|
+
"""A non-2xx RFC 7009 revocation answer other than 5xx/429 (those raise `ServiceUnavailableError`):
|
|
264
|
+
`invalid_client`, `unsupported_token_type`, .... `http_status` is the wire status; `.status` stays 0."""
|
|
265
|
+
|
|
266
|
+
code = "revocation_failed"
|
|
267
|
+
status = 0
|
|
268
|
+
|
|
269
|
+
def __init__(self, message: str = "", *, http_status: int = 0, **kwargs: Any) -> None:
|
|
270
|
+
super().__init__(message, **kwargs)
|
|
271
|
+
self.http_status = http_status
|
|
272
|
+
self.oauth_error: Optional[str] = oauth_error_of(self.body)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
#: The OIDC errors a `prompt=none` probe is answered with when the user must interact (OIDC Core §3.1.2.6).
|
|
276
|
+
INTERACTION_REQUIRED_ERRORS: tuple[str, ...] = ("login_required", "interaction_required", "consent_required", "account_selection_required")
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class AuthorizationResponseError(MemyAuthError):
|
|
280
|
+
"""The provider answered the authorization request with an error (RFC 6749 §4.1.2.1) instead of a code.
|
|
281
|
+
`oauth_error` is the error code; the provider's `error_description` is untrusted text and is kept only in
|
|
282
|
+
`detail` (truncated), never in the message."""
|
|
283
|
+
|
|
284
|
+
code = "authorization_error"
|
|
285
|
+
status = 0
|
|
286
|
+
|
|
287
|
+
def __init__(self, oauth_error: str, **kwargs: Any) -> None:
|
|
288
|
+
super().__init__(f"OIDC authorization failed: {oauth_error}", **kwargs)
|
|
289
|
+
self.oauth_error = oauth_error
|
|
290
|
+
|
|
291
|
+
@property
|
|
292
|
+
def interaction_required(self) -> bool:
|
|
293
|
+
"""Whether a `prompt=none` probe failed only because the user must interact."""
|
|
294
|
+
return self.oauth_error in INTERACTION_REQUIRED_ERRORS
|
|
295
|
+
|
|
228
296
|
|
|
229
297
|
class UserInfoError(MemyAuthError):
|
|
230
298
|
code = "userinfo_failed"
|
|
@@ -236,6 +304,14 @@ class IdTokenError(MemyAuthError):
|
|
|
236
304
|
status = 401
|
|
237
305
|
|
|
238
306
|
|
|
307
|
+
class LogoutTokenError(IdTokenError):
|
|
308
|
+
"""A back-channel logout token failed OIDC Back-Channel Logout 1.0 §2.6 validation. Still an
|
|
309
|
+
`IdTokenError` (the same signature/issuer/audience machinery verifies it)."""
|
|
310
|
+
|
|
311
|
+
code = "logout_token_invalid"
|
|
312
|
+
status = 401
|
|
313
|
+
|
|
314
|
+
|
|
239
315
|
class UnsupportedAlgorithmError(IdTokenError):
|
|
240
316
|
"""A token's `alg` header is outside the configured `allowed_algorithms` allow-list — raised
|
|
241
317
|
BEFORE any key lookup. This is what the mandatory RS256 negative test (R1) asserts on."""
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
rewritten, since JWKS is a server-side endpoint per §C.1 step 6), _config.py
|
|
18
18
|
(`allowed_algorithms`/`id_token_validation`), _errors.py, tests/test_idtoken.py
|
|
19
19
|
@created 2026-09-06
|
|
20
|
+
@updated 2026-10-18 SDK 0.3.0: `verify_logout_token()` (OIDC Back-Channel Logout 1.0 §2.6), mirroring
|
|
21
|
+
js/src/idtoken.ts, so a BFF verifies logout tokens here instead of with its own JWT code.
|
|
20
22
|
"""
|
|
21
23
|
from __future__ import annotations
|
|
22
24
|
|
|
@@ -27,13 +29,16 @@ import jwt
|
|
|
27
29
|
|
|
28
30
|
# ABSOLUTE imports for the shared/pure modules; RELATIVE for ._discovery/._transport, which DO get
|
|
29
31
|
# _sync/ twins and must bind to THEIR sibling twins (see _transport.py's header for why).
|
|
30
|
-
from memyauth._errors import ConfigError, IdTokenError, UnsupportedAlgorithmError
|
|
31
|
-
from memyauth._types import Clock, IdpTokenClaims, IdTokenValidationMode, SystemClock
|
|
32
|
+
from memyauth._errors import ConfigError, IdTokenError, LogoutTokenError, UnsupportedAlgorithmError
|
|
33
|
+
from memyauth._types import Clock, IdpTokenClaims, IdTokenValidationMode, LogoutTokenClaims, SystemClock
|
|
32
34
|
|
|
33
35
|
from ._discovery import DiscoveryService
|
|
34
36
|
from ._transport import Transport, read_json_or_none
|
|
35
37
|
|
|
36
|
-
__all__ = ["IdTokenVerifier"]
|
|
38
|
+
__all__ = ["IdTokenVerifier", "BACKCHANNEL_LOGOUT_EVENT"]
|
|
39
|
+
|
|
40
|
+
#: The `events` member a back-channel logout token carries (OIDC Back-Channel Logout 1.0 §2.4).
|
|
41
|
+
BACKCHANNEL_LOGOUT_EVENT = "http://schemas.openid.net/event/backchannel-logout"
|
|
37
42
|
|
|
38
43
|
_DEFAULT_LOCAL_JWKS_ALGORITHMS = ("ES384",)
|
|
39
44
|
_DEFAULT_CLOCK_TOLERANCE_SECONDS = 60
|
|
@@ -153,6 +158,67 @@ class IdTokenVerifier:
|
|
|
153
158
|
|
|
154
159
|
return claims # type: ignore[return-value]
|
|
155
160
|
|
|
161
|
+
async def verify_logout_token(self, logout_token: str, *, issuer: str, audience: str) -> LogoutTokenClaims:
|
|
162
|
+
"""Verifies an OIDC Back-Channel Logout 1.0 logout token (§2.6): the same signature, algorithm,
|
|
163
|
+
issuer, audience and time checks as an ID token (but `exp` only when present, as in the JS SDK), then
|
|
164
|
+
an `events` member naming the back-channel logout event, `iat` and `jti`, `sub` and/or `sid`, and no
|
|
165
|
+
`nonce`. Replay (`jti` already seen) is the caller's check. Raises `LogoutTokenError`."""
|
|
166
|
+
if self.mode == "tls-token-endpoint":
|
|
167
|
+
raise ConfigError(
|
|
168
|
+
"tls-token-endpoint mode does not verify ID tokens; identity is derived from the "
|
|
169
|
+
"token-endpoint TLS channel per OIDC Core 3.1.3.7 item 6 — use decode_unverified() "
|
|
170
|
+
"plus /oidc/me or /iam/me"
|
|
171
|
+
)
|
|
172
|
+
try:
|
|
173
|
+
header = jwt.get_unverified_header(logout_token)
|
|
174
|
+
except jwt.PyJWTError as exc:
|
|
175
|
+
raise LogoutTokenError(f"malformed logout token: {exc}") from exc
|
|
176
|
+
alg = header.get("alg")
|
|
177
|
+
if alg not in self._allowed_algorithms:
|
|
178
|
+
raise UnsupportedAlgorithmError(
|
|
179
|
+
f"logout token alg {alg!r} is not in the allowed algorithms {sorted(self._allowed_algorithms)!r}"
|
|
180
|
+
)
|
|
181
|
+
kid = header.get("kid")
|
|
182
|
+
if not kid:
|
|
183
|
+
raise LogoutTokenError("logout token is missing a kid header")
|
|
184
|
+
try:
|
|
185
|
+
jwk = await self._get_key_for_kid(kid)
|
|
186
|
+
except IdTokenError as exc:
|
|
187
|
+
raise LogoutTokenError(str(exc)) from exc
|
|
188
|
+
key = jwt.PyJWK(jwk).key
|
|
189
|
+
try:
|
|
190
|
+
claims = jwt.decode(
|
|
191
|
+
logout_token,
|
|
192
|
+
key,
|
|
193
|
+
algorithms=[alg],
|
|
194
|
+
audience=audience,
|
|
195
|
+
issuer=issuer,
|
|
196
|
+
options={"verify_exp": False, "verify_nbf": False, "verify_iat": False, "require": []},
|
|
197
|
+
)
|
|
198
|
+
except jwt.PyJWTError as exc:
|
|
199
|
+
raise LogoutTokenError(f"logout token verification failed: {exc}") from exc
|
|
200
|
+
now_seconds = self._clock.now() / 1000
|
|
201
|
+
tolerance = self._clock_tolerance_seconds
|
|
202
|
+
exp = claims.get("exp")
|
|
203
|
+
if exp is not None and (not isinstance(exp, (int, float)) or now_seconds > exp + tolerance):
|
|
204
|
+
raise LogoutTokenError("logout token has expired")
|
|
205
|
+
nbf = claims.get("nbf")
|
|
206
|
+
if isinstance(nbf, (int, float)) and now_seconds < nbf - tolerance:
|
|
207
|
+
raise LogoutTokenError("logout token is not yet valid")
|
|
208
|
+
events = claims.get("events")
|
|
209
|
+
if not isinstance(events, dict) or BACKCHANNEL_LOGOUT_EVENT not in events:
|
|
210
|
+
raise LogoutTokenError("logout token carries no back-channel logout event")
|
|
211
|
+
if "nonce" in claims:
|
|
212
|
+
raise LogoutTokenError("a logout token must not carry a nonce")
|
|
213
|
+
jti = claims.get("jti")
|
|
214
|
+
if not isinstance(claims.get("iat"), (int, float)) or isinstance(claims.get("iat"), bool) or not isinstance(jti, str) or not jti:
|
|
215
|
+
raise LogoutTokenError("logout token is missing iat or jti")
|
|
216
|
+
sub = claims.get("sub")
|
|
217
|
+
sid = claims.get("sid")
|
|
218
|
+
if not (isinstance(sub, str) and sub) and not (isinstance(sid, str) and sid):
|
|
219
|
+
raise LogoutTokenError("logout token names neither sub nor sid")
|
|
220
|
+
return claims # type: ignore[return-value]
|
|
221
|
+
|
|
156
222
|
def decode_unverified(self, id_token: str) -> IdpTokenClaims:
|
|
157
223
|
"""Decode without verifying. Available in BOTH modes; the ONLY path in
|
|
158
224
|
`tls-token-endpoint` mode."""
|
|
@@ -9,6 +9,14 @@
|
|
|
9
9
|
(`TokenExchangeError` / `RefreshRejectedError`) rather than the generic `decode_error` mapping,
|
|
10
10
|
because the token endpoint's failure body is an OAuth `{error, error_description}` shape, not
|
|
11
11
|
the IAM surface's `{code,message,detail}` envelope.
|
|
12
|
+
|
|
13
|
+
0.3.0 (2026-10-18), mirroring js/src/oidc.ts: `omit_prompt=True` sends no `prompt` (JS `prompt: null`;
|
|
14
|
+
`prompt=None` keeps meaning the `'consent'` default, so a wrapper forwarding an unset value is unchanged),
|
|
15
|
+
`max_age`, `login_hint` and RFC 8707 `resource` on the authorization request,
|
|
16
|
+
`resource` on the code exchange and the refresh grant, `revoke_token()` (RFC 7009) and
|
|
17
|
+
`parse_authorization_response()` (state, RFC 9207 `iss`, typed provider errors). A refresh answered
|
|
18
|
+
5xx/429 raises `ServiceUnavailableError`: before, every such answer was `RefreshRejectedError`, so a provider
|
|
19
|
+
outage ended every session.
|
|
12
20
|
@dependencies base64, urllib.parse (stdlib); ._transport, ._pkce, ._discovery
|
|
13
21
|
@relatedFiles _discovery.py (this service's sole source of every OIDC URL), _pkce.py (PKCE +
|
|
14
22
|
random helpers), _config.py (ResolvedConfig), _errors.py, tests/test_oidc.py
|
|
@@ -17,8 +25,9 @@
|
|
|
17
25
|
from __future__ import annotations
|
|
18
26
|
|
|
19
27
|
import base64
|
|
20
|
-
|
|
21
|
-
from
|
|
28
|
+
import hmac
|
|
29
|
+
from typing import Optional, Union
|
|
30
|
+
from urllib.parse import parse_qs, urlencode, urlsplit
|
|
22
31
|
|
|
23
32
|
import httpx
|
|
24
33
|
|
|
@@ -26,16 +35,22 @@ import httpx
|
|
|
26
35
|
# _sync/ twins and must bind to THEIR sibling twins (see _transport.py's header for why).
|
|
27
36
|
from memyauth._config import ResolvedConfig
|
|
28
37
|
from memyauth._errors import (
|
|
38
|
+
AuthorizationResponseError,
|
|
29
39
|
ConfigError,
|
|
40
|
+
IssuerMismatchError,
|
|
30
41
|
ProtocolError,
|
|
31
42
|
RefreshRejectedError,
|
|
43
|
+
RevocationError,
|
|
44
|
+
ServiceUnavailableError,
|
|
45
|
+
StateMismatchError,
|
|
32
46
|
TokenExchangeError,
|
|
33
47
|
UnauthorizedError,
|
|
34
48
|
UserInfoError,
|
|
49
|
+
oauth_error_of,
|
|
35
50
|
)
|
|
36
51
|
from memyauth._headers import is_header_safe
|
|
37
52
|
from memyauth._pkce import generate_code_challenge, generate_code_verifier, random_nonce, random_state
|
|
38
|
-
from memyauth._types import AuthorizationRequest, OidcTokenResponse, RandomSource, UserInfo
|
|
53
|
+
from memyauth._types import AuthorizationRequest, AuthorizationResponse, OidcTokenResponse, RandomSource, UserInfo
|
|
39
54
|
|
|
40
55
|
from ._discovery import DiscoveryService
|
|
41
56
|
from ._transport import Transport, read_json_or_none, truncated_text
|
|
@@ -43,8 +58,31 @@ from ._transport import Transport, read_json_or_none, truncated_text
|
|
|
43
58
|
__all__ = ["OidcService"]
|
|
44
59
|
|
|
45
60
|
_AUTH_STANDARD_KEYS = frozenset(
|
|
46
|
-
{
|
|
61
|
+
{
|
|
62
|
+
"response_type", "client_id", "redirect_uri", "code_challenge", "code_challenge_method", "scope", "state", "nonce",
|
|
63
|
+
"prompt",
|
|
64
|
+
}
|
|
47
65
|
)
|
|
66
|
+
# 0.3.0 typed parameters a 0.2.0 caller may still pass through `extra_params`: refused only when the typed
|
|
67
|
+
# argument sets the same key too (the value would be ambiguous).
|
|
68
|
+
_TYPED_AUTH_KEYS = (("max_age", "max_age"), ("login_hint", "login_hint"), ("resource", "resource"))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _resource_list(resource: Union[str, list[str], tuple[str, ...], None]) -> list[str]:
|
|
72
|
+
"""RFC 8707 §2: each resource indicator is an absolute URI without a fragment."""
|
|
73
|
+
if resource is None:
|
|
74
|
+
return []
|
|
75
|
+
values = [resource] if isinstance(resource, str) else list(resource)
|
|
76
|
+
for value in values:
|
|
77
|
+
parts = urlsplit(value) if isinstance(value, str) else None
|
|
78
|
+
if parts is None or not parts.scheme or "#" in value:
|
|
79
|
+
raise ConfigError("resource must be an absolute URI with no fragment (RFC 8707 §2)")
|
|
80
|
+
return values
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _is_unavailable_status(status: int) -> bool:
|
|
84
|
+
"""A token (or revocation) endpoint answering 5xx or 429 is unavailable, not refusing."""
|
|
85
|
+
return status >= 500 or status == 429
|
|
48
86
|
_LOGOUT_STANDARD_KEYS = frozenset({"post_logout_redirect_uri", "id_token_hint", "client_id", "state"})
|
|
49
87
|
|
|
50
88
|
|
|
@@ -77,6 +115,10 @@ class OidcService:
|
|
|
77
115
|
nonce: Optional[str] = None,
|
|
78
116
|
code_verifier: Optional[str] = None,
|
|
79
117
|
prompt: Optional[str] = None,
|
|
118
|
+
omit_prompt: bool = False,
|
|
119
|
+
max_age: Optional[int] = None,
|
|
120
|
+
login_hint: Optional[str] = None,
|
|
121
|
+
resource: Union[str, list[str], tuple[str, ...], None] = None,
|
|
80
122
|
return_to: Optional[str] = None,
|
|
81
123
|
extra_params: Optional[dict[str, str]] = None,
|
|
82
124
|
) -> AuthorizationRequest:
|
|
@@ -85,6 +127,15 @@ class OidcService:
|
|
|
85
127
|
# caller must fail deterministically without spending a discovery round trip or generating
|
|
86
128
|
# provider traffic.
|
|
87
129
|
_assert_no_extra_param_collision(extra_params, _AUTH_STANDARD_KEYS)
|
|
130
|
+
if omit_prompt and prompt is not None:
|
|
131
|
+
raise ConfigError("prompt and omit_prompt cannot both be set")
|
|
132
|
+
typed = {"max_age": max_age, "login_hint": login_hint, "resource": resource}
|
|
133
|
+
for argument, key in _TYPED_AUTH_KEYS:
|
|
134
|
+
if typed[argument] is not None and extra_params and key in extra_params:
|
|
135
|
+
raise ConfigError(f"{argument} and extraParams.{key} cannot both be set")
|
|
136
|
+
if max_age is not None and (isinstance(max_age, bool) or not isinstance(max_age, int) or max_age < 0):
|
|
137
|
+
raise ConfigError("maxAge must be a non-negative integer number of seconds")
|
|
138
|
+
resources = _resource_list(resource)
|
|
88
139
|
doc = await self._discovery.discover()
|
|
89
140
|
|
|
90
141
|
effective_redirect_uri = redirect_uri or self._config["redirect_uri"]
|
|
@@ -104,19 +155,25 @@ class OidcService:
|
|
|
104
155
|
negotiated = list(base_scopes)
|
|
105
156
|
scope = " ".join(negotiated)
|
|
106
157
|
|
|
107
|
-
params:
|
|
108
|
-
"response_type"
|
|
109
|
-
"client_id"
|
|
110
|
-
"redirect_uri"
|
|
111
|
-
"code_challenge"
|
|
112
|
-
"code_challenge_method"
|
|
113
|
-
"scope"
|
|
114
|
-
"state"
|
|
115
|
-
"nonce"
|
|
116
|
-
|
|
117
|
-
|
|
158
|
+
params: list[tuple[str, str]] = [
|
|
159
|
+
("response_type", "code"),
|
|
160
|
+
("client_id", self._config["client_id"]),
|
|
161
|
+
("redirect_uri", effective_redirect_uri),
|
|
162
|
+
("code_challenge", challenge),
|
|
163
|
+
("code_challenge_method", "S256"),
|
|
164
|
+
("scope", scope),
|
|
165
|
+
("state", effective_state),
|
|
166
|
+
("nonce", effective_nonce),
|
|
167
|
+
]
|
|
168
|
+
if not omit_prompt:
|
|
169
|
+
params.append(("prompt", prompt if prompt is not None else "consent"))
|
|
170
|
+
if max_age is not None:
|
|
171
|
+
params.append(("max_age", str(max_age)))
|
|
172
|
+
if login_hint is not None:
|
|
173
|
+
params.append(("login_hint", login_hint))
|
|
174
|
+
params.extend(("resource", value) for value in resources)
|
|
118
175
|
if extra_params:
|
|
119
|
-
params.
|
|
176
|
+
params.extend(extra_params.items())
|
|
120
177
|
|
|
121
178
|
url = f"{doc['authorization_endpoint']}?{urlencode(params)}"
|
|
122
179
|
return {
|
|
@@ -140,7 +197,7 @@ class OidcService:
|
|
|
140
197
|
return {"Authorization": f"Basic {base64.b64encode(raw).decode('ascii')}"}
|
|
141
198
|
return {}
|
|
142
199
|
|
|
143
|
-
async def _post_token(self, body: dict[str, str]) -> httpx.Response:
|
|
200
|
+
async def _post_token(self, body: dict[str, Union[str, list[str]]]) -> httpx.Response:
|
|
144
201
|
doc = await self._discovery.discover()
|
|
145
202
|
form = {**body, **self._credential_form_fields()}
|
|
146
203
|
headers = self._credential_headers()
|
|
@@ -154,30 +211,111 @@ class OidcService:
|
|
|
154
211
|
return body # type: ignore[return-value]
|
|
155
212
|
|
|
156
213
|
async def exchange_code(
|
|
157
|
-
self,
|
|
214
|
+
self,
|
|
215
|
+
*,
|
|
216
|
+
code: str,
|
|
217
|
+
code_verifier: str,
|
|
218
|
+
redirect_uri: Optional[str] = None,
|
|
219
|
+
resource: Union[str, list[str], tuple[str, ...], None] = None,
|
|
158
220
|
) -> OidcTokenResponse:
|
|
159
|
-
|
|
221
|
+
resources = _resource_list(resource)
|
|
222
|
+
body: dict[str, Union[str, list[str]]] = {
|
|
160
223
|
"grant_type": "authorization_code",
|
|
161
224
|
"code": code,
|
|
162
225
|
"redirect_uri": redirect_uri or self._config["redirect_uri"],
|
|
163
226
|
"code_verifier": code_verifier,
|
|
164
227
|
}
|
|
228
|
+
if resources:
|
|
229
|
+
body["resource"] = resources
|
|
165
230
|
resp = await self._post_token(body)
|
|
166
231
|
if not (200 <= resp.status_code < 300):
|
|
167
232
|
raise TokenExchangeError(
|
|
168
|
-
f"OIDC token exchange failed: {resp.status_code}", detail=truncated_text(resp)
|
|
233
|
+
f"OIDC token exchange failed: {resp.status_code}", detail=truncated_text(resp), body=read_json_or_none(resp)
|
|
169
234
|
)
|
|
170
235
|
return self._parse_token_response(resp)
|
|
171
236
|
|
|
172
|
-
async def refresh_tokens(
|
|
173
|
-
|
|
237
|
+
async def refresh_tokens(
|
|
238
|
+
self, refresh_token: str, *, resource: Union[str, list[str], tuple[str, ...], None] = None
|
|
239
|
+
) -> OidcTokenResponse:
|
|
240
|
+
"""The refresh-token grant. A 4xx answer raises `RefreshRejectedError` (the session is over); a 5xx
|
|
241
|
+
or 429 raises `ServiceUnavailableError` (the provider is down: keep the session and retry later)."""
|
|
242
|
+
resources = _resource_list(resource)
|
|
243
|
+
body: dict[str, Union[str, list[str]]] = {"grant_type": "refresh_token", "refresh_token": refresh_token}
|
|
244
|
+
if resources:
|
|
245
|
+
body["resource"] = resources
|
|
174
246
|
resp = await self._post_token(body)
|
|
175
247
|
if not (200 <= resp.status_code < 300):
|
|
248
|
+
if _is_unavailable_status(resp.status_code):
|
|
249
|
+
raise ServiceUnavailableError(
|
|
250
|
+
"OIDC token endpoint unavailable", detail=truncated_text(resp), body=read_json_or_none(resp)
|
|
251
|
+
)
|
|
176
252
|
raise RefreshRejectedError(
|
|
177
|
-
f"OIDC token refresh rejected: {resp.status_code}", detail=truncated_text(resp)
|
|
253
|
+
f"OIDC token refresh rejected: {resp.status_code}", detail=truncated_text(resp), body=read_json_or_none(resp)
|
|
178
254
|
)
|
|
179
255
|
return self._parse_token_response(resp)
|
|
180
256
|
|
|
257
|
+
async def revoke_token(self, token: str, *, token_type_hint: Optional[str] = None) -> None:
|
|
258
|
+
"""RFC 7009 token revocation, authenticated like the token endpoint (§2.1). Returns once the provider
|
|
259
|
+
answered 200 (§2.2: also for a token it did not know). Raises `ConfigError` when discovery publishes no
|
|
260
|
+
`revocation_endpoint`, `ServiceUnavailableError` on 5xx/429 (§2.2.1: retry later), `RevocationError`
|
|
261
|
+
otherwise. Never retried: a POST."""
|
|
262
|
+
if not token:
|
|
263
|
+
raise ConfigError("revokeToken needs a token")
|
|
264
|
+
doc = await self._discovery.discover()
|
|
265
|
+
endpoint = doc.get("revocation_endpoint")
|
|
266
|
+
if not isinstance(endpoint, str):
|
|
267
|
+
raise ConfigError("the provider publishes no revocation_endpoint")
|
|
268
|
+
form: dict[str, Union[str, list[str]]] = {"token": token}
|
|
269
|
+
if token_type_hint is not None:
|
|
270
|
+
form["token_type_hint"] = token_type_hint
|
|
271
|
+
form.update(self._credential_form_fields())
|
|
272
|
+
resp = await self._transport.send("POST", endpoint, form=form, headers=self._credential_headers())
|
|
273
|
+
if 200 <= resp.status_code < 300:
|
|
274
|
+
return
|
|
275
|
+
if _is_unavailable_status(resp.status_code):
|
|
276
|
+
raise ServiceUnavailableError("OAuth revocation endpoint unavailable", body=read_json_or_none(resp))
|
|
277
|
+
raise RevocationError(
|
|
278
|
+
f"OAuth token revocation failed: {resp.status_code}", http_status=resp.status_code, body=read_json_or_none(resp)
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
async def parse_authorization_response(self, response: str, *, state: str) -> AuthorizationResponse:
|
|
282
|
+
"""Reads the authorization response at the redirect URI (RFC 6749 §4.1.2): a callback URL or its query
|
|
283
|
+
string. Checks `state` first (a mismatch is `StateMismatchError`, error responses included), then RFC
|
|
284
|
+
9207 `iss` against discovery (required when discovery sets `authorization_response_iss_parameter_supported`;
|
|
285
|
+
a mismatch is `IssuerMismatchError`), then returns the code, or raises `AuthorizationResponseError` for a
|
|
286
|
+
provider error (`interaction_required` tells a failed `prompt=none` probe apart)."""
|
|
287
|
+
query = urlsplit(response).query if "://" in response else response.lstrip("?")
|
|
288
|
+
parsed = parse_qs(query, keep_blank_values=True)
|
|
289
|
+
|
|
290
|
+
def first(key: str) -> Optional[str]:
|
|
291
|
+
values = parsed.get(key)
|
|
292
|
+
return values[0] if values else None
|
|
293
|
+
|
|
294
|
+
got_state = first("state")
|
|
295
|
+
if got_state is None or not hmac.compare_digest(got_state.encode("utf-8"), state.encode("utf-8")):
|
|
296
|
+
raise StateMismatchError("Authorization response state does not match the sign-in")
|
|
297
|
+
doc = await self._discovery.discover()
|
|
298
|
+
iss = first("iss")
|
|
299
|
+
if iss is not None:
|
|
300
|
+
if iss.rstrip("/") != str(doc["issuer"]).rstrip("/"):
|
|
301
|
+
raise IssuerMismatchError("Authorization response iss does not match the discovered issuer (RFC 9207)")
|
|
302
|
+
elif doc.get("authorization_response_iss_parameter_supported") is True:
|
|
303
|
+
raise IssuerMismatchError("Authorization response carries no iss, which the provider promises (RFC 9207 §2.4)")
|
|
304
|
+
error = first("error")
|
|
305
|
+
if error is not None:
|
|
306
|
+
code = oauth_error_of({"error": error})
|
|
307
|
+
if code is None:
|
|
308
|
+
raise ProtocolError("Authorization response carries a malformed error")
|
|
309
|
+
description = first("error_description")
|
|
310
|
+
raise AuthorizationResponseError(code, detail=description[:200] if description is not None else None)
|
|
311
|
+
code_value = first("code")
|
|
312
|
+
if not code_value:
|
|
313
|
+
raise ProtocolError("Authorization response carries neither code nor error")
|
|
314
|
+
result: AuthorizationResponse = {"code": code_value, "state": got_state}
|
|
315
|
+
if iss is not None:
|
|
316
|
+
result["iss"] = iss
|
|
317
|
+
return result
|
|
318
|
+
|
|
181
319
|
async def fetch_user_info(self, access_token: str) -> UserInfo:
|
|
182
320
|
# Checked before discovery: a token that is not a safe header value would otherwise reach the HTTP
|
|
183
321
|
# stack, whose error message quotes the header — i.e. the token.
|