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.
Files changed (62) hide show
  1. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/PKG-INFO +1 -1
  2. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/pyproject.toml +1 -1
  3. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/__init__.py +14 -1
  4. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_client.py +1 -1
  5. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_discovery.py +11 -1
  6. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_errors.py +78 -2
  7. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_idtoken.py +69 -3
  8. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_oidc.py +161 -23
  9. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_sync/_client.py +1 -1
  10. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_sync/_discovery.py +11 -1
  11. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_sync/_idtoken.py +69 -3
  12. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_sync/_oidc.py +161 -23
  13. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_sync/_transport.py +3 -3
  14. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_transport.py +3 -3
  15. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_types.py +26 -0
  16. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_idtoken.py +66 -2
  17. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_oidc.py +184 -1
  18. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/.gitignore +0 -0
  19. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/README.md +0 -0
  20. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/requirements-release.in +0 -0
  21. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/requirements-release.lock +0 -0
  22. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/scripts/gen_sync.py +0 -0
  23. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_caller.py +0 -0
  24. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_config.py +0 -0
  25. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_contract.py +0 -0
  26. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_headers.py +0 -0
  27. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_iam.py +0 -0
  28. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_iam_shapes.py +0 -0
  29. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_pkce.py +0 -0
  30. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_roles.py +0 -0
  31. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_session.py +0 -0
  32. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_state.py +0 -0
  33. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_sync/__init__.py +0 -0
  34. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_sync/_caller.py +0 -0
  35. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_sync/_iam.py +0 -0
  36. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_sync/_tenant_admin.py +0 -0
  37. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_sync/_tenant_keys.py +0 -0
  38. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_sync/_webhooks.py +0 -0
  39. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_tenant_admin.py +0 -0
  40. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_tenant_keys.py +0 -0
  41. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_webhook_receiver.py +0 -0
  42. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/_webhooks.py +0 -0
  43. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/py.typed +0 -0
  44. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/src/memyauth/sync/__init__.py +0 -0
  45. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/conftest.py +0 -0
  46. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_client.py +0 -0
  47. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_config.py +0 -0
  48. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_contract_parity.py +0 -0
  49. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_discovery.py +0 -0
  50. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_errors.py +0 -0
  51. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_iam.py +0 -0
  52. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_iam_lookups.py +0 -0
  53. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_pkce.py +0 -0
  54. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_roles.py +0 -0
  55. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_session.py +0 -0
  56. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_state.py +0 -0
  57. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_sync_functional.py +0 -0
  58. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_sync_parity.py +0 -0
  59. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_tenant_admin.py +0 -0
  60. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_tenant_keys.py +0 -0
  61. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_transport.py +0 -0
  62. {memydev_auth_sdk-0.2.0 → memydev_auth_sdk-0.3.0}/tests/test_webhooks.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memydev-auth-sdk
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Official MemyAuth (OIDC + IAM) client SDK for Python
5
5
  License: Proprietary and unlicensed pending approved service terms.
6
6
  Requires-Python: >=3.11
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "memydev-auth-sdk"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "Official MemyAuth (OIDC + IAM) client SDK for Python"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -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",
@@ -43,7 +43,7 @@ from ._webhooks import AppWebhookService
43
43
 
44
44
  __all__ = ["MemyAuth", "SDK_VERSION"]
45
45
 
46
- SDK_VERSION = "0.2.0"
46
+ SDK_VERSION = "0.3.0"
47
47
 
48
48
  _SECRET_FIELDS = (
49
49
  "client_secret",
@@ -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 rejected refresh means the session is over — modeled as 401 semantics even though the
223
- wire status may differ, matching memybase's `UnauthorizedError`-on-refresh-rejection behavior."""
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
- from typing import Optional
21
- from urllib.parse import urlencode
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
- {"response_type", "client_id", "redirect_uri", "code_challenge", "code_challenge_method", "scope", "state", "nonce", "prompt"}
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: dict[str, str] = {
108
- "response_type": "code",
109
- "client_id": self._config["client_id"],
110
- "redirect_uri": effective_redirect_uri,
111
- "code_challenge": challenge,
112
- "code_challenge_method": "S256",
113
- "scope": scope,
114
- "state": effective_state,
115
- "nonce": effective_nonce,
116
- "prompt": prompt if prompt is not None else "consent",
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.update(extra_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, *, code: str, code_verifier: str, redirect_uri: Optional[str] = None
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
- body = {
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(self, refresh_token: str) -> OidcTokenResponse:
173
- body = {"grant_type": "refresh_token", "refresh_token": refresh_token}
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.
@@ -45,7 +45,7 @@ from ._webhooks import AppWebhookService
45
45
 
46
46
  __all__ = ["MemyAuth", "SDK_VERSION"]
47
47
 
48
- SDK_VERSION = "0.2.0"
48
+ SDK_VERSION = "0.3.0"
49
49
 
50
50
  _SECRET_FIELDS = (
51
51
  "client_secret",