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/_idtoken.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@fileoverview Capability C0 — ID-token trust strategies: local JWKS verification, or an explicit
|
|
3
|
+
no-verification mode for TLS-token-endpoint trust.
|
|
4
|
+
@module memyauth._idtoken
|
|
5
|
+
@description [async -> _sync/_idtoken.py twin via scripts/gen_sync.py]. BOTH trust strategies are
|
|
6
|
+
first-class and BOTH are in live production use (§C.10.1): `local-jwks` (memyui, roboson,
|
|
7
|
+
memyvet) and `tls-token-endpoint` (memybase). `mode` has no default at this layer — the caller
|
|
8
|
+
MUST pick one, mirroring `_config.py`'s `id_token_validation` (§C.16). This is the module the
|
|
9
|
+
mandatory RS256 negative test (R1) exercises: a pin that cannot verify ANY token this provider
|
|
10
|
+
issues must fail LOUDLY, and `UnsupportedAlgorithmError` must be raised on the `alg` header
|
|
11
|
+
BEFORE any key lookup — a verifier that skipped the algorithm check and just tried every key
|
|
12
|
+
would accept a genuinely-signed RS256 token, which is exactly the twice-shipped defect (D4)
|
|
13
|
+
this class exists to make impossible. Exp/nbf are checked against the INJECTED `Clock`, not
|
|
14
|
+
PyJWT's internal wall-clock read, so the conformance harness's `fixedClock` can pin determinism.
|
|
15
|
+
@dependencies pyjwt[crypto]
|
|
16
|
+
@relatedFiles _discovery.py (source of `jwks_uri` — always the internal/fetch origin, never
|
|
17
|
+
rewritten, since JWKS is a server-side endpoint per §C.1 step 6), _config.py
|
|
18
|
+
(`allowed_algorithms`/`id_token_validation`), _errors.py, tests/test_idtoken.py
|
|
19
|
+
@created 2026-09-06
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import Any, Optional
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
import jwt
|
|
27
|
+
|
|
28
|
+
# ABSOLUTE imports for the shared/pure modules; RELATIVE for ._discovery/._transport, which DO get
|
|
29
|
+
# _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
|
+
|
|
33
|
+
from ._discovery import DiscoveryService
|
|
34
|
+
from ._transport import Transport, read_json_or_none
|
|
35
|
+
|
|
36
|
+
__all__ = ["IdTokenVerifier"]
|
|
37
|
+
|
|
38
|
+
_DEFAULT_LOCAL_JWKS_ALGORITHMS = ("ES384",)
|
|
39
|
+
_DEFAULT_CLOCK_TOLERANCE_SECONDS = 60
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class IdTokenVerifier:
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
*,
|
|
46
|
+
mode: IdTokenValidationMode,
|
|
47
|
+
allowed_algorithms: Optional[list[str]] = None,
|
|
48
|
+
discovery: DiscoveryService,
|
|
49
|
+
clock_tolerance_seconds: int = _DEFAULT_CLOCK_TOLERANCE_SECONDS,
|
|
50
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
51
|
+
clock: Optional[Clock] = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
if mode not in ("local-jwks", "tls-token-endpoint"):
|
|
54
|
+
raise ConfigError("idTokenValidation must be local-jwks or tls-token-endpoint")
|
|
55
|
+
|
|
56
|
+
resolved_algorithms = allowed_algorithms
|
|
57
|
+
if resolved_algorithms is None:
|
|
58
|
+
resolved_algorithms = list(_DEFAULT_LOCAL_JWKS_ALGORITHMS) if mode == "local-jwks" else []
|
|
59
|
+
|
|
60
|
+
if mode == "tls-token-endpoint" and resolved_algorithms:
|
|
61
|
+
raise ConfigError("allowedAlgorithms must be empty when idTokenValidation is tls-token-endpoint")
|
|
62
|
+
if mode == "local-jwks" and not resolved_algorithms:
|
|
63
|
+
raise ConfigError("allowedAlgorithms must not be empty when idTokenValidation is local-jwks")
|
|
64
|
+
|
|
65
|
+
self.mode: IdTokenValidationMode = mode
|
|
66
|
+
self._allowed_algorithms = set(resolved_algorithms)
|
|
67
|
+
self._discovery = discovery
|
|
68
|
+
self._clock_tolerance_seconds = clock_tolerance_seconds
|
|
69
|
+
self._clock: Clock = clock or SystemClock()
|
|
70
|
+
self._transport = Transport(transport=transport)
|
|
71
|
+
self._jwks_cache: dict[str, dict[str, Any]] = {}
|
|
72
|
+
|
|
73
|
+
async def _refresh_jwks(self) -> None:
|
|
74
|
+
doc = await self._discovery.discover()
|
|
75
|
+
resp = await self._transport.send("GET", doc["jwks_uri"])
|
|
76
|
+
if not (200 <= resp.status_code < 300):
|
|
77
|
+
raise IdTokenError(f"failed to fetch JWKS: {resp.status_code}")
|
|
78
|
+
body = read_json_or_none(resp)
|
|
79
|
+
keys = (body or {}).get("keys", []) if isinstance(body, dict) else []
|
|
80
|
+
self._jwks_cache = {k["kid"]: k for k in keys if isinstance(k, dict) and "kid" in k}
|
|
81
|
+
|
|
82
|
+
async def _get_key_for_kid(self, kid: str) -> dict[str, Any]:
|
|
83
|
+
if kid not in self._jwks_cache:
|
|
84
|
+
await self._refresh_jwks() # the single forced refresh on an unknown kid
|
|
85
|
+
if kid not in self._jwks_cache:
|
|
86
|
+
raise IdTokenError(f"no matching JWKS key for kid={kid!r}")
|
|
87
|
+
return self._jwks_cache[kid]
|
|
88
|
+
|
|
89
|
+
async def verify(
|
|
90
|
+
self,
|
|
91
|
+
id_token: str,
|
|
92
|
+
*,
|
|
93
|
+
issuer: str,
|
|
94
|
+
audience: str,
|
|
95
|
+
nonce: Optional[str] = None,
|
|
96
|
+
max_age_seconds: Optional[int] = None,
|
|
97
|
+
) -> IdpTokenClaims:
|
|
98
|
+
if self.mode == "tls-token-endpoint":
|
|
99
|
+
raise ConfigError(
|
|
100
|
+
"tls-token-endpoint mode does not verify ID tokens; identity is derived from the "
|
|
101
|
+
"token-endpoint TLS channel per OIDC Core 3.1.3.7 item 6 — use decode_unverified() "
|
|
102
|
+
"plus /oidc/me or /iam/me"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
header = jwt.get_unverified_header(id_token)
|
|
107
|
+
except jwt.PyJWTError as exc:
|
|
108
|
+
raise IdTokenError(f"malformed ID token: {exc}") from exc
|
|
109
|
+
|
|
110
|
+
alg = header.get("alg")
|
|
111
|
+
# THE R1 check: reject on `alg` BEFORE any key lookup — a verifier that tried every key
|
|
112
|
+
# regardless of alg would accept a genuinely-signed RS256 token under an ES384-only pin.
|
|
113
|
+
if alg not in self._allowed_algorithms:
|
|
114
|
+
raise UnsupportedAlgorithmError(
|
|
115
|
+
f"ID token alg {alg!r} is not in the allowed algorithms {sorted(self._allowed_algorithms)!r}"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
kid = header.get("kid")
|
|
119
|
+
if not kid:
|
|
120
|
+
raise IdTokenError("ID token is missing a kid header")
|
|
121
|
+
jwk = await self._get_key_for_kid(kid)
|
|
122
|
+
key = jwt.PyJWK(jwk).key
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
claims = jwt.decode(
|
|
126
|
+
id_token,
|
|
127
|
+
key,
|
|
128
|
+
algorithms=[alg],
|
|
129
|
+
audience=audience,
|
|
130
|
+
issuer=issuer,
|
|
131
|
+
options={"verify_exp": False, "verify_nbf": False, "require": ["exp", "iat"]},
|
|
132
|
+
)
|
|
133
|
+
except jwt.PyJWTError as exc:
|
|
134
|
+
raise IdTokenError(f"ID token verification failed: {exc}") from exc
|
|
135
|
+
|
|
136
|
+
# exp/nbf checked against the INJECTED clock, not PyJWT's wall-clock read (determinism).
|
|
137
|
+
now_seconds = self._clock.now() / 1000
|
|
138
|
+
tolerance = self._clock_tolerance_seconds
|
|
139
|
+
exp = claims.get("exp")
|
|
140
|
+
if not isinstance(exp, (int, float)) or now_seconds > exp + tolerance:
|
|
141
|
+
raise IdTokenError("ID token has expired")
|
|
142
|
+
nbf = claims.get("nbf")
|
|
143
|
+
if isinstance(nbf, (int, float)) and now_seconds < nbf - tolerance:
|
|
144
|
+
raise IdTokenError("ID token is not yet valid")
|
|
145
|
+
|
|
146
|
+
if nonce is not None and claims.get("nonce") != nonce:
|
|
147
|
+
raise IdTokenError("ID token nonce mismatch")
|
|
148
|
+
|
|
149
|
+
if max_age_seconds is not None:
|
|
150
|
+
auth_time = claims.get("auth_time")
|
|
151
|
+
if not isinstance(auth_time, (int, float)) or (now_seconds - auth_time) > max_age_seconds:
|
|
152
|
+
raise IdTokenError("ID token auth_time exceeds max_age_seconds")
|
|
153
|
+
|
|
154
|
+
return claims # type: ignore[return-value]
|
|
155
|
+
|
|
156
|
+
def decode_unverified(self, id_token: str) -> IdpTokenClaims:
|
|
157
|
+
"""Decode without verifying. Available in BOTH modes; the ONLY path in
|
|
158
|
+
`tls-token-endpoint` mode."""
|
|
159
|
+
try:
|
|
160
|
+
return jwt.decode(id_token, options={"verify_signature": False}) # type: ignore[return-value]
|
|
161
|
+
except jwt.PyJWTError as exc:
|
|
162
|
+
raise IdTokenError(f"malformed ID token: {exc}") from exc
|
|
163
|
+
|
|
164
|
+
async def close(self) -> None:
|
|
165
|
+
await self._transport.close()
|
memyauth/_oidc.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@fileoverview Capabilities 2, 3, 4, 9 — authorization URL, token exchange/refresh, userinfo, logout.
|
|
3
|
+
@module memyauth._oidc
|
|
4
|
+
@description [async -> _sync/_oidc.py twin via scripts/gen_sync.py]. Ported from
|
|
5
|
+
`memybase/src/auth/oidc.ts:14-20,155-159,178-225`. Every URL this service builds comes from the
|
|
6
|
+
DISCOVERY DOCUMENT, never from a guessed path convention (§C.2, D7 — memyui's guessed
|
|
7
|
+
`{issuer}/auth`). Token exchange and refresh are POSTs and are therefore NEVER retried by
|
|
8
|
+
`Transport` (§0 C13 — an authorization code is single-use); their errors are bespoke
|
|
9
|
+
(`TokenExchangeError` / `RefreshRejectedError`) rather than the generic `decode_error` mapping,
|
|
10
|
+
because the token endpoint's failure body is an OAuth `{error, error_description}` shape, not
|
|
11
|
+
the IAM surface's `{code,message,detail}` envelope.
|
|
12
|
+
@dependencies base64, urllib.parse (stdlib); ._transport, ._pkce, ._discovery
|
|
13
|
+
@relatedFiles _discovery.py (this service's sole source of every OIDC URL), _pkce.py (PKCE +
|
|
14
|
+
random helpers), _config.py (ResolvedConfig), _errors.py, tests/test_oidc.py
|
|
15
|
+
@created 2026-09-06
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import base64
|
|
20
|
+
from typing import Optional
|
|
21
|
+
from urllib.parse import urlencode
|
|
22
|
+
|
|
23
|
+
import httpx
|
|
24
|
+
|
|
25
|
+
# ABSOLUTE imports for the shared/pure modules; RELATIVE for ._discovery/._transport, which DO get
|
|
26
|
+
# _sync/ twins and must bind to THEIR sibling twins (see _transport.py's header for why).
|
|
27
|
+
from memyauth._config import ResolvedConfig
|
|
28
|
+
from memyauth._errors import ConfigError, ProtocolError, RefreshRejectedError, TokenExchangeError, UserInfoError
|
|
29
|
+
from memyauth._pkce import generate_code_challenge, generate_code_verifier, random_nonce, random_state
|
|
30
|
+
from memyauth._types import AuthorizationRequest, OidcTokenResponse, RandomSource, UserInfo
|
|
31
|
+
|
|
32
|
+
from ._discovery import DiscoveryService
|
|
33
|
+
from ._transport import Transport, read_json_or_none, truncated_text
|
|
34
|
+
|
|
35
|
+
__all__ = ["OidcService"]
|
|
36
|
+
|
|
37
|
+
_AUTH_STANDARD_KEYS = frozenset(
|
|
38
|
+
{"response_type", "client_id", "redirect_uri", "code_challenge", "code_challenge_method", "scope", "state", "nonce", "prompt"}
|
|
39
|
+
)
|
|
40
|
+
_LOGOUT_STANDARD_KEYS = frozenset({"post_logout_redirect_uri", "id_token_hint", "client_id", "state"})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class OidcService:
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
*,
|
|
47
|
+
config: ResolvedConfig,
|
|
48
|
+
discovery: DiscoveryService,
|
|
49
|
+
transport: Optional[httpx.BaseTransport] = None,
|
|
50
|
+
random: Optional[RandomSource] = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
self._config = config
|
|
53
|
+
self._discovery = discovery
|
|
54
|
+
self._random: RandomSource = random or config["random"]
|
|
55
|
+
self._transport = Transport(
|
|
56
|
+
timeout_ms=config.get("discovery_timeout_ms", 10000),
|
|
57
|
+
max_retries=config["retry"]["max_retries"],
|
|
58
|
+
base_delay_ms=config["retry"]["base_delay_ms"],
|
|
59
|
+
retryable_statuses=config["retry"]["retryable_statuses"],
|
|
60
|
+
transport=transport,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
async def build_authorization_url(
|
|
64
|
+
self,
|
|
65
|
+
*,
|
|
66
|
+
redirect_uri: Optional[str] = None,
|
|
67
|
+
scopes: Optional[list[str]] = None,
|
|
68
|
+
state: Optional[str] = None,
|
|
69
|
+
nonce: Optional[str] = None,
|
|
70
|
+
code_verifier: Optional[str] = None,
|
|
71
|
+
prompt: Optional[str] = None,
|
|
72
|
+
return_to: Optional[str] = None,
|
|
73
|
+
extra_params: Optional[dict[str, str]] = None,
|
|
74
|
+
) -> AuthorizationRequest:
|
|
75
|
+
# Validated BEFORE any network call (sdk-design.md §C.2) — mirrors js/src/oidc.ts:143,
|
|
76
|
+
# where `assertNoReservedCollision` runs ahead of `discovery.discover()`. A misconfigured
|
|
77
|
+
# caller must fail deterministically without spending a discovery round trip or generating
|
|
78
|
+
# provider traffic.
|
|
79
|
+
_assert_no_extra_param_collision(extra_params, _AUTH_STANDARD_KEYS)
|
|
80
|
+
doc = await self._discovery.discover()
|
|
81
|
+
|
|
82
|
+
effective_redirect_uri = redirect_uri or self._config["redirect_uri"]
|
|
83
|
+
if not effective_redirect_uri:
|
|
84
|
+
raise ConfigError("redirectUri or publicBaseUrl must be configured")
|
|
85
|
+
|
|
86
|
+
cv = code_verifier or generate_code_verifier(self._random)
|
|
87
|
+
challenge = generate_code_challenge(cv)
|
|
88
|
+
effective_state = state or random_state(self._random)
|
|
89
|
+
effective_nonce = nonce or random_nonce(self._random)
|
|
90
|
+
|
|
91
|
+
base_scopes = scopes if scopes is not None else self._config["scopes"]
|
|
92
|
+
scopes_supported = doc.get("scopes_supported")
|
|
93
|
+
if isinstance(scopes_supported, list) and scopes_supported:
|
|
94
|
+
negotiated = [s for s in base_scopes if s == "openid" or s in scopes_supported]
|
|
95
|
+
else:
|
|
96
|
+
negotiated = list(base_scopes)
|
|
97
|
+
scope = " ".join(negotiated)
|
|
98
|
+
|
|
99
|
+
params: dict[str, str] = {
|
|
100
|
+
"response_type": "code",
|
|
101
|
+
"client_id": self._config["client_id"],
|
|
102
|
+
"redirect_uri": effective_redirect_uri,
|
|
103
|
+
"code_challenge": challenge,
|
|
104
|
+
"code_challenge_method": "S256",
|
|
105
|
+
"scope": scope,
|
|
106
|
+
"state": effective_state,
|
|
107
|
+
"nonce": effective_nonce,
|
|
108
|
+
"prompt": prompt if prompt is not None else "consent",
|
|
109
|
+
}
|
|
110
|
+
if extra_params:
|
|
111
|
+
params.update(extra_params)
|
|
112
|
+
|
|
113
|
+
url = f"{doc['authorization_endpoint']}?{urlencode(params)}"
|
|
114
|
+
return {
|
|
115
|
+
"url": url,
|
|
116
|
+
"code_verifier": cv,
|
|
117
|
+
"code_challenge": challenge,
|
|
118
|
+
"state": effective_state,
|
|
119
|
+
"nonce": effective_nonce,
|
|
120
|
+
"redirect_uri": effective_redirect_uri,
|
|
121
|
+
"scope": scope,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
def _credential_form_fields(self) -> dict[str, str]:
|
|
125
|
+
if self._config["token_endpoint_auth_method"] == "client_secret_post":
|
|
126
|
+
return {"client_id": self._config["client_id"], "client_secret": self._config["client_secret"]}
|
|
127
|
+
return {}
|
|
128
|
+
|
|
129
|
+
def _credential_headers(self) -> dict[str, str]:
|
|
130
|
+
if self._config["token_endpoint_auth_method"] == "client_secret_basic":
|
|
131
|
+
raw = f"{self._config['client_id']}:{self._config['client_secret']}".encode("utf-8")
|
|
132
|
+
return {"Authorization": f"Basic {base64.b64encode(raw).decode('ascii')}"}
|
|
133
|
+
return {}
|
|
134
|
+
|
|
135
|
+
async def _post_token(self, body: dict[str, str]) -> httpx.Response:
|
|
136
|
+
doc = await self._discovery.discover()
|
|
137
|
+
form = {**body, **self._credential_form_fields()}
|
|
138
|
+
headers = self._credential_headers()
|
|
139
|
+
return await self._transport.send("POST", doc["token_endpoint"], form=form, headers=headers)
|
|
140
|
+
|
|
141
|
+
@staticmethod
|
|
142
|
+
def _parse_token_response(resp: httpx.Response) -> OidcTokenResponse:
|
|
143
|
+
body = read_json_or_none(resp)
|
|
144
|
+
if not isinstance(body, dict) or "token_type" not in body or "expires_in" not in body:
|
|
145
|
+
raise ProtocolError("OIDC token response is missing token_type or expires_in")
|
|
146
|
+
return body # type: ignore[return-value]
|
|
147
|
+
|
|
148
|
+
async def exchange_code(
|
|
149
|
+
self, *, code: str, code_verifier: str, redirect_uri: Optional[str] = None
|
|
150
|
+
) -> OidcTokenResponse:
|
|
151
|
+
body = {
|
|
152
|
+
"grant_type": "authorization_code",
|
|
153
|
+
"code": code,
|
|
154
|
+
"redirect_uri": redirect_uri or self._config["redirect_uri"],
|
|
155
|
+
"code_verifier": code_verifier,
|
|
156
|
+
}
|
|
157
|
+
resp = await self._post_token(body)
|
|
158
|
+
if not (200 <= resp.status_code < 300):
|
|
159
|
+
raise TokenExchangeError(
|
|
160
|
+
f"OIDC token exchange failed: {resp.status_code}", detail=truncated_text(resp)
|
|
161
|
+
)
|
|
162
|
+
return self._parse_token_response(resp)
|
|
163
|
+
|
|
164
|
+
async def refresh_tokens(self, refresh_token: str) -> OidcTokenResponse:
|
|
165
|
+
body = {"grant_type": "refresh_token", "refresh_token": refresh_token}
|
|
166
|
+
resp = await self._post_token(body)
|
|
167
|
+
if not (200 <= resp.status_code < 300):
|
|
168
|
+
raise RefreshRejectedError(
|
|
169
|
+
f"OIDC token refresh rejected: {resp.status_code}", detail=truncated_text(resp)
|
|
170
|
+
)
|
|
171
|
+
return self._parse_token_response(resp)
|
|
172
|
+
|
|
173
|
+
async def fetch_user_info(self, access_token: str) -> UserInfo:
|
|
174
|
+
doc = await self._discovery.discover()
|
|
175
|
+
resp = await self._transport.send(
|
|
176
|
+
"GET",
|
|
177
|
+
doc["userinfo_endpoint"],
|
|
178
|
+
headers={"Authorization": f"Bearer {access_token}", "Accept": "application/json"},
|
|
179
|
+
)
|
|
180
|
+
if not (200 <= resp.status_code < 300):
|
|
181
|
+
raise UserInfoError(f"OIDC userinfo failed: {resp.status_code}", detail=truncated_text(resp))
|
|
182
|
+
body = read_json_or_none(resp)
|
|
183
|
+
if not isinstance(body, dict) or not isinstance(body.get("sub"), str) or not body["sub"]:
|
|
184
|
+
raise ProtocolError("OIDC userinfo response is missing sub")
|
|
185
|
+
return body # type: ignore[return-value]
|
|
186
|
+
|
|
187
|
+
async def build_logout_url(
|
|
188
|
+
self,
|
|
189
|
+
*,
|
|
190
|
+
post_logout_redirect_uri: Optional[str] = None,
|
|
191
|
+
id_token_hint: Optional[str] = None,
|
|
192
|
+
client_id: Optional[str] = None,
|
|
193
|
+
state: Optional[str] = None,
|
|
194
|
+
extra_params: Optional[dict[str, str]] = None,
|
|
195
|
+
) -> str:
|
|
196
|
+
doc = await self._discovery.discover()
|
|
197
|
+
endpoint = doc.get("end_session_endpoint") or f"{doc['issuer'].rstrip('/')}/session/end"
|
|
198
|
+
|
|
199
|
+
# NOTE: unlike build_authorization_url, client_id is NEVER silently defaulted from config
|
|
200
|
+
# here — only an EXPLICITLY passed value is emitted. Memybase's own `buildLogoutUrl()`
|
|
201
|
+
# (the estate's shipped behavior, oidc.ts:222-225) takes no arguments and returns the bare
|
|
202
|
+
# endpoint with no query string at all; the frozen invariant "no arguments and no
|
|
203
|
+
# configured postLogoutRedirectUri -> bare endpoint, no ?" (conformance case
|
|
204
|
+
# `logout-url-bare-endpoint`) is only satisfiable if every param, client_id included, is
|
|
205
|
+
# caller-opt-in.
|
|
206
|
+
params: dict[str, str] = {}
|
|
207
|
+
effective_plr = post_logout_redirect_uri or self._config.get("post_logout_redirect_uri")
|
|
208
|
+
if effective_plr:
|
|
209
|
+
params["post_logout_redirect_uri"] = effective_plr
|
|
210
|
+
if id_token_hint:
|
|
211
|
+
params["id_token_hint"] = id_token_hint
|
|
212
|
+
if client_id:
|
|
213
|
+
params["client_id"] = client_id
|
|
214
|
+
if state:
|
|
215
|
+
params["state"] = state
|
|
216
|
+
_merge_extra_params(params, extra_params, _LOGOUT_STANDARD_KEYS)
|
|
217
|
+
|
|
218
|
+
if not params:
|
|
219
|
+
return endpoint
|
|
220
|
+
return f"{endpoint}?{urlencode(params)}"
|
|
221
|
+
|
|
222
|
+
async def close(self) -> None:
|
|
223
|
+
await self._transport.close()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _assert_no_extra_param_collision(extra: Optional[dict[str, str]], standard_keys: frozenset) -> None:
|
|
227
|
+
if not extra:
|
|
228
|
+
return
|
|
229
|
+
for key in extra:
|
|
230
|
+
if key in standard_keys:
|
|
231
|
+
raise ConfigError(f"extraParams cannot override a standard parameter: {key}")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _merge_extra_params(params: dict[str, str], extra: Optional[dict[str, str]], standard_keys: frozenset) -> None:
|
|
235
|
+
_assert_no_extra_param_collision(extra, standard_keys)
|
|
236
|
+
if extra:
|
|
237
|
+
params.update(extra)
|
memyauth/_pkce.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@fileoverview PKCE (RFC 7636) code-verifier/challenge generation and random state/nonce helpers.
|
|
3
|
+
@module memyauth._pkce
|
|
4
|
+
@description Pure, synchronous, zero I/O — ported verbatim from
|
|
5
|
+
`memybase/src/auth/oidc.ts:14-20`. PKCE is mandatory and S256-only in this estate (the
|
|
6
|
+
descriptor schema makes anything else structurally invalid — §C.2). Every random value is drawn
|
|
7
|
+
through the injected `RandomSource` seam so the conformance harness can seed determinism
|
|
8
|
+
(`fixedRandom` replaying `case.seed.randomHex`); the default seam uses `secrets.token_bytes`,
|
|
9
|
+
Python's CSPRNG.
|
|
10
|
+
@dependencies hashlib, base64, secrets (stdlib only)
|
|
11
|
+
@relatedFiles _oidc.py (build_authorization_url), _types.py (RandomSource protocol)
|
|
12
|
+
@created 2026-09-06
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import base64
|
|
17
|
+
import hashlib
|
|
18
|
+
import secrets
|
|
19
|
+
|
|
20
|
+
from ._types import RandomSource
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"SystemRandomSource",
|
|
24
|
+
"base64url_encode",
|
|
25
|
+
"generate_code_verifier",
|
|
26
|
+
"generate_code_challenge",
|
|
27
|
+
"random_state",
|
|
28
|
+
"random_nonce",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SystemRandomSource:
|
|
33
|
+
"""Default `RandomSource`: Python's CSPRNG (`secrets.token_bytes`, itself `os.urandom`)."""
|
|
34
|
+
|
|
35
|
+
def bytes(self, n: int) -> bytes:
|
|
36
|
+
return secrets.token_bytes(n)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def base64url_encode(data: bytes) -> str:
|
|
40
|
+
"""Base64url, NO padding — RFC 7636 §4.1 / RFC 4648 §5, matching the JS SDK's encoder."""
|
|
41
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def generate_code_verifier(random: RandomSource) -> str:
|
|
45
|
+
"""43-128 char verifier. 32 random bytes -> 43-char base64url string (RFC 7636 §4.1)."""
|
|
46
|
+
return base64url_encode(random.bytes(32))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def generate_code_challenge(code_verifier: str) -> str:
|
|
50
|
+
"""S256 challenge: base64url(sha256(ascii(code_verifier))). The only method this estate's
|
|
51
|
+
descriptor schema permits (`pkce.methods ⊆ ['S256']`)."""
|
|
52
|
+
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
|
|
53
|
+
return base64url_encode(digest)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def random_state(random: RandomSource) -> str:
|
|
57
|
+
return base64url_encode(random.bytes(32))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def random_nonce(random: RandomSource) -> str:
|
|
61
|
+
return base64url_encode(random.bytes(32))
|
memyauth/_roles.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""
|
|
2
|
+
@fileoverview IdP role -> consumer role mapping, and the sole global-admin vocabulary check.
|
|
3
|
+
@module memyauth._roles
|
|
4
|
+
@description Pure — no I/O, no async, no sync twin (§C.6, shared module in both surfaces). Ported
|
|
5
|
+
verbatim from `memybase/src/iam/iam-client.ts:61-72,82-89`. This is a CONSUMER concept: memyauth
|
|
6
|
+
itself has no role-mapping table. `GLOBAL_ADMIN_IDP_ROLES = ['admin']` is the single most
|
|
7
|
+
contested fact in the whole design (§C.6.1) — roboson independently invented `'global-admin'`,
|
|
8
|
+
a string that appears NOWHERE in the provider (verified by a zero-hit grep across the whole
|
|
9
|
+
memyauth tree), and its admin gate has matched nobody since. `is_global_admin` MUST test the
|
|
10
|
+
RAW idp roles, never the mapped output, and `'global-admin'` MUST NOT be a default alias — a
|
|
11
|
+
test that accepted both would be a weaker test than the one this module ships with.
|
|
12
|
+
@dependencies typing (stdlib only)
|
|
13
|
+
@relatedFiles _iam.py (constructs a RoleMapper and calls map_all/is_global_admin during
|
|
14
|
+
projection), _errors.py (ConfigError), tests/test_roles.py
|
|
15
|
+
@created 2026-09-06
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Mapping, Optional, Sequence
|
|
20
|
+
|
|
21
|
+
from ._errors import ConfigError
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"DEFAULT_ROLE_MAP",
|
|
25
|
+
"CONSUMER_ROLES",
|
|
26
|
+
"DEFAULT_CONSUMER_ROLE",
|
|
27
|
+
"GLOBAL_ADMIN_IDP_ROLES",
|
|
28
|
+
"RoleMapper",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
# Ported verbatim from memybase/src/iam/iam-client.ts:61-72 (includes the `datamanager` alias).
|
|
32
|
+
DEFAULT_ROLE_MAP: dict[str, str] = {
|
|
33
|
+
"admin": "owner",
|
|
34
|
+
"owner": "owner",
|
|
35
|
+
"member": "member",
|
|
36
|
+
"developer": "developer",
|
|
37
|
+
"data-manager": "data-manager",
|
|
38
|
+
"datamanager": "data-manager",
|
|
39
|
+
"viewer": "viewer",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
CONSUMER_ROLES: tuple[str, ...] = ("owner", "member", "developer", "data-manager", "viewer")
|
|
43
|
+
DEFAULT_CONSUMER_ROLE = "member"
|
|
44
|
+
|
|
45
|
+
# §C.6.1 RESOLVED: 'admin' is the canonical global-admin IdP role. 'global-admin' (roboson's
|
|
46
|
+
# invented string) is deliberately ABSENT and must never be added as a default alias.
|
|
47
|
+
GLOBAL_ADMIN_IDP_ROLES: tuple[str, ...] = ("admin",)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _normalize(raw: str) -> str:
|
|
51
|
+
return raw.strip().lower()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class RoleMapper:
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
*,
|
|
58
|
+
role_map: Optional[Mapping[str, str]] = None,
|
|
59
|
+
role_overrides: Optional[Mapping[str, str]] = None,
|
|
60
|
+
default_role: str = DEFAULT_CONSUMER_ROLE,
|
|
61
|
+
global_admin_roles: Sequence[str] = GLOBAL_ADMIN_IDP_ROLES,
|
|
62
|
+
passthrough_unknown: bool = False,
|
|
63
|
+
) -> None:
|
|
64
|
+
base = role_map if role_map is not None else DEFAULT_ROLE_MAP
|
|
65
|
+
self._validate_map_values("roleMap", base)
|
|
66
|
+
merged: dict[str, str] = {_normalize(k): v for k, v in base.items()}
|
|
67
|
+
if role_overrides:
|
|
68
|
+
self._validate_map_values("roleOverrides", role_overrides)
|
|
69
|
+
merged.update({_normalize(k): v for k, v in role_overrides.items()})
|
|
70
|
+
self._map = merged
|
|
71
|
+
|
|
72
|
+
if not default_role or not default_role.strip():
|
|
73
|
+
raise ConfigError("defaultRole must be a non-empty string")
|
|
74
|
+
self._default_role = default_role
|
|
75
|
+
|
|
76
|
+
admins = list(global_admin_roles)
|
|
77
|
+
if not admins or any(not a or not a.strip() for a in admins):
|
|
78
|
+
raise ConfigError("globalAdminRoles must be a non-empty array of role names")
|
|
79
|
+
self._global_admin_roles = {_normalize(a) for a in admins}
|
|
80
|
+
|
|
81
|
+
self._passthrough_unknown = passthrough_unknown
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def _validate_map_values(label: str, mapping: Mapping[str, str]) -> None:
|
|
85
|
+
for value in mapping.values():
|
|
86
|
+
if not isinstance(value, str) or not value.strip():
|
|
87
|
+
raise ConfigError(f"{label} values must be non-empty strings")
|
|
88
|
+
|
|
89
|
+
def map(self, idp_role: str) -> Optional[str]:
|
|
90
|
+
normalized = _normalize(idp_role)
|
|
91
|
+
if normalized in self._map:
|
|
92
|
+
return self._map[normalized]
|
|
93
|
+
return normalized if self._passthrough_unknown else None
|
|
94
|
+
|
|
95
|
+
def map_all(self, idp_roles: Sequence[str]) -> list[str]:
|
|
96
|
+
result: list[str] = []
|
|
97
|
+
seen: set[str] = set()
|
|
98
|
+
for raw in idp_roles:
|
|
99
|
+
mapped = self.map(raw)
|
|
100
|
+
if mapped is None:
|
|
101
|
+
continue
|
|
102
|
+
if mapped not in seen:
|
|
103
|
+
seen.add(mapped)
|
|
104
|
+
result.append(mapped)
|
|
105
|
+
return result if result else [self._default_role]
|
|
106
|
+
|
|
107
|
+
def is_global_admin(self, idp_roles: Sequence[str]) -> bool:
|
|
108
|
+
"""Tests the RAW idp roles (never the mapped output) against `global_admin_roles`."""
|
|
109
|
+
return any(_normalize(raw) in self._global_admin_roles for raw in idp_roles)
|