terp-cap-oidc 0.1.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.
@@ -0,0 +1,47 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+ build/
7
+ dist/
8
+ .venv/
9
+ .venv-*/
10
+ venv/
11
+ .pytest_cache/
12
+ .mypy_cache/
13
+ .ruff_cache/
14
+ .coverage
15
+ htmlcov/
16
+
17
+ # uv
18
+ uv.lock
19
+
20
+ # Node
21
+ node_modules/
22
+ .pnpm-store/
23
+ *.tsbuildinfo
24
+
25
+ # Playwright (conformance e2e) artifacts
26
+ test-results/
27
+ playwright-report/
28
+ blob-report/
29
+ playwright/.cache/
30
+ .last-run.json
31
+
32
+ # Local frontend template render checks
33
+ apps/example/_frontend_tpl_check/
34
+
35
+ # Editor / OS
36
+ .DS_Store
37
+ .idea/
38
+ *.local
39
+
40
+ # Local environment overrides — never commit (a real .env may hold SECRET_KEY).
41
+ # The tracked template is `.env.example`.
42
+ .env
43
+ .env.*
44
+ !.env.example
45
+ !.env.example.jinja
46
+ # Rendered app-declared variables (environment.schema.json) — may hold secrets.
47
+ .app.env
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: terp-cap-oidc
3
+ Version: 0.1.0
4
+ Summary: Terp OIDC capability — pluggable SSO via the OpenID Connect code flow with PKCE.
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.13
7
+ Requires-Dist: httpx>=0.27
8
+ Requires-Dist: pyjwt[crypto]>=2.8
9
+ Requires-Dist: terp-cap-auth==0.1.0
10
+ Requires-Dist: terp-core==0.1.0
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "terp-cap-oidc"
7
+ version = "0.1.0"
8
+ description = "Terp OIDC capability — pluggable SSO via the OpenID Connect code flow with PKCE."
9
+ requires-python = ">=3.13"
10
+ license = "Apache-2.0"
11
+ dependencies = [
12
+ "terp-core==0.1.0",
13
+ "terp-cap-auth==0.1.0",
14
+ # The token-endpoint exchange + discovery/JWKS fetches (like the webhooks delivery
15
+ # client, the outbound HTTP client lives only inside the capability).
16
+ "httpx>=0.27",
17
+ # ID-token signature validation against the provider JWKS (asymmetric algorithms
18
+ # need the crypto extra).
19
+ "pyjwt[crypto]>=2.8",
20
+ ]
21
+
22
+ # A library capability: the app builds its module explicitly (build_oidc_module) with
23
+ # its provider registry + identity seam, so there is no self-registering entry point.
24
+
25
+ # PEP 420 namespace package: this distribution owns only `terp.capabilities.oidc`.
26
+ [tool.hatch.build.targets.wheel]
27
+ sources = ["src"]
28
+ only-include = ["src/terp/capabilities/oidc"]
@@ -0,0 +1,56 @@
1
+ """terp.capabilities.oidc — pluggable SSO via the OpenID Connect code flow (ADR 0058).
2
+
3
+ An opt-in capability implementing the Authorization Code flow with PKCE against any
4
+ spec-compliant OIDC provider — no vendor tenant baked in (design §5.5). It owns the
5
+ *protocol* only: the app wires the one identity seam
6
+ (``resolve_or_provision(session, claims) -> Principal | None``, backed by the identity
7
+ capability's federated store) and the auth capability's token seams, so an SSO login
8
+ mints a normal Terp session and every existing session control (revocation, refresh,
9
+ ``/me``, ``/logout``) covers it unchanged.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from terp.capabilities.oidc.client import (
15
+ ALLOWED_ALGORITHMS,
16
+ CLOCK_SKEW_LEEWAY_SECONDS,
17
+ OIDCClient,
18
+ ProviderUnavailableError,
19
+ )
20
+ from terp.capabilities.oidc.config import OIDCClaims, OIDCProviderConfig
21
+ from terp.capabilities.oidc.router import (
22
+ IdentityResolver,
23
+ SecretResolver,
24
+ build_oidc_module,
25
+ build_oidc_router,
26
+ )
27
+ from terp.capabilities.oidc.schemas import AuthorizationRequest, OIDCCallbackRequest
28
+ from terp.capabilities.oidc.state import (
29
+ DEFAULT_STATE_TTL,
30
+ InMemoryStateStore,
31
+ OIDCStateStore,
32
+ PendingAuthorization,
33
+ code_challenge_s256,
34
+ generate_code_verifier,
35
+ )
36
+
37
+ __all__ = [
38
+ "ALLOWED_ALGORITHMS",
39
+ "AuthorizationRequest",
40
+ "CLOCK_SKEW_LEEWAY_SECONDS",
41
+ "DEFAULT_STATE_TTL",
42
+ "IdentityResolver",
43
+ "InMemoryStateStore",
44
+ "OIDCCallbackRequest",
45
+ "OIDCClaims",
46
+ "OIDCClient",
47
+ "OIDCProviderConfig",
48
+ "OIDCStateStore",
49
+ "PendingAuthorization",
50
+ "ProviderUnavailableError",
51
+ "SecretResolver",
52
+ "build_oidc_module",
53
+ "build_oidc_router",
54
+ "code_challenge_s256",
55
+ "generate_code_verifier",
56
+ ]
@@ -0,0 +1,227 @@
1
+ """The OIDC protocol client: discovery, JWKS, code exchange, ID-token validation.
2
+
3
+ One ``OIDCClient`` per configured provider. Endpoints and signing keys come from the
4
+ issuer's ``/.well-known/openid-configuration`` (fetched lazily, cached for the client's
5
+ lifetime); the JWKS is cached too and re-fetched **once** when a token names an unknown
6
+ ``kid`` (key rotation). Validation is fail-closed (ADR 0058): asymmetric signature
7
+ algorithms only (``alg=none`` / HS* are never accepted), exact ``iss`` / ``aud`` /
8
+ ``nonce`` matches, ``exp`` / ``iat`` required with bounded clock skew, and the discovery
9
+ document's ``issuer`` must equal the configured issuer (IdP mix-up defense). Every
10
+ validation failure is the uniform 401; an unreachable provider is a distinct 502 so
11
+ operators can tell an outage from an attack.
12
+
13
+ The outbound HTTP client lives only inside this capability (like the webhooks delivery
14
+ client); tests inject an ``http_factory`` returning an ``httpx.Client`` over a mock
15
+ transport.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from threading import Lock
21
+ from collections.abc import Callable
22
+ from typing import Any
23
+
24
+ import httpx
25
+ import jwt
26
+
27
+ from terp.core import AppError, AuthenticationError
28
+
29
+ from terp.capabilities.oidc.config import OIDCClaims, OIDCProviderConfig
30
+
31
+ #: Asymmetric signature algorithms accepted on an ID token. ``alg=none`` and the
32
+ #: HS* (symmetric) family are excluded by construction: with HS* the "key" would be
33
+ #: the client secret, and a leaked secret could then forge identities.
34
+ ALLOWED_ALGORITHMS: tuple[str, ...] = ("RS256", "RS384", "RS512", "PS256", "ES256", "ES384")
35
+
36
+ #: Bounded clock skew for ``exp`` / ``iat`` validation, in seconds.
37
+ CLOCK_SKEW_LEEWAY_SECONDS = 60
38
+
39
+ _DISCOVERY_PATH = "/.well-known/openid-configuration"
40
+ _HTTP_TIMEOUT_SECONDS = 10.0
41
+
42
+
43
+ class ProviderUnavailableError(AppError):
44
+ """502 — the identity provider could not be reached or answered malformed data."""
45
+
46
+ status_code = 502
47
+ code = "oidc_provider_unavailable"
48
+ default_message = "The identity provider is unavailable; please try again."
49
+
50
+
51
+ def _default_http_factory() -> httpx.Client:
52
+ return httpx.Client(timeout=_HTTP_TIMEOUT_SECONDS)
53
+
54
+
55
+ class OIDCClient:
56
+ """The protocol client for one configured provider."""
57
+
58
+ def __init__(
59
+ self,
60
+ config: OIDCProviderConfig,
61
+ *,
62
+ http_factory: Callable[[], httpx.Client] | None = None,
63
+ ) -> None:
64
+ self._config = config
65
+ self._http_factory = http_factory or _default_http_factory
66
+ self._lock = Lock()
67
+ self._discovery: dict[str, Any] | None = None
68
+ self._jwks: jwt.PyJWKSet | None = None
69
+
70
+ @property
71
+ def config(self) -> OIDCProviderConfig:
72
+ return self._config
73
+
74
+ # ------------------------------------------------------------------ #
75
+ # discovery + JWKS
76
+ # ------------------------------------------------------------------ #
77
+ def _get_json(self, url: str) -> dict[str, Any]:
78
+ """GET *url* and parse JSON; any transport / status / parse failure is a 502."""
79
+ try:
80
+ with self._http_factory() as client:
81
+ response = client.get(url)
82
+ response.raise_for_status()
83
+ payload = response.json()
84
+ except (httpx.HTTPError, ValueError) as exc:
85
+ raise ProviderUnavailableError() from exc
86
+ if not isinstance(payload, dict):
87
+ raise ProviderUnavailableError()
88
+ return payload
89
+
90
+ def discovery(self) -> dict[str, Any]:
91
+ """The provider's discovery document (fetched once, then cached)."""
92
+ with self._lock:
93
+ if self._discovery is None:
94
+ document = self._get_json(
95
+ self._config.issuer.rstrip("/") + _DISCOVERY_PATH
96
+ )
97
+ # IdP mix-up defense: the document must claim exactly the configured
98
+ # issuer, and must name the three endpoints the code flow needs.
99
+ if document.get("issuer") != self._config.issuer:
100
+ raise ProviderUnavailableError(
101
+ "The provider's discovery document does not match the "
102
+ "configured issuer."
103
+ )
104
+ for key in ("authorization_endpoint", "token_endpoint", "jwks_uri"):
105
+ if not document.get(key):
106
+ raise ProviderUnavailableError(
107
+ "The provider's discovery document is missing a "
108
+ "required endpoint."
109
+ )
110
+ self._discovery = document
111
+ return self._discovery
112
+
113
+ def _signing_key(self, token: str) -> jwt.PyJWK:
114
+ """The JWKS key for *token*'s ``kid`` — re-fetching once on rotation."""
115
+ try:
116
+ kid = jwt.get_unverified_header(token).get("kid")
117
+ except jwt.PyJWTError as exc:
118
+ raise AuthenticationError() from exc
119
+ if not kid:
120
+ raise AuthenticationError()
121
+ jwks_uri = str(self.discovery()["jwks_uri"])
122
+ with self._lock:
123
+ for refreshed in (False, True):
124
+ if self._jwks is None or refreshed:
125
+ try:
126
+ self._jwks = jwt.PyJWKSet.from_dict(self._get_json(jwks_uri))
127
+ except jwt.PyJWTError as exc:
128
+ raise ProviderUnavailableError() from exc
129
+ for key in self._jwks.keys:
130
+ if key.key_id == kid:
131
+ return key
132
+ raise AuthenticationError()
133
+
134
+ # ------------------------------------------------------------------ #
135
+ # the code flow
136
+ # ------------------------------------------------------------------ #
137
+ def authorization_url(self, *, state: str, nonce: str, code_challenge: str) -> str:
138
+ """The IdP authorize URL for one flow — code + PKCE (S256) parameters only."""
139
+ params = httpx.QueryParams(
140
+ response_type="code",
141
+ client_id=self._config.client_id,
142
+ redirect_uri=self._config.redirect_uri,
143
+ scope=" ".join(self._config.scopes),
144
+ state=state,
145
+ nonce=nonce,
146
+ code_challenge=code_challenge,
147
+ code_challenge_method="S256",
148
+ )
149
+ endpoint = str(self.discovery()["authorization_endpoint"])
150
+ separator = "&" if "?" in endpoint else "?"
151
+ return f"{endpoint}{separator}{params}"
152
+
153
+ def exchange_code(self, *, code: str, code_verifier: str, client_secret: str) -> str:
154
+ """Redeem *code* at the token endpoint; return the raw ID token.
155
+
156
+ The IdP's access / refresh tokens in the response are deliberately ignored
157
+ (ADR 0058): Terp mints its own session, so they are used zero times and never
158
+ stored or returned.
159
+ """
160
+ endpoint = str(self.discovery()["token_endpoint"])
161
+ try:
162
+ with self._http_factory() as client:
163
+ response = client.post(
164
+ endpoint,
165
+ data={
166
+ "grant_type": "authorization_code",
167
+ "code": code,
168
+ "redirect_uri": self._config.redirect_uri,
169
+ "client_id": self._config.client_id,
170
+ "client_secret": client_secret,
171
+ "code_verifier": code_verifier,
172
+ },
173
+ )
174
+ except httpx.HTTPError as exc:
175
+ raise ProviderUnavailableError() from exc
176
+ if response.status_code != 200:
177
+ # A refused exchange (bad / replayed / expired code) is an auth failure,
178
+ # not an outage — the uniform 401.
179
+ raise AuthenticationError()
180
+ try:
181
+ payload = response.json()
182
+ except ValueError as exc:
183
+ raise ProviderUnavailableError() from exc
184
+ id_token = payload.get("id_token") if isinstance(payload, dict) else None
185
+ if not isinstance(id_token, str) or not id_token:
186
+ raise AuthenticationError()
187
+ return id_token
188
+
189
+ def validate_id_token(self, raw_token: str, *, nonce: str) -> OIDCClaims:
190
+ """Fully validate *raw_token*; return the typed claims or raise the uniform 401."""
191
+ key = self._signing_key(raw_token)
192
+ try:
193
+ payload = jwt.decode(
194
+ raw_token,
195
+ key=key,
196
+ algorithms=list(ALLOWED_ALGORITHMS),
197
+ audience=self._config.client_id,
198
+ issuer=self._config.issuer,
199
+ leeway=CLOCK_SKEW_LEEWAY_SECONDS,
200
+ options={"require": ["exp", "iat", "iss", "aud", "sub"]},
201
+ )
202
+ except jwt.PyJWTError as exc:
203
+ raise AuthenticationError() from exc
204
+ if payload.get("nonce") != nonce:
205
+ # The nonce binds the token to the flow this server started; a mismatch
206
+ # is an injected / replayed token.
207
+ raise AuthenticationError()
208
+ subject = payload.get("sub")
209
+ if not isinstance(subject, str) or not subject:
210
+ raise AuthenticationError()
211
+ email = payload.get("email")
212
+ return OIDCClaims(
213
+ issuer=self._config.issuer,
214
+ subject=subject,
215
+ email=email if isinstance(email, str) and email else None,
216
+ email_verified=payload.get("email_verified") is True,
217
+ name=payload.get("name") if isinstance(payload.get("name"), str) else None,
218
+ raw=payload,
219
+ )
220
+
221
+
222
+ __all__ = [
223
+ "ALLOWED_ALGORITHMS",
224
+ "CLOCK_SKEW_LEEWAY_SECONDS",
225
+ "OIDCClient",
226
+ "ProviderUnavailableError",
227
+ ]
@@ -0,0 +1,79 @@
1
+ """Provider registry — one validated ``OIDCProviderConfig`` per named provider.
2
+
3
+ Fail-fast (ADR 0058): a config is validated at construction, so a misconfigured
4
+ provider refuses to boot instead of failing on the first login. The redirect URI is
5
+ the app's own explicit allowlisted value — it is signed into every authorize request
6
+ and echoed at the token exchange, so an attacker-supplied redirect can never enter
7
+ the flow (deny-by-default, mirroring the CORS stance). In production the issuer and
8
+ redirect URI must be ``https``; the scopes must include ``openid`` (without it the
9
+ IdP would run plain OAuth2 and return no ID token).
10
+
11
+ The ``client_secret`` may be a sealed ``enc:v1:`` value (ADR 0055); the capability
12
+ never decrypts it — see ``build_oidc_module``'s ``secret_resolver`` seam.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from dataclasses import dataclass, field
19
+
20
+ from terp.core import settings
21
+
22
+ _NAME_RE = re.compile(r"^[a-z][a-z0-9_-]*$")
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class OIDCProviderConfig:
27
+ """One OIDC provider: issuer + client credentials + the allowlisted redirect URI."""
28
+
29
+ name: str
30
+ issuer: str
31
+ client_id: str
32
+ client_secret: str
33
+ redirect_uri: str
34
+ scopes: tuple[str, ...] = ("openid", "email", "profile")
35
+
36
+ def __post_init__(self) -> None:
37
+ if not _NAME_RE.match(self.name):
38
+ raise ValueError(
39
+ f"OIDC provider name {self.name!r} must be a lowercase slug "
40
+ "(it becomes a path segment)"
41
+ )
42
+ if "openid" not in self.scopes:
43
+ raise ValueError(
44
+ f"OIDC provider {self.name!r} must request the 'openid' scope; "
45
+ "without it the IdP returns no ID token"
46
+ )
47
+ for label, url in (("issuer", self.issuer), ("redirect_uri", self.redirect_uri)):
48
+ if not url.startswith(("https://", "http://")):
49
+ raise ValueError(
50
+ f"OIDC provider {self.name!r} {label} must be an http(s) URL"
51
+ )
52
+ if settings.is_production and not url.startswith("https://"):
53
+ raise ValueError(
54
+ f"OIDC provider {self.name!r} {label} must be https in production "
55
+ "(a plaintext redirect leaks the authorization code)"
56
+ )
57
+ if not self.client_id:
58
+ raise ValueError(f"OIDC provider {self.name!r} requires a client_id")
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class OIDCClaims:
63
+ """The validated identity claims an SSO login hands to the identity seam.
64
+
65
+ Only what the ``resolve_or_provision`` seam needs: the stable ``(issuer, subject)``
66
+ pair links to a local user; the email pair gates JIT provisioning (a provisioner
67
+ must refuse an unverified email — ADR 0058). The IdP's raw tokens never leave the
68
+ capability.
69
+ """
70
+
71
+ issuer: str
72
+ subject: str
73
+ email: str | None = None
74
+ email_verified: bool = False
75
+ name: str | None = None
76
+ raw: dict[str, object] = field(default_factory=dict)
77
+
78
+
79
+ __all__ = ["OIDCClaims", "OIDCProviderConfig"]
@@ -0,0 +1,234 @@
1
+ """The SSO login router + ``ModuleSpec`` builder for the OIDC capability (ADR 0058).
2
+
3
+ Two public routes per configured provider, shaped for a SPA client:
4
+
5
+ * ``GET /{provider}/authorize`` opens a flow — generates ``state`` / ``nonce`` / PKCE
6
+ verifier into the single-use state store and returns the IdP authorize URL; and
7
+ * ``POST /{provider}/callback`` finishes it — consumes the state (single-use,
8
+ expiring), exchanges the code (PKCE verifier + client secret), fully validates the
9
+ ID token, resolves a principal through the app-wired identity seam, and mints a
10
+ normal **Terp** session (the IdP's tokens are used once and discarded).
11
+
12
+ The capability owns protocol, never users: ``resolve_or_provision(session, claims)``
13
+ is the one identity seam (the ``authenticate`` analog), app-wired to the identity
14
+ capability's federated store. Token minting reuses the auth capability's machinery
15
+ unchanged — the ``tenant_resolver`` / ``token_version_resolver`` (ADR 0031) /
16
+ ``refresh_issuer`` (ADR 0054) seams — so revocation, ``/refresh``, ``/me``, and
17
+ ``/logout`` cover an SSO session exactly as a password one.
18
+
19
+ A sealed (``enc:v1:``) client secret requires the app-wired ``secret_resolver`` (the
20
+ app's single allowlisted decrypt site, ADR 0055); a sealed secret with no resolver is
21
+ refused at construction, and the capability itself never calls ``decrypt_config``.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from collections.abc import Callable, Sequence
27
+
28
+ import httpx
29
+ from fastapi import APIRouter, Request, Response
30
+ from sqlmodel import Session
31
+
32
+ from terp.core import (
33
+ AuthenticationError,
34
+ ModuleSpec,
35
+ NotFoundError,
36
+ Policy,
37
+ Principal,
38
+ SessionDep,
39
+ client_ip,
40
+ is_sealed_config,
41
+ )
42
+
43
+ from terp.capabilities.auth import (
44
+ AccessToken,
45
+ LoginTenantResolver,
46
+ LoginThrottle,
47
+ RefreshIssuer,
48
+ TokenVersionResolver,
49
+ create_access_token,
50
+ set_refresh_cookie,
51
+ )
52
+
53
+ from terp.capabilities.oidc.client import OIDCClient
54
+ from terp.capabilities.oidc.config import OIDCClaims, OIDCProviderConfig
55
+ from terp.capabilities.oidc.schemas import AuthorizationRequest, OIDCCallbackRequest
56
+ from terp.capabilities.oidc.state import (
57
+ InMemoryStateStore,
58
+ OIDCStateStore,
59
+ code_challenge_s256,
60
+ )
61
+
62
+ # The one identity seam (the ``authenticate`` analog): validated claims in, a
63
+ # principal (or a refusal) out. App-wired so OIDC never imports where users live.
64
+ IdentityResolver = Callable[[Session, OIDCClaims], Principal | None]
65
+ # Unseal a sealed client secret — app-wired to its single allowlisted decrypt site
66
+ # (ADR 0055); the capability never decrypts.
67
+ SecretResolver = Callable[[str], str]
68
+
69
+
70
+ def _throttle_key(provider: str, request: Request) -> str:
71
+ """Per-source lockout key for the callback (the login-throttle analog).
72
+
73
+ Keys on the centrally resolved client address (``terp.core.client_ip``), so a
74
+ deployment that declared ``SecurityConfig.trusted_proxy_hops`` throttles the
75
+ real caller rather than collapsing everyone onto the proxy's IP.
76
+ """
77
+ return f"oidc:{provider}:{client_ip(request)}"
78
+
79
+
80
+ def build_oidc_router(
81
+ providers: Sequence[OIDCProviderConfig],
82
+ resolve_or_provision: IdentityResolver,
83
+ *,
84
+ tenant_resolver: LoginTenantResolver | None = None,
85
+ token_version_resolver: TokenVersionResolver | None = None,
86
+ refresh_issuer: RefreshIssuer | None = None,
87
+ throttle: LoginThrottle | None = None,
88
+ state_store: OIDCStateStore | None = None,
89
+ secret_resolver: SecretResolver | None = None,
90
+ http_factory: Callable[[], httpx.Client] | None = None,
91
+ ) -> APIRouter:
92
+ """Build the per-provider ``/authorize`` + ``/callback`` router (fail-fast).
93
+
94
+ Construction refuses an empty or name-colliding registry, and a sealed client
95
+ secret with no *secret_resolver* — a misconfigured provider fails the boot, not
96
+ the first login.
97
+ """
98
+ if not providers:
99
+ raise ValueError("build_oidc_router requires at least one OIDCProviderConfig")
100
+ registry: dict[str, OIDCProviderConfig] = {}
101
+ for config in providers:
102
+ if config.name in registry:
103
+ raise ValueError(f"duplicate OIDC provider name {config.name!r}")
104
+ if is_sealed_config(config.client_secret) and secret_resolver is None:
105
+ raise ValueError(
106
+ f"OIDC provider {config.name!r} has a sealed client_secret but no "
107
+ "secret_resolver is wired; the capability never decrypts (ADR 0055)"
108
+ )
109
+ registry[config.name] = config
110
+
111
+ clients = {
112
+ name: OIDCClient(config, http_factory=http_factory)
113
+ for name, config in registry.items()
114
+ }
115
+ store = state_store if state_store is not None else InMemoryStateStore()
116
+ active_throttle = throttle if throttle is not None else LoginThrottle()
117
+
118
+ def _client(provider: str) -> OIDCClient:
119
+ client = clients.get(provider)
120
+ if client is None:
121
+ raise NotFoundError(f"Unknown SSO provider {provider!r}.")
122
+ return client
123
+
124
+ def _client_secret(config: OIDCProviderConfig) -> str:
125
+ if is_sealed_config(config.client_secret):
126
+ assert secret_resolver is not None # noqa: S101 - enforced at construction
127
+ return secret_resolver(config.client_secret)
128
+ return config.client_secret
129
+
130
+ def _mint_access_token(session: Session, principal: Principal) -> str:
131
+ tenant = tenant_resolver(session, principal) if tenant_resolver is not None else None
132
+ token_version = (
133
+ token_version_resolver(session, principal)
134
+ if token_version_resolver is not None
135
+ else 0
136
+ )
137
+ return create_access_token(
138
+ subject=principal.id,
139
+ role=principal.role,
140
+ tenant=tenant,
141
+ token_version=token_version,
142
+ )
143
+
144
+ router = APIRouter(tags=["auth"])
145
+
146
+ @router.get("/{provider}/authorize", response_model=AuthorizationRequest)
147
+ def authorize(provider: str) -> AuthorizationRequest:
148
+ client = _client(provider)
149
+ state, pending = store.issue(provider)
150
+ return AuthorizationRequest(
151
+ provider=provider,
152
+ authorization_url=client.authorization_url(
153
+ state=state,
154
+ nonce=pending.nonce,
155
+ code_challenge=code_challenge_s256(pending.code_verifier),
156
+ ),
157
+ )
158
+
159
+ @router.post("/{provider}/callback", response_model=AccessToken)
160
+ def callback(
161
+ provider: str,
162
+ payload: OIDCCallbackRequest,
163
+ session: SessionDep,
164
+ request: Request,
165
+ response: Response,
166
+ ) -> AccessToken:
167
+ client = _client(provider)
168
+ identifier = _throttle_key(provider, request)
169
+ active_throttle.check(identifier)
170
+ pending = store.consume(payload.state, provider)
171
+ if pending is None:
172
+ # Unknown, expired, replayed, or cross-provider state — the uniform 401.
173
+ active_throttle.record_failure(identifier)
174
+ raise AuthenticationError()
175
+ id_token = client.exchange_code(
176
+ code=payload.code,
177
+ code_verifier=pending.code_verifier,
178
+ client_secret=_client_secret(client.config),
179
+ )
180
+ claims = client.validate_id_token(id_token, nonce=pending.nonce)
181
+ principal = resolve_or_provision(session, claims)
182
+ if principal is None:
183
+ active_throttle.record_failure(identifier)
184
+ raise AuthenticationError()
185
+ active_throttle.record_success(identifier)
186
+ token = _mint_access_token(session, principal)
187
+ if refresh_issuer is not None:
188
+ # The SSO session gets the same rotating refresh cookie a password login
189
+ # does (ADR 0054), so reloads and /refresh work identically.
190
+ set_refresh_cookie(response, refresh_issuer(session, principal.id))
191
+ return AccessToken(access_token=token)
192
+
193
+ return router
194
+
195
+
196
+ def build_oidc_module(
197
+ providers: Sequence[OIDCProviderConfig],
198
+ resolve_or_provision: IdentityResolver,
199
+ *,
200
+ name: str = "oidc",
201
+ tenant_resolver: LoginTenantResolver | None = None,
202
+ token_version_resolver: TokenVersionResolver | None = None,
203
+ refresh_issuer: RefreshIssuer | None = None,
204
+ throttle: LoginThrottle | None = None,
205
+ state_store: OIDCStateStore | None = None,
206
+ secret_resolver: SecretResolver | None = None,
207
+ http_factory: Callable[[], httpx.Client] | None = None,
208
+ ) -> ModuleSpec:
209
+ """Build the SSO ``ModuleSpec`` (public authorize + callback endpoints)."""
210
+ return ModuleSpec(
211
+ name=name,
212
+ router=build_oidc_router(
213
+ providers,
214
+ resolve_or_provision,
215
+ tenant_resolver=tenant_resolver,
216
+ token_version_resolver=token_version_resolver,
217
+ refresh_issuer=refresh_issuer,
218
+ throttle=throttle,
219
+ state_store=state_store,
220
+ secret_resolver=secret_resolver,
221
+ http_factory=http_factory,
222
+ ),
223
+ policy=Policy.public_write(
224
+ reason="SSO login endpoints must be reachable without a token"
225
+ ),
226
+ )
227
+
228
+
229
+ __all__ = [
230
+ "IdentityResolver",
231
+ "SecretResolver",
232
+ "build_oidc_module",
233
+ "build_oidc_router",
234
+ ]
@@ -0,0 +1,28 @@
1
+ """SSO request/response DTOs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from sqlmodel import Field
6
+
7
+ from terp.core import BaseSchema
8
+
9
+
10
+ class AuthorizationRequest(BaseSchema):
11
+ """The IdP authorize URL for one freshly-opened flow — the client navigates to it.
12
+
13
+ The binding secrets (``state`` server-side lookup key aside, the ``nonce`` and the
14
+ PKCE verifier) stay server-side in the state store; only the URL leaves.
15
+ """
16
+
17
+ provider: str
18
+ authorization_url: str
19
+
20
+
21
+ class OIDCCallbackRequest(BaseSchema):
22
+ """What the IdP appended to the redirect URI, relayed by the client."""
23
+
24
+ code: str = Field(max_length=4096)
25
+ state: str = Field(max_length=512)
26
+
27
+
28
+ __all__ = ["AuthorizationRequest", "OIDCCallbackRequest"]
@@ -0,0 +1,125 @@
1
+ """Single-use, TTL-bounded authorization state (ADR 0058).
2
+
3
+ Every ``/authorize`` issues a fresh ``state`` (the CSRF binder), ``nonce`` (bound
4
+ into the ID token), and PKCE ``code_verifier`` — all from ``secrets`` — and parks
5
+ them here until the callback presents the ``state`` back. ``consume`` is strictly
6
+ single-use (a replayed state finds nothing) and expiring (an abandoned flow ages
7
+ out), so a captured callback URL cannot be replayed and the store cannot grow
8
+ without bound. In-memory and per-process by default — an authorization flow is
9
+ short-lived, so per-instance state suffices behind a sticky or single-API setup; a
10
+ multi-instance deployment swaps in a shared :class:`OIDCStateStore` implementation
11
+ (e.g. ``terp.capabilities.redis.oidc.RedisOIDCStateStore``) so any replica can
12
+ finish a flow another replica opened.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import datetime
18
+ import hashlib
19
+ import secrets
20
+ from threading import Lock
21
+ from base64 import urlsafe_b64encode
22
+ from dataclasses import dataclass
23
+ from typing import Protocol, runtime_checkable
24
+
25
+ DEFAULT_STATE_TTL = datetime.timedelta(minutes=10)
26
+
27
+
28
+ def _utc_now() -> datetime.datetime:
29
+ """UTC ``now`` provider — private so tests can monkeypatch the clock."""
30
+ return datetime.datetime.now(datetime.UTC)
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class PendingAuthorization:
35
+ """One in-flight authorization: what the callback must match against."""
36
+
37
+ provider: str
38
+ nonce: str
39
+ code_verifier: str
40
+ expires_at: datetime.datetime
41
+
42
+
43
+ def generate_code_verifier() -> str:
44
+ """A high-entropy PKCE code verifier (RFC 7636 §4.1)."""
45
+ return secrets.token_urlsafe(64)
46
+
47
+
48
+ def code_challenge_s256(verifier: str) -> str:
49
+ """The S256 code challenge for *verifier* (RFC 7636 §4.2): base64url(sha256), unpadded."""
50
+ digest = hashlib.sha256(verifier.encode("ascii")).digest()
51
+ return urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
52
+
53
+
54
+ @runtime_checkable
55
+ class OIDCStateStore(Protocol):
56
+ """The single-use authorization-state port every state store implements.
57
+
58
+ The router only ever calls these two methods, so a deployment picks its scope by
59
+ implementation: the default :class:`InMemoryStateStore` is per-process (one API
60
+ replica or sticky routing); a shared implementation (e.g. the Redis-backed store in
61
+ ``terp-cap-redis[oidc]``) lets any replica finish a flow another replica opened.
62
+ Every implementation must keep ``consume`` strictly single-use, expiring, and
63
+ provider-matched.
64
+ """
65
+
66
+ def issue(self, provider: str) -> tuple[str, PendingAuthorization]:
67
+ """Open a new flow for *provider*: returns ``(state, pending)``."""
68
+ ...
69
+
70
+ def consume(self, state: str, provider: str) -> PendingAuthorization | None:
71
+ """Redeem *state* exactly once, or ``None`` (unknown / expired / wrong provider)."""
72
+ ...
73
+
74
+
75
+ class InMemoryStateStore:
76
+ """The default per-process single-use state store."""
77
+
78
+ def __init__(self, *, ttl: datetime.timedelta = DEFAULT_STATE_TTL) -> None:
79
+ self._ttl = ttl
80
+ self._pending: dict[str, PendingAuthorization] = {}
81
+ self._lock = Lock()
82
+
83
+ def issue(self, provider: str) -> tuple[str, PendingAuthorization]:
84
+ """Open a new flow for *provider*: returns ``(state, pending)``."""
85
+ state = secrets.token_urlsafe(32)
86
+ pending = PendingAuthorization(
87
+ provider=provider,
88
+ nonce=secrets.token_urlsafe(32),
89
+ code_verifier=generate_code_verifier(),
90
+ expires_at=_utc_now() + self._ttl,
91
+ )
92
+ with self._lock:
93
+ self._prune()
94
+ self._pending[state] = pending
95
+ return state, pending
96
+
97
+ def consume(self, state: str, provider: str) -> PendingAuthorization | None:
98
+ """Redeem *state* exactly once, or ``None`` (unknown / expired / wrong provider).
99
+
100
+ The provider match refuses a cross-provider splice: a state issued for one
101
+ provider cannot finish another provider's callback.
102
+ """
103
+ with self._lock:
104
+ pending = self._pending.pop(state, None)
105
+ if pending is None or pending.provider != provider:
106
+ return None
107
+ if pending.expires_at <= _utc_now():
108
+ return None
109
+ return pending
110
+
111
+ def _prune(self) -> None:
112
+ """Drop expired flows (called under the lock) so abandoned logins age out."""
113
+ now = _utc_now()
114
+ for key in [k for k, v in self._pending.items() if v.expires_at <= now]:
115
+ del self._pending[key]
116
+
117
+
118
+ __all__ = [
119
+ "DEFAULT_STATE_TTL",
120
+ "InMemoryStateStore",
121
+ "OIDCStateStore",
122
+ "PendingAuthorization",
123
+ "code_challenge_s256",
124
+ "generate_code_verifier",
125
+ ]