memydev-auth-sdk 0.1.0__py3-none-any.whl
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.
- memyauth/__init__.py +144 -0
- memyauth/_client.py +169 -0
- memyauth/_config.py +407 -0
- memyauth/_discovery.py +172 -0
- memyauth/_errors.py +270 -0
- memyauth/_iam.py +298 -0
- memyauth/_idtoken.py +165 -0
- memyauth/_oidc.py +237 -0
- memyauth/_pkce.py +61 -0
- memyauth/_roles.py +109 -0
- memyauth/_session.py +149 -0
- memyauth/_state.py +139 -0
- memyauth/_sync/__init__.py +3 -0
- memyauth/_sync/_client.py +171 -0
- memyauth/_sync/_discovery.py +174 -0
- memyauth/_sync/_iam.py +300 -0
- memyauth/_sync/_idtoken.py +167 -0
- memyauth/_sync/_oidc.py +239 -0
- memyauth/_sync/_transport.py +223 -0
- memyauth/_transport.py +221 -0
- memyauth/_types.py +309 -0
- memyauth/py.typed +0 -0
- memyauth/sync/__init__.py +147 -0
- memydev_auth_sdk-0.1.0.dist-info/METADATA +86 -0
- memydev_auth_sdk-0.1.0.dist-info/RECORD +26 -0
- memydev_auth_sdk-0.1.0.dist-info/WHEEL +4 -0
memyauth/_sync/_iam.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
# GENERATED by scripts/gen_sync.py from memyauth/_iam.py — DO NOT EDIT.
|
|
2
|
+
# Regenerate: python sdk/py/scripts/gen_sync.py (CI gate: gen_sync.py --check).
|
|
3
|
+
"""
|
|
4
|
+
@fileoverview Capability 5 — IAM identity resolver: caller-credential resolution, permissive
|
|
5
|
+
response validation, role projection, tenant-key redaction.
|
|
6
|
+
@module memyauth._iam
|
|
7
|
+
@description [async -> _sync/_iam.py twin via scripts/gen_sync.py]. This module is the direct
|
|
8
|
+
fix for the estate's single worst confirmed defect (D2): roboson's `/iam/me` model is
|
|
9
|
+
`extra="forbid"` and omits `organizations`, a field memyauth's IAM returns UNCONDITIONALLY, so
|
|
10
|
+
a real login there can 503. `assert_iam_user_context()` is the enforcement point of the
|
|
11
|
+
"permissive about unknown fields, strict about known ones" policy (§C.5.1) — it is a
|
|
12
|
+
hand-written checker, NOT a Pydantic `BaseModel`, deliberately: `ConfigDict(extra="forbid")` is
|
|
13
|
+
one keyword away from re-creating D2, and Pydantic is not a dependency of this SDK (zero deps
|
|
14
|
+
beyond httpx + PyJWT). The caller-credential resolver mirrors
|
|
15
|
+
`memybase/src/iam/iam-client.ts:112-126` with the header names corrected to the real ones
|
|
16
|
+
(`X-App-Id`/`X-App-Secret` never existed) and enforces mutual exclusion structurally: the
|
|
17
|
+
header builder can only ever produce ONE marker header per request (R6).
|
|
18
|
+
@dependencies ._transport, ._roles
|
|
19
|
+
@relatedFiles _roles.py (RoleMapper — mapAll/isGlobalAdmin), _config.py (ResolvedConfig, the
|
|
20
|
+
caller-credential fields), _errors.py, _types.py (UserContext/IamSession/IamOrgEntry),
|
|
21
|
+
tests/test_iam.py
|
|
22
|
+
@created 2026-09-06
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import Any, Optional
|
|
27
|
+
|
|
28
|
+
import httpx
|
|
29
|
+
|
|
30
|
+
# ABSOLUTE imports for the shared/pure modules; RELATIVE for ._transport, which DOES get a
|
|
31
|
+
# _sync/ twin and must bind to ITS sibling twin (see _transport.py's header for why).
|
|
32
|
+
from memyauth._config import ResolvedConfig
|
|
33
|
+
from memyauth._errors import ConfigError, ProtocolError, UnauthorizedError
|
|
34
|
+
from memyauth._roles import RoleMapper
|
|
35
|
+
from memyauth._types import IamCallerMode, IamOrgEntry, IamSession, IamTenant, IamUserContext
|
|
36
|
+
|
|
37
|
+
from ._transport import Transport
|
|
38
|
+
|
|
39
|
+
__all__ = ["IamService", "assert_iam_user_context"]
|
|
40
|
+
|
|
41
|
+
_KNOWN_TOP_LEVEL = frozenset({"user", "tenant", "organizations", "permissions"})
|
|
42
|
+
_KNOWN_USER_FIELDS = frozenset(
|
|
43
|
+
{
|
|
44
|
+
"sub", "username", "primary_email", "primary_phone", "name", "avatar", "custom_data",
|
|
45
|
+
"identities", "is_suspended", "has_password", "last_sign_in_at", "created_at",
|
|
46
|
+
"updated_at", "roles", "scopes", "organization_roles",
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
_KNOWN_TENANT_FIELDS = frozenset({"id", "code", "idp_users_sub", "key", "created_at", "updated_at"})
|
|
50
|
+
_KNOWN_ORG_ENTRY_FIELDS = frozenset({"id", "name", "roles", "tenant"})
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def assert_iam_user_context(body: Any, *, strict_unknown_fields: bool = False) -> IamUserContext:
|
|
54
|
+
"""The "permissive about unknown fields, strict about known ones" enforcement point (§C.5.1).
|
|
55
|
+
|
|
56
|
+
Unknown keys, at ANY level, are ALWAYS tolerated and preserved (the returned value IS `body`,
|
|
57
|
+
unmodified) — there is no allow-list construct that could reject a field the provider adds
|
|
58
|
+
later. `strict_unknown_fields=True` (opt-in canary, never the default) additionally checks for
|
|
59
|
+
unknown keys and raises if any are found; the default path never even computes that check.
|
|
60
|
+
"""
|
|
61
|
+
if not isinstance(body, dict):
|
|
62
|
+
raise ProtocolError("Malformed IAM /iam/me response")
|
|
63
|
+
|
|
64
|
+
user = body.get("user")
|
|
65
|
+
if not isinstance(user, dict) or not isinstance(user.get("sub"), str) or not user["sub"]:
|
|
66
|
+
raise ProtocolError("Malformed IAM /iam/me response")
|
|
67
|
+
if "roles" in user and not isinstance(user["roles"], list):
|
|
68
|
+
raise ProtocolError("Malformed IAM /iam/me response")
|
|
69
|
+
if "scopes" in user and not isinstance(user["scopes"], list):
|
|
70
|
+
raise ProtocolError("Malformed IAM /iam/me response")
|
|
71
|
+
|
|
72
|
+
tenant = body.get("tenant")
|
|
73
|
+
if not isinstance(tenant, dict) or not isinstance(tenant.get("id"), str) or not tenant["id"]:
|
|
74
|
+
raise ProtocolError("Malformed IAM /iam/me response")
|
|
75
|
+
|
|
76
|
+
organizations = body.get("organizations")
|
|
77
|
+
if organizations is not None:
|
|
78
|
+
if not isinstance(organizations, dict):
|
|
79
|
+
raise ProtocolError("Malformed IAM /iam/me response")
|
|
80
|
+
for key in ("own", "as_member"):
|
|
81
|
+
if key in organizations and not isinstance(organizations[key], list):
|
|
82
|
+
raise ProtocolError("Malformed IAM /iam/me response")
|
|
83
|
+
|
|
84
|
+
permissions = body.get("permissions")
|
|
85
|
+
if permissions is not None and not isinstance(permissions, list):
|
|
86
|
+
raise ProtocolError("Malformed IAM /iam/me response")
|
|
87
|
+
|
|
88
|
+
if strict_unknown_fields:
|
|
89
|
+
_assert_no_unknown_fields(body, user, tenant, organizations)
|
|
90
|
+
|
|
91
|
+
return body # type: ignore[return-value]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _assert_no_unknown_fields(
|
|
95
|
+
body: dict[str, Any], user: dict[str, Any], tenant: dict[str, Any], organizations: Any
|
|
96
|
+
) -> None:
|
|
97
|
+
unknown = set(body) - _KNOWN_TOP_LEVEL
|
|
98
|
+
if unknown:
|
|
99
|
+
raise ProtocolError(f"IAM /iam/me response has unknown top-level field(s): {sorted(unknown)}")
|
|
100
|
+
unknown = set(user) - _KNOWN_USER_FIELDS
|
|
101
|
+
if unknown:
|
|
102
|
+
raise ProtocolError(f"IAM /iam/me response has unknown user field(s): {sorted(unknown)}")
|
|
103
|
+
unknown = set(tenant) - _KNOWN_TENANT_FIELDS
|
|
104
|
+
if unknown:
|
|
105
|
+
raise ProtocolError(f"IAM /iam/me response has unknown tenant field(s): {sorted(unknown)}")
|
|
106
|
+
if isinstance(organizations, dict):
|
|
107
|
+
for entries in (organizations.get("own") or [], organizations.get("as_member") or []):
|
|
108
|
+
for entry in entries:
|
|
109
|
+
if isinstance(entry, dict):
|
|
110
|
+
unknown = set(entry) - _KNOWN_ORG_ENTRY_FIELDS
|
|
111
|
+
if unknown:
|
|
112
|
+
raise ProtocolError(
|
|
113
|
+
f"IAM /iam/me response has unknown organization field(s): {sorted(unknown)}"
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _project_org_entry(entry: dict[str, Any], *, strip_key: bool) -> IamOrgEntry:
|
|
118
|
+
raw_tenant = entry.get("tenant")
|
|
119
|
+
projected_tenant = None
|
|
120
|
+
if isinstance(raw_tenant, dict):
|
|
121
|
+
projected_tenant = {
|
|
122
|
+
"id": raw_tenant.get("id"),
|
|
123
|
+
"code": raw_tenant.get("code"),
|
|
124
|
+
"idp_users_sub": raw_tenant.get("idp_users_sub"),
|
|
125
|
+
}
|
|
126
|
+
key = raw_tenant.get("key")
|
|
127
|
+
# strip_key=True (as_member) => the key is NEVER copied, even when the wire sent one —
|
|
128
|
+
# the C.5.1 / tenantKeyRedaction enforcement that must hold regardless of provider intent.
|
|
129
|
+
if not strip_key and isinstance(key, str) and key:
|
|
130
|
+
projected_tenant["key"] = key
|
|
131
|
+
return {
|
|
132
|
+
"id": entry.get("id"),
|
|
133
|
+
"name": entry.get("name"),
|
|
134
|
+
"roles": list(entry.get("roles") or []),
|
|
135
|
+
"tenant": projected_tenant,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def project_session(body: IamUserContext, role_mapper: RoleMapper) -> IamSession:
|
|
140
|
+
"""Ported from `iam-client.ts:75-110`."""
|
|
141
|
+
user = body["user"] # type: ignore[index]
|
|
142
|
+
tenant = body["tenant"] # type: ignore[index]
|
|
143
|
+
organizations = body.get("organizations") or {} # type: ignore[union-attr]
|
|
144
|
+
own_raw = organizations.get("own") or []
|
|
145
|
+
as_member_raw = organizations.get("as_member") or []
|
|
146
|
+
idp_roles = list(user.get("roles") or [])
|
|
147
|
+
scopes = list(user.get("scopes") or [])
|
|
148
|
+
permissions = list(body.get("permissions") or []) # type: ignore[union-attr]
|
|
149
|
+
|
|
150
|
+
email_verified = user.get("email_verified")
|
|
151
|
+
session: IamSession = {
|
|
152
|
+
"user_id": user["sub"],
|
|
153
|
+
"idp_subject": user["sub"],
|
|
154
|
+
"email": user.get("primary_email") or "",
|
|
155
|
+
"email_verified": email_verified if isinstance(email_verified, bool) else None,
|
|
156
|
+
"tenant_id": tenant["id"],
|
|
157
|
+
"tenant_code": tenant.get("code") or "",
|
|
158
|
+
"idp_roles": idp_roles,
|
|
159
|
+
"roles": role_mapper.map_all(idp_roles),
|
|
160
|
+
"scopes": scopes,
|
|
161
|
+
"permissions": permissions,
|
|
162
|
+
"is_global_admin": role_mapper.is_global_admin(idp_roles),
|
|
163
|
+
"organizations": {
|
|
164
|
+
"own": [_project_org_entry(o, strip_key=False) for o in own_raw],
|
|
165
|
+
"as_member": [_project_org_entry(o, strip_key=True) for o in as_member_raw],
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
display_name = user.get("name")
|
|
169
|
+
if display_name:
|
|
170
|
+
session["display_name"] = display_name
|
|
171
|
+
tenant_key = tenant.get("key")
|
|
172
|
+
if isinstance(tenant_key, str) and tenant_key:
|
|
173
|
+
session["tenant_key"] = tenant_key
|
|
174
|
+
return session
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def resolve_caller_headers(config: ResolvedConfig, override_mode: Optional[IamCallerMode]) -> tuple[dict[str, str], str]:
|
|
178
|
+
"""Returns EXACTLY ONE marker header (or none, for m2m) + the caller kind (§C.5, R6)."""
|
|
179
|
+
mode = override_mode or config["iam_caller_mode"]
|
|
180
|
+
|
|
181
|
+
def appiam() -> tuple[dict[str, str], str]:
|
|
182
|
+
if config.get("iam_app_id") and config.get("iam_app_secret"):
|
|
183
|
+
return (
|
|
184
|
+
{"X-AppIam-Id": config["iam_app_id"], "Authorization": f"Bearer {config['iam_app_secret']}"},
|
|
185
|
+
"appiam",
|
|
186
|
+
)
|
|
187
|
+
raise ConfigError("IAM caller credentials are not configured")
|
|
188
|
+
|
|
189
|
+
def appidp() -> tuple[dict[str, str], str]:
|
|
190
|
+
if config.get("client_id") and config.get("client_secret"):
|
|
191
|
+
return (
|
|
192
|
+
{"X-AppIdp-Id": config["client_id"], "Authorization": f"Bearer {config['client_secret']}"},
|
|
193
|
+
"appidp",
|
|
194
|
+
)
|
|
195
|
+
raise ConfigError("IAM caller credentials are not configured")
|
|
196
|
+
|
|
197
|
+
def m2m() -> tuple[dict[str, str], str]:
|
|
198
|
+
if config.get("iam_m2m_token"):
|
|
199
|
+
return {"Authorization": f"Bearer {config['iam_m2m_token']}"}, "m2m"
|
|
200
|
+
raise ConfigError("IAM caller credentials are not configured")
|
|
201
|
+
|
|
202
|
+
if mode == "appiam":
|
|
203
|
+
return appiam()
|
|
204
|
+
if mode == "appidp":
|
|
205
|
+
return appidp()
|
|
206
|
+
if mode == "m2m":
|
|
207
|
+
return m2m()
|
|
208
|
+
|
|
209
|
+
# auto: precedence appiam -> appidp -> m2m (memybase's order, §C.5).
|
|
210
|
+
if config.get("iam_app_id") and config.get("iam_app_secret"):
|
|
211
|
+
return appiam()
|
|
212
|
+
if config.get("client_id") and config.get("client_secret"):
|
|
213
|
+
return appidp()
|
|
214
|
+
if config.get("iam_m2m_token"):
|
|
215
|
+
return m2m()
|
|
216
|
+
raise ConfigError("IAM caller credentials are not configured")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class IamService:
|
|
220
|
+
def __init__(
|
|
221
|
+
self,
|
|
222
|
+
*,
|
|
223
|
+
config: ResolvedConfig,
|
|
224
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
225
|
+
) -> None:
|
|
226
|
+
self._config = config
|
|
227
|
+
self._transport = Transport(
|
|
228
|
+
timeout_ms=config["iam_timeout_ms"],
|
|
229
|
+
max_retries=config["retry"]["max_retries"],
|
|
230
|
+
base_delay_ms=config["retry"]["base_delay_ms"],
|
|
231
|
+
retryable_statuses=config["retry"]["retryable_statuses"],
|
|
232
|
+
transport=transport,
|
|
233
|
+
)
|
|
234
|
+
self._role_mapper = RoleMapper(
|
|
235
|
+
role_map=config["role_map"],
|
|
236
|
+
role_overrides=config["role_overrides"],
|
|
237
|
+
default_role=config["default_role"],
|
|
238
|
+
global_admin_roles=config["global_admin_roles"],
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
def me_raw(
|
|
242
|
+
self,
|
|
243
|
+
user_token: str,
|
|
244
|
+
*,
|
|
245
|
+
caller_mode: Optional[IamCallerMode] = None,
|
|
246
|
+
timeout_ms: Optional[int] = None,
|
|
247
|
+
headers: Optional[dict[str, str]] = None,
|
|
248
|
+
) -> IamUserContext:
|
|
249
|
+
if not user_token:
|
|
250
|
+
raise UnauthorizedError("Missing user token")
|
|
251
|
+
auth_headers, _kind = resolve_caller_headers(self._config, caller_mode)
|
|
252
|
+
req_headers = {**auth_headers, "X-User-Token": user_token, "Accept": "application/json", **(headers or {})}
|
|
253
|
+
url = f"{self._config['iam_base_url']}/iam/me"
|
|
254
|
+
body = self._transport.request_json(
|
|
255
|
+
"GET", url, headers=req_headers, timeout_ms=timeout_ms if timeout_ms is not None else self._config["iam_timeout_ms"]
|
|
256
|
+
)
|
|
257
|
+
return assert_iam_user_context(body, strict_unknown_fields=self._config["strict_unknown_fields"])
|
|
258
|
+
|
|
259
|
+
def me(
|
|
260
|
+
self,
|
|
261
|
+
user_token: str,
|
|
262
|
+
*,
|
|
263
|
+
caller_mode: Optional[IamCallerMode] = None,
|
|
264
|
+
timeout_ms: Optional[int] = None,
|
|
265
|
+
headers: Optional[dict[str, str]] = None,
|
|
266
|
+
) -> IamSession:
|
|
267
|
+
body = self.me_raw(user_token, caller_mode=caller_mode, timeout_ms=timeout_ms, headers=headers)
|
|
268
|
+
return project_session(body, self._role_mapper)
|
|
269
|
+
|
|
270
|
+
def tenant_by_key(
|
|
271
|
+
self,
|
|
272
|
+
tenant_key: str,
|
|
273
|
+
*,
|
|
274
|
+
caller_mode: Optional[IamCallerMode] = None,
|
|
275
|
+
timeout_ms: Optional[int] = None,
|
|
276
|
+
headers: Optional[dict[str, str]] = None,
|
|
277
|
+
) -> IamTenant:
|
|
278
|
+
if not tenant_key:
|
|
279
|
+
raise UnauthorizedError("Missing tenant key")
|
|
280
|
+
auth_headers, _kind = resolve_caller_headers(self._config, caller_mode)
|
|
281
|
+
req_headers = {**auth_headers, "X-Tenant-Key": tenant_key, "Accept": "application/json", **(headers or {})}
|
|
282
|
+
url = f"{self._config['iam_base_url']}/iam/tenants/me"
|
|
283
|
+
body = self._transport.request_json(
|
|
284
|
+
"GET", url, headers=req_headers, timeout_ms=timeout_ms if timeout_ms is not None else self._config["iam_timeout_ms"]
|
|
285
|
+
)
|
|
286
|
+
if not isinstance(body, dict) or not isinstance(body.get("id"), str) or not body["id"]:
|
|
287
|
+
raise ProtocolError("Malformed IAM /iam/tenants/me response")
|
|
288
|
+
return body # type: ignore[return-value]
|
|
289
|
+
|
|
290
|
+
def health(self) -> bool:
|
|
291
|
+
"""Never throws."""
|
|
292
|
+
try:
|
|
293
|
+
url = f"{self._config['iam_base_url']}/iam/health"
|
|
294
|
+
resp = self._transport.send("GET", url)
|
|
295
|
+
return 200 <= resp.status_code < 300
|
|
296
|
+
except Exception: # noqa: BLE001 - a health probe must never raise
|
|
297
|
+
return False
|
|
298
|
+
|
|
299
|
+
def close(self) -> None:
|
|
300
|
+
self._transport.close()
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# GENERATED by scripts/gen_sync.py from memyauth/_idtoken.py — DO NOT EDIT.
|
|
2
|
+
# Regenerate: python sdk/py/scripts/gen_sync.py (CI gate: gen_sync.py --check).
|
|
3
|
+
"""
|
|
4
|
+
@fileoverview Capability C0 — ID-token trust strategies: local JWKS verification, or an explicit
|
|
5
|
+
no-verification mode for TLS-token-endpoint trust.
|
|
6
|
+
@module memyauth._idtoken
|
|
7
|
+
@description [async -> _sync/_idtoken.py twin via scripts/gen_sync.py]. BOTH trust strategies are
|
|
8
|
+
first-class and BOTH are in live production use (§C.10.1): `local-jwks` (memyui, roboson,
|
|
9
|
+
memyvet) and `tls-token-endpoint` (memybase). `mode` has no default at this layer — the caller
|
|
10
|
+
MUST pick one, mirroring `_config.py`'s `id_token_validation` (§C.16). This is the module the
|
|
11
|
+
mandatory RS256 negative test (R1) exercises: a pin that cannot verify ANY token this provider
|
|
12
|
+
issues must fail LOUDLY, and `UnsupportedAlgorithmError` must be raised on the `alg` header
|
|
13
|
+
BEFORE any key lookup — a verifier that skipped the algorithm check and just tried every key
|
|
14
|
+
would accept a genuinely-signed RS256 token, which is exactly the twice-shipped defect (D4)
|
|
15
|
+
this class exists to make impossible. Exp/nbf are checked against the INJECTED `Clock`, not
|
|
16
|
+
PyJWT's internal wall-clock read, so the conformance harness's `fixedClock` can pin determinism.
|
|
17
|
+
@dependencies pyjwt[crypto]
|
|
18
|
+
@relatedFiles _discovery.py (source of `jwks_uri` — always the internal/fetch origin, never
|
|
19
|
+
rewritten, since JWKS is a server-side endpoint per §C.1 step 6), _config.py
|
|
20
|
+
(`allowed_algorithms`/`id_token_validation`), _errors.py, tests/test_idtoken.py
|
|
21
|
+
@created 2026-09-06
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import Any, Optional
|
|
26
|
+
|
|
27
|
+
import httpx
|
|
28
|
+
import jwt
|
|
29
|
+
|
|
30
|
+
# ABSOLUTE imports for the shared/pure modules; RELATIVE for ._discovery/._transport, which DO get
|
|
31
|
+
# _sync/ twins and must bind to THEIR sibling twins (see _transport.py's header for why).
|
|
32
|
+
from memyauth._errors import ConfigError, IdTokenError, UnsupportedAlgorithmError
|
|
33
|
+
from memyauth._types import Clock, IdpTokenClaims, IdTokenValidationMode, SystemClock
|
|
34
|
+
|
|
35
|
+
from ._discovery import DiscoveryService
|
|
36
|
+
from ._transport import Transport, read_json_or_none
|
|
37
|
+
|
|
38
|
+
__all__ = ["IdTokenVerifier"]
|
|
39
|
+
|
|
40
|
+
_DEFAULT_LOCAL_JWKS_ALGORITHMS = ("ES384",)
|
|
41
|
+
_DEFAULT_CLOCK_TOLERANCE_SECONDS = 60
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class IdTokenVerifier:
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
*,
|
|
48
|
+
mode: IdTokenValidationMode,
|
|
49
|
+
allowed_algorithms: Optional[list[str]] = None,
|
|
50
|
+
discovery: DiscoveryService,
|
|
51
|
+
clock_tolerance_seconds: int = _DEFAULT_CLOCK_TOLERANCE_SECONDS,
|
|
52
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
53
|
+
clock: Optional[Clock] = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
if mode not in ("local-jwks", "tls-token-endpoint"):
|
|
56
|
+
raise ConfigError("idTokenValidation must be local-jwks or tls-token-endpoint")
|
|
57
|
+
|
|
58
|
+
resolved_algorithms = allowed_algorithms
|
|
59
|
+
if resolved_algorithms is None:
|
|
60
|
+
resolved_algorithms = list(_DEFAULT_LOCAL_JWKS_ALGORITHMS) if mode == "local-jwks" else []
|
|
61
|
+
|
|
62
|
+
if mode == "tls-token-endpoint" and resolved_algorithms:
|
|
63
|
+
raise ConfigError("allowedAlgorithms must be empty when idTokenValidation is tls-token-endpoint")
|
|
64
|
+
if mode == "local-jwks" and not resolved_algorithms:
|
|
65
|
+
raise ConfigError("allowedAlgorithms must not be empty when idTokenValidation is local-jwks")
|
|
66
|
+
|
|
67
|
+
self.mode: IdTokenValidationMode = mode
|
|
68
|
+
self._allowed_algorithms = set(resolved_algorithms)
|
|
69
|
+
self._discovery = discovery
|
|
70
|
+
self._clock_tolerance_seconds = clock_tolerance_seconds
|
|
71
|
+
self._clock: Clock = clock or SystemClock()
|
|
72
|
+
self._transport = Transport(transport=transport)
|
|
73
|
+
self._jwks_cache: dict[str, dict[str, Any]] = {}
|
|
74
|
+
|
|
75
|
+
def _refresh_jwks(self) -> None:
|
|
76
|
+
doc = self._discovery.discover()
|
|
77
|
+
resp = self._transport.send("GET", doc["jwks_uri"])
|
|
78
|
+
if not (200 <= resp.status_code < 300):
|
|
79
|
+
raise IdTokenError(f"failed to fetch JWKS: {resp.status_code}")
|
|
80
|
+
body = read_json_or_none(resp)
|
|
81
|
+
keys = (body or {}).get("keys", []) if isinstance(body, dict) else []
|
|
82
|
+
self._jwks_cache = {k["kid"]: k for k in keys if isinstance(k, dict) and "kid" in k}
|
|
83
|
+
|
|
84
|
+
def _get_key_for_kid(self, kid: str) -> dict[str, Any]:
|
|
85
|
+
if kid not in self._jwks_cache:
|
|
86
|
+
self._refresh_jwks() # the single forced refresh on an unknown kid
|
|
87
|
+
if kid not in self._jwks_cache:
|
|
88
|
+
raise IdTokenError(f"no matching JWKS key for kid={kid!r}")
|
|
89
|
+
return self._jwks_cache[kid]
|
|
90
|
+
|
|
91
|
+
def verify(
|
|
92
|
+
self,
|
|
93
|
+
id_token: str,
|
|
94
|
+
*,
|
|
95
|
+
issuer: str,
|
|
96
|
+
audience: str,
|
|
97
|
+
nonce: Optional[str] = None,
|
|
98
|
+
max_age_seconds: Optional[int] = None,
|
|
99
|
+
) -> IdpTokenClaims:
|
|
100
|
+
if self.mode == "tls-token-endpoint":
|
|
101
|
+
raise ConfigError(
|
|
102
|
+
"tls-token-endpoint mode does not verify ID tokens; identity is derived from the "
|
|
103
|
+
"token-endpoint TLS channel per OIDC Core 3.1.3.7 item 6 — use decode_unverified() "
|
|
104
|
+
"plus /oidc/me or /iam/me"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
header = jwt.get_unverified_header(id_token)
|
|
109
|
+
except jwt.PyJWTError as exc:
|
|
110
|
+
raise IdTokenError(f"malformed ID token: {exc}") from exc
|
|
111
|
+
|
|
112
|
+
alg = header.get("alg")
|
|
113
|
+
# THE R1 check: reject on `alg` BEFORE any key lookup — a verifier that tried every key
|
|
114
|
+
# regardless of alg would accept a genuinely-signed RS256 token under an ES384-only pin.
|
|
115
|
+
if alg not in self._allowed_algorithms:
|
|
116
|
+
raise UnsupportedAlgorithmError(
|
|
117
|
+
f"ID token alg {alg!r} is not in the allowed algorithms {sorted(self._allowed_algorithms)!r}"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
kid = header.get("kid")
|
|
121
|
+
if not kid:
|
|
122
|
+
raise IdTokenError("ID token is missing a kid header")
|
|
123
|
+
jwk = self._get_key_for_kid(kid)
|
|
124
|
+
key = jwt.PyJWK(jwk).key
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
claims = jwt.decode(
|
|
128
|
+
id_token,
|
|
129
|
+
key,
|
|
130
|
+
algorithms=[alg],
|
|
131
|
+
audience=audience,
|
|
132
|
+
issuer=issuer,
|
|
133
|
+
options={"verify_exp": False, "verify_nbf": False, "require": ["exp", "iat"]},
|
|
134
|
+
)
|
|
135
|
+
except jwt.PyJWTError as exc:
|
|
136
|
+
raise IdTokenError(f"ID token verification failed: {exc}") from exc
|
|
137
|
+
|
|
138
|
+
# exp/nbf checked against the INJECTED clock, not PyJWT's wall-clock read (determinism).
|
|
139
|
+
now_seconds = self._clock.now() / 1000
|
|
140
|
+
tolerance = self._clock_tolerance_seconds
|
|
141
|
+
exp = claims.get("exp")
|
|
142
|
+
if not isinstance(exp, (int, float)) or now_seconds > exp + tolerance:
|
|
143
|
+
raise IdTokenError("ID token has expired")
|
|
144
|
+
nbf = claims.get("nbf")
|
|
145
|
+
if isinstance(nbf, (int, float)) and now_seconds < nbf - tolerance:
|
|
146
|
+
raise IdTokenError("ID token is not yet valid")
|
|
147
|
+
|
|
148
|
+
if nonce is not None and claims.get("nonce") != nonce:
|
|
149
|
+
raise IdTokenError("ID token nonce mismatch")
|
|
150
|
+
|
|
151
|
+
if max_age_seconds is not None:
|
|
152
|
+
auth_time = claims.get("auth_time")
|
|
153
|
+
if not isinstance(auth_time, (int, float)) or (now_seconds - auth_time) > max_age_seconds:
|
|
154
|
+
raise IdTokenError("ID token auth_time exceeds max_age_seconds")
|
|
155
|
+
|
|
156
|
+
return claims # type: ignore[return-value]
|
|
157
|
+
|
|
158
|
+
def decode_unverified(self, id_token: str) -> IdpTokenClaims:
|
|
159
|
+
"""Decode without verifying. Available in BOTH modes; the ONLY path in
|
|
160
|
+
`tls-token-endpoint` mode."""
|
|
161
|
+
try:
|
|
162
|
+
return jwt.decode(id_token, options={"verify_signature": False}) # type: ignore[return-value]
|
|
163
|
+
except jwt.PyJWTError as exc:
|
|
164
|
+
raise IdTokenError(f"malformed ID token: {exc}") from exc
|
|
165
|
+
|
|
166
|
+
def close(self) -> None:
|
|
167
|
+
self._transport.close()
|