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.
- terp_cap_oidc-0.1.0/.gitignore +47 -0
- terp_cap_oidc-0.1.0/PKG-INFO +10 -0
- terp_cap_oidc-0.1.0/pyproject.toml +28 -0
- terp_cap_oidc-0.1.0/src/terp/capabilities/oidc/__init__.py +56 -0
- terp_cap_oidc-0.1.0/src/terp/capabilities/oidc/client.py +227 -0
- terp_cap_oidc-0.1.0/src/terp/capabilities/oidc/config.py +79 -0
- terp_cap_oidc-0.1.0/src/terp/capabilities/oidc/py.typed +0 -0
- terp_cap_oidc-0.1.0/src/terp/capabilities/oidc/router.py +234 -0
- terp_cap_oidc-0.1.0/src/terp/capabilities/oidc/schemas.py +28 -0
- terp_cap_oidc-0.1.0/src/terp/capabilities/oidc/state.py +125 -0
|
@@ -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"]
|
|
File without changes
|
|
@@ -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
|
+
]
|