terp-cap-auth 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,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: terp-cap-auth
3
+ Version: 0.1.0
4
+ Summary: Terp auth capability — Argon2 password hashing, JWT access tokens, and the get_principal seam.
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.13
7
+ Requires-Dist: pwdlib[argon2]>=0.2
8
+ Requires-Dist: pyjwt>=2.8
9
+ Requires-Dist: terp-core==0.1.0
@@ -0,0 +1,3 @@
1
+ {
2
+ "arch-allow-schemas-exclude-sensitive-fields": 1
3
+ }
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "terp-cap-auth"
7
+ version = "0.1.0"
8
+ description = "Terp auth capability — Argon2 password hashing, JWT access tokens, and the get_principal seam."
9
+ requires-python = ">=3.13"
10
+ license = "Apache-2.0"
11
+ dependencies = [
12
+ "terp-core==0.1.0",
13
+ "pwdlib[argon2]>=0.2",
14
+ "pyjwt>=2.8",
15
+ ]
16
+
17
+ # PEP 420 namespace package: this distribution owns only `terp.capabilities.auth`.
18
+ [tool.hatch.build.targets.wheel]
19
+ sources = ["src"]
20
+ only-include = ["src/terp/capabilities/auth"]
@@ -0,0 +1,91 @@
1
+ """terp.capabilities.auth — authentication (Argon2 hashing + JWT + ``get_principal``).
2
+
3
+ The first opt-in capability. It fills the kernel's ``get_principal`` seam with a
4
+ real Bearer-JWT provider and supplies password hashing + a login router. It is
5
+ **decoupled from the user store**: the app (or, later, the identity capability)
6
+ supplies an ``authenticate`` callback, so auth never needs to know where users
7
+ live.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from terp.capabilities.auth.deps import (
13
+ TokenValidator,
14
+ build_get_principal,
15
+ build_realtime_validator,
16
+ get_principal,
17
+ tenant_from_bearer,
18
+ )
19
+ from terp.capabilities.auth.hashing import (
20
+ hash_password,
21
+ verify_password,
22
+ verify_password_dummy,
23
+ )
24
+ from terp.capabilities.auth.refresh import (
25
+ RefreshRotation,
26
+ clear_refresh_cookie,
27
+ generate_refresh_token,
28
+ refresh_token_digest,
29
+ set_refresh_cookie,
30
+ )
31
+ from terp.capabilities.auth.router import (
32
+ Authenticator,
33
+ CurrentUserResolver,
34
+ LoginTenantResolver,
35
+ PrincipalResolver,
36
+ RefreshIssuer,
37
+ RefreshRotator,
38
+ TokenRevoker,
39
+ TokenVersionResolver,
40
+ build_login_module,
41
+ build_login_router,
42
+ build_me_module,
43
+ build_me_router,
44
+ )
45
+ from terp.capabilities.auth.schemas import AccessToken, CurrentUser, LoginRequest
46
+ from terp.capabilities.auth.throttle import AccountLockedError, LoginThrottle
47
+ from terp.capabilities.auth.tokens import (
48
+ TOKEN_AUDIENCE,
49
+ TOKEN_ISSUER,
50
+ AccessTokenClaims,
51
+ create_access_token,
52
+ decode_access_token,
53
+ )
54
+
55
+ __all__ = [
56
+ "AccessToken",
57
+ "AccessTokenClaims",
58
+ "AccountLockedError",
59
+ "Authenticator",
60
+ "CurrentUser",
61
+ "CurrentUserResolver",
62
+ "LoginRequest",
63
+ "LoginTenantResolver",
64
+ "LoginThrottle",
65
+ "PrincipalResolver",
66
+ "RefreshIssuer",
67
+ "RefreshRotation",
68
+ "RefreshRotator",
69
+ "TOKEN_AUDIENCE",
70
+ "TOKEN_ISSUER",
71
+ "TokenRevoker",
72
+ "TokenValidator",
73
+ "TokenVersionResolver",
74
+ "build_get_principal",
75
+ "build_realtime_validator",
76
+ "build_login_module",
77
+ "build_login_router",
78
+ "build_me_module",
79
+ "build_me_router",
80
+ "clear_refresh_cookie",
81
+ "create_access_token",
82
+ "decode_access_token",
83
+ "generate_refresh_token",
84
+ "get_principal",
85
+ "hash_password",
86
+ "refresh_token_digest",
87
+ "set_refresh_cookie",
88
+ "tenant_from_bearer",
89
+ "verify_password",
90
+ "verify_password_dummy",
91
+ ]
@@ -0,0 +1,156 @@
1
+ """The auth ``get_principal`` provider — turns a Bearer JWT into a ``Principal``.
2
+
3
+ This is the implementation that fills the kernel's ``get_principal`` seam: pass
4
+ it as ``create_app(..., principal_provider=get_principal)``. A missing or invalid
5
+ token yields ``None`` (unauthenticated), which the deny-by-default guard turns
6
+ into HTTP 401.
7
+
8
+ Two providers are offered:
9
+
10
+ * :func:`get_principal` — the **stateless** default: it trusts a validly-signed,
11
+ unexpired token for its whole lifetime (no store lookup). Simple and zero
12
+ per-request DB cost, at the price of the access-TTL staleness window.
13
+ * :func:`build_get_principal` — the **revocable** provider (ADR 0031): it runs an
14
+ app-supplied ``TokenValidator`` against the store every request, so a token whose
15
+ user was deactivated, demoted, re-tenanted, password-reset, or logged out is
16
+ rejected mid-session. Auth owns the *seam* and never imports the store; the app
17
+ wires the implementation (e.g. ``IdentityService(...).token_is_current``), exactly
18
+ as it wires ``authenticate`` and ``tenant_resolver``.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import uuid
24
+ from collections.abc import Callable
25
+
26
+ from fastapi import Request
27
+ from sqlmodel import Session
28
+ from starlette.requests import HTTPConnection
29
+
30
+ from terp.core import (
31
+ AuthenticationError,
32
+ Principal,
33
+ SessionDep,
34
+ mark_token_revocation_provider,
35
+ )
36
+
37
+ from terp.capabilities.auth.tokens import AccessTokenClaims, decode_access_token
38
+
39
+ _BEARER_PREFIX = "bearer "
40
+
41
+ # The app-wired validity check the revocable provider consults every request: given the
42
+ # request session and the decoded claims, return whether the token is still valid (the
43
+ # subject is active and its token epoch is current). Auth owns the type; the app supplies
44
+ # the implementation, so auth never imports the user store (symmetric with the
45
+ # ``authenticate`` / ``tenant_resolver`` seams).
46
+ TokenValidator = Callable[[Session, AccessTokenClaims], bool]
47
+
48
+
49
+ def _bearer_token(connection: HTTPConnection) -> str | None:
50
+ """Return the raw ``Authorization: Bearer`` token, or ``None`` if absent."""
51
+ header = connection.headers.get("Authorization")
52
+ if not header or not header.lower().startswith(_BEARER_PREFIX):
53
+ return None
54
+ return header[len(_BEARER_PREFIX):].strip()
55
+
56
+
57
+ def get_principal(connection: HTTPConnection) -> Principal | None:
58
+ """Extract the caller's principal from the ``Authorization: Bearer`` header.
59
+
60
+ The stateless provider: it does **no** per-request store lookup, so an already-issued
61
+ token stays valid until it expires. Use :func:`build_get_principal` for prompt
62
+ revocation (deactivate / demote / password-reset / logout taking effect mid-session).
63
+ """
64
+ token = _bearer_token(connection)
65
+ if token is None:
66
+ return None
67
+ try:
68
+ claims = decode_access_token(token)
69
+ except AuthenticationError:
70
+ return None
71
+ return Principal(id=claims.subject, role=claims.role)
72
+
73
+
74
+ def build_get_principal(
75
+ token_validator: TokenValidator | None = None,
76
+ ) -> Callable[..., Principal | None]:
77
+ """Build a ``get_principal`` seam that re-validates the token against the store.
78
+
79
+ When *token_validator* is supplied, the returned provider rejects (→ ``None`` → 401)
80
+ a token the validator fails — the runtime half of the session-revocation control
81
+ (ADR 0031). The provider depends on the request ``Session`` so the validator can do
82
+ its one indexed lookup (the guard already opens a session per guarded request, so
83
+ there is no extra cost there). The returned provider is **marked** as
84
+ revocation-enforcing (:func:`~terp.core.mark_token_revocation_provider`), so
85
+ ``create_app(require_token_revocation=True)`` accepts it and refuses the stateless
86
+ default. With no validator it behaves like :func:`get_principal` (stateless) and is
87
+ left unmarked.
88
+ """
89
+
90
+ def get_principal(
91
+ connection: HTTPConnection, session: SessionDep
92
+ ) -> Principal | None:
93
+ token = _bearer_token(connection)
94
+ if token is None:
95
+ return None
96
+ try:
97
+ claims = decode_access_token(token)
98
+ except AuthenticationError:
99
+ return None
100
+ if token_validator is not None and not token_validator(session, claims):
101
+ return None
102
+ return Principal(id=claims.subject, role=claims.role)
103
+
104
+ if token_validator is not None:
105
+ mark_token_revocation_provider(get_principal)
106
+ return get_principal
107
+
108
+
109
+ def build_realtime_validator() -> Callable[[Principal, str], bool]:
110
+ """Validate the server-retained bearer behind a realtime connection ticket.
111
+
112
+ Native transports cannot send the Authorization header after the generated
113
+ client mints their one-use ticket. The ticket retains the credential only
114
+ in server-side TTL state; this validator rechecks signature/expiry and that
115
+ its subject+role still match the captured principal. Store-backed epoch /
116
+ active-user revocation is an app concern (it owns the identity store):
117
+ compose this validator inside the realtime capability's
118
+ ``principal_validator`` with the app's own fresh-session check.
119
+ """
120
+
121
+ def validate(principal: Principal, credential: str) -> bool:
122
+ if not credential:
123
+ return False
124
+ try:
125
+ claims = decode_access_token(credential)
126
+ except AuthenticationError:
127
+ return False
128
+ return claims.subject == principal.id and claims.role == principal.role
129
+
130
+ return validate
131
+
132
+
133
+ def tenant_from_bearer(request: Request) -> uuid.UUID | None:
134
+ """Resolve the signed ``tenant`` claim from the request's Bearer token.
135
+
136
+ A ready-made resolver for ``terp.capabilities.tenancy.TenantMiddleware``: the
137
+ tenant is only as trustworthy as the token that carries it. A missing,
138
+ invalid, or tenant-less token resolves to ``None`` — scoped services then fail
139
+ closed rather than leaking another tenant's rows.
140
+ """
141
+ token = _bearer_token(request)
142
+ if token is None:
143
+ return None
144
+ try:
145
+ return decode_access_token(token).tenant
146
+ except AuthenticationError:
147
+ return None
148
+
149
+
150
+ __all__ = [
151
+ "TokenValidator",
152
+ "build_get_principal",
153
+ "build_realtime_validator",
154
+ "get_principal",
155
+ "tenant_from_bearer",
156
+ ]
@@ -0,0 +1,40 @@
1
+ """Argon2 password hashing (recommended defaults)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pwdlib import PasswordHash
6
+
7
+ _password_hash = PasswordHash.recommended()
8
+
9
+ # Lazily-built fixed hash that `verify_password_dummy` burns a verification
10
+ # against, so a refused login costs the same as a real password check.
11
+ _dummy_hash: str | None = None
12
+
13
+
14
+ def hash_password(password: str) -> str:
15
+ """Hash *password* with Argon2 (per-password salt, recommended parameters)."""
16
+ return _password_hash.hash(password)
17
+
18
+
19
+ def verify_password(password: str, password_hash: str) -> bool:
20
+ """Return ``True`` iff *password* matches *password_hash*."""
21
+ return _password_hash.verify(password, password_hash)
22
+
23
+
24
+ def verify_password_dummy() -> None:
25
+ """Burn one Argon2 verification against a fixed dummy hash (timing equalization).
26
+
27
+ An authenticate path that refuses *before* verifying — unknown email, inactive
28
+ account, SSO-only user with no local credential — must call this so the refusal
29
+ costs the same as a real password check. Without it, a login attempt against a
30
+ valid email takes ~an Argon2 verify while an invalid one returns in microseconds:
31
+ a remote timing side channel that enumerates registered accounts. The dummy hash
32
+ is built lazily on first use (never at import) and the result is discarded.
33
+ """
34
+ global _dummy_hash
35
+ if _dummy_hash is None:
36
+ _dummy_hash = _password_hash.hash("terp-timing-equalization-dummy")
37
+ _password_hash.verify("terp-timing-equalization-probe", _dummy_hash)
38
+
39
+
40
+ __all__ = ["hash_password", "verify_password", "verify_password_dummy"]
@@ -0,0 +1,88 @@
1
+ """Refresh-token mechanics for the auth capability (ADR 0054).
2
+
3
+ Auth owns the *mechanics* of the refresh credential — how a token is generated, how it is
4
+ digested for storage, and how it rides an httpOnly cookie — but **not** where it is stored:
5
+ the row lives in the identity store, reached through the app-wired refresh seams (symmetric
6
+ with ``authenticate`` / ``token_version_resolver``), so auth never imports identity.
7
+
8
+ The refresh token is opaque, 256-bit ``secrets`` randomness (not a JWT — its only meaning is
9
+ "this row is still live", which is what makes it individually revocable). It is stored as a
10
+ **keyed HMAC-SHA256 digest**: the key is derived from the app ``SECRET_KEY`` with domain
11
+ separation, so a database leak alone cannot use or even confirm a token without the app
12
+ secret, while the digest stays deterministic — one indexed lookup on ``/refresh``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import hmac
19
+ import secrets
20
+ import uuid
21
+ from dataclasses import dataclass
22
+
23
+ from fastapi import Response
24
+
25
+ from terp.core import settings
26
+
27
+ # 32 bytes = 256 bits of entropy; url-safe so it is a valid cookie value verbatim.
28
+ _TOKEN_NBYTES = 32
29
+ # Domain-separates the refresh-digest key from every other use of SECRET_KEY.
30
+ _DIGEST_CONTEXT = b"terp.refresh-token.v1"
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class RefreshRotation:
35
+ """The outcome of a successful refresh: whose session, and the new token to re-cookie."""
36
+
37
+ user_id: uuid.UUID
38
+ token: str
39
+
40
+
41
+ def generate_refresh_token() -> str:
42
+ """A fresh opaque refresh token — 256 bits of URL-safe randomness."""
43
+ return secrets.token_urlsafe(_TOKEN_NBYTES)
44
+
45
+
46
+ def refresh_token_digest(raw_token: str) -> str:
47
+ """The at-rest digest of *raw_token*: a SECRET_KEY-keyed (peppered) HMAC-SHA256 hex.
48
+
49
+ Keyed so a leaked database of digests is useless without the app secret; deterministic
50
+ so ``/refresh`` looks the row up in one indexed read. The key is derived from
51
+ ``SECRET_KEY`` (read at call time, so tests and rotation see the live value) with a
52
+ context prefix, keeping it separate from the access-token signing use of the secret.
53
+ """
54
+ key = hashlib.sha256(_DIGEST_CONTEXT + b":" + settings.SECRET_KEY.encode()).digest()
55
+ return hmac.new(key, raw_token.encode(), hashlib.sha256).hexdigest()
56
+
57
+
58
+ def set_refresh_cookie(response: Response, token: str) -> None:
59
+ """Attach the rotating refresh token as an httpOnly, path-scoped cookie (ADR 0054)."""
60
+ response.set_cookie(
61
+ key=settings.REFRESH_COOKIE_NAME,
62
+ value=token,
63
+ max_age=settings.REFRESH_FAMILY_TTL_SECONDS,
64
+ path=settings.REFRESH_COOKIE_PATH,
65
+ httponly=True,
66
+ secure=settings.refresh_cookie_secure,
67
+ samesite=settings.REFRESH_COOKIE_SAMESITE,
68
+ )
69
+
70
+
71
+ def clear_refresh_cookie(response: Response) -> None:
72
+ """Delete the refresh cookie (logout) — matched on name + path so the browser drops it."""
73
+ response.delete_cookie(
74
+ key=settings.REFRESH_COOKIE_NAME,
75
+ path=settings.REFRESH_COOKIE_PATH,
76
+ httponly=True,
77
+ secure=settings.refresh_cookie_secure,
78
+ samesite=settings.REFRESH_COOKIE_SAMESITE,
79
+ )
80
+
81
+
82
+ __all__ = [
83
+ "RefreshRotation",
84
+ "clear_refresh_cookie",
85
+ "generate_refresh_token",
86
+ "refresh_token_digest",
87
+ "set_refresh_cookie",
88
+ ]
@@ -0,0 +1,311 @@
1
+ """Login + logout router + ``ModuleSpec`` builder for the auth capability.
2
+
3
+ The capability does **not** own a user store (the identity capability does). The
4
+ app supplies an ``authenticate(session, email, password) -> Principal | None``
5
+ callback; auth only checks that the credential resolves to a principal and then issues
6
+ a token. The login route is mounted with ``Policy.public`` so it is reachable without a
7
+ token; the optional logout route revokes the caller's sessions through an app-supplied
8
+ seam (auth does not own the store it must write).
9
+
10
+ Session-management seams (ADR 0031), all optional and app-wired so auth never imports
11
+ the store:
12
+
13
+ * ``token_version_resolver`` signs the subject's **current token epoch** into the issued
14
+ token, so a freshly-minted token is valid against the store (without it, the first
15
+ token issued after any revoking change would be instantly stale);
16
+ * ``revoke_sessions`` bumps the caller's epoch on ``POST /logout`` (mounted only when
17
+ wired); and
18
+ * ``throttle`` is the per-account login lockout (on by default, ADR 0031 / L3).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import uuid
24
+ from collections.abc import Callable
25
+
26
+ from fastapi import APIRouter, Depends, Request, Response
27
+ from sqlmodel import Session
28
+
29
+ from terp.core import (
30
+ AuthenticationError,
31
+ ModuleSpec,
32
+ Policy,
33
+ Principal,
34
+ SessionDep,
35
+ get_principal,
36
+ settings,
37
+ )
38
+
39
+ from terp.capabilities.auth.refresh import (
40
+ RefreshRotation,
41
+ clear_refresh_cookie,
42
+ set_refresh_cookie,
43
+ )
44
+ from terp.capabilities.auth.schemas import AccessToken, CurrentUser, LoginRequest
45
+ from terp.capabilities.auth.throttle import LoginThrottle
46
+ from terp.capabilities.auth.tokens import create_access_token
47
+
48
+ Authenticator = Callable[[Session, str, str], Principal | None]
49
+ LoginTenantResolver = Callable[[Session, Principal], uuid.UUID | None]
50
+ # Resolve the subject's current token epoch so login signs it (see module docstring).
51
+ TokenVersionResolver = Callable[[Session, Principal], int]
52
+ # Revoke (invalidate) a subject's outstanding tokens — the logout write-back, app-wired
53
+ # to the store (e.g. ``UsersService.revoke_sessions``) since auth does not own it.
54
+ TokenRevoker = Callable[[Session, uuid.UUID], None]
55
+ # Refresh-token seams (ADR 0054), app-wired to the identity refresh store so auth never
56
+ # imports it. Issue the first token at login; rotate (single-use + reuse-detect) a presented
57
+ # token; resolve an ACTIVE subject's principal by id (the /refresh analog of authenticate,
58
+ # which needs a password). All three are wired together or not at all.
59
+ RefreshIssuer = Callable[[Session, uuid.UUID], str]
60
+ RefreshRotator = Callable[[Session, str], RefreshRotation | None]
61
+ PrincipalResolver = Callable[[Session, uuid.UUID], Principal | None]
62
+ # Resolve the authenticated caller's own identity for ``GET /me`` — app-wired to the
63
+ # store (e.g. ``IdentityService.current_user``) so auth never imports where users live
64
+ # (symmetric with the authenticate / tenant_resolver / revoke_sessions seams).
65
+ CurrentUserResolver = Callable[[Session, Principal], CurrentUser]
66
+
67
+
68
+ def build_login_router(
69
+ authenticate: Authenticator,
70
+ *,
71
+ tenant_resolver: LoginTenantResolver | None = None,
72
+ token_version_resolver: TokenVersionResolver | None = None,
73
+ revoke_sessions: TokenRevoker | None = None,
74
+ throttle: LoginThrottle | None = None,
75
+ refresh_issuer: RefreshIssuer | None = None,
76
+ refresh_rotator: RefreshRotator | None = None,
77
+ principal_resolver: PrincipalResolver | None = None,
78
+ require_refresh: bool = False,
79
+ ) -> APIRouter:
80
+ """Build a ``/login`` (+ optional ``/logout`` / ``/refresh``) router via *authenticate*.
81
+
82
+ When *tenant_resolver* is supplied, the authenticated principal is mapped to a
83
+ tenant and that tenant is signed into the token's ``tenant`` claim — so a
84
+ multi-tenant app issues a tenant-bound token through this one seam (symmetric with
85
+ ``TenantMiddleware``'s ``resolve_tenant``), without the auth capability needing to
86
+ know how a tenant is stored. *token_version_resolver* likewise signs the subject's
87
+ current token epoch (ADR 0031). *throttle* (default: a fresh on-by-default
88
+ :class:`LoginThrottle`) locks an account after repeated failed logins. When
89
+ *revoke_sessions* is supplied, a ``POST /logout`` route is mounted that bumps the
90
+ caller's epoch (idempotent; an unauthenticated call is a no-op).
91
+
92
+ Refresh-token rotation (ADR 0054) is opt-in and **all-or-nothing**: supply
93
+ *refresh_issuer* (open a family + mint the first token at login), *refresh_rotator*
94
+ (validate + single-use rotate + reuse-detect), and *principal_resolver* (resolve an
95
+ active subject by id) together — plus *revoke_sessions*, so ``/logout`` revokes the
96
+ family server-side and not just the cookie — to mount ``POST /refresh`` and have
97
+ ``/login`` / ``/logout`` set / clear the httpOnly refresh cookie. Wiring only some of
98
+ the seams, omitting *revoke_sessions*, or passing *require_refresh* without them,
99
+ raises at construction (fail-closed).
100
+ """
101
+ router = APIRouter(tags=["auth"])
102
+ active_throttle = throttle if throttle is not None else LoginThrottle()
103
+
104
+ # Refresh rotation is all-or-nothing: a token issued at login that nothing can rotate
105
+ # (or vice versa) is a fail-closed misconfiguration caught here, at construction.
106
+ refresh_enabled = (
107
+ refresh_issuer is not None
108
+ and refresh_rotator is not None
109
+ and principal_resolver is not None
110
+ )
111
+ any_refresh_seam = (
112
+ refresh_issuer is not None
113
+ or refresh_rotator is not None
114
+ or principal_resolver is not None
115
+ )
116
+ if any_refresh_seam and not refresh_enabled:
117
+ raise ValueError(
118
+ "refresh-token rotation is half-wired: refresh_issuer, refresh_rotator, and "
119
+ "principal_resolver must be supplied together (ADR 0054)."
120
+ )
121
+ if refresh_enabled and revoke_sessions is None:
122
+ # Without a server-side revoker, /logout could only drop the browser cookie while
123
+ # every token in the family stayed live in the store — a logout that does not log
124
+ # out. Refuse the half-secure shape at construction (fail-closed).
125
+ raise ValueError(
126
+ "refresh-token rotation requires revoke_sessions: without it a logout clears "
127
+ "the cookie but leaves the refresh-token family live server-side (ADR 0054)."
128
+ )
129
+ if require_refresh and not refresh_enabled:
130
+ raise ValueError(
131
+ "require_refresh=True but the refresh seams are not wired (ADR 0054)."
132
+ )
133
+
134
+ def _mint_access_token(session: Session, principal: Principal) -> str:
135
+ tenant = tenant_resolver(session, principal) if tenant_resolver is not None else None
136
+ token_version = (
137
+ token_version_resolver(session, principal)
138
+ if token_version_resolver is not None
139
+ else 0
140
+ )
141
+ return create_access_token(
142
+ subject=principal.id,
143
+ role=principal.role,
144
+ tenant=tenant,
145
+ token_version=token_version,
146
+ )
147
+
148
+ @router.post("/login", response_model=AccessToken)
149
+ def login(
150
+ credentials: LoginRequest, session: SessionDep, response: Response
151
+ ) -> AccessToken:
152
+ active_throttle.check(credentials.email)
153
+ principal = authenticate(session, credentials.email, credentials.password)
154
+ if principal is None:
155
+ active_throttle.record_failure(credentials.email)
156
+ raise AuthenticationError()
157
+ active_throttle.record_success(credentials.email)
158
+ token = _mint_access_token(session, principal)
159
+ if refresh_issuer is not None:
160
+ # Open a fresh refresh-token family and set its httpOnly cookie beside the
161
+ # bearer, so the session survives a reload and can outlive the access TTL.
162
+ set_refresh_cookie(response, refresh_issuer(session, principal.id))
163
+ return AccessToken(access_token=token)
164
+
165
+ if refresh_rotator is not None and principal_resolver is not None:
166
+ rotate_token = refresh_rotator
167
+ resolve_principal = principal_resolver
168
+
169
+ @router.post("/refresh", response_model=AccessToken)
170
+ def refresh(
171
+ request: Request, session: SessionDep, response: Response
172
+ ) -> AccessToken:
173
+ # The refresh cookie is the credential here (no bearer): rotate it (single-use +
174
+ # reuse-detection), re-check the subject is still active, then mint a fresh
175
+ # access token and set the rotated cookie. Any failure is a clean 401.
176
+ raw = request.cookies.get(settings.REFRESH_COOKIE_NAME)
177
+ if raw is None:
178
+ raise AuthenticationError()
179
+ rotation = rotate_token(session, raw)
180
+ if rotation is None:
181
+ raise AuthenticationError()
182
+ principal = resolve_principal(session, rotation.user_id)
183
+ if principal is None:
184
+ raise AuthenticationError()
185
+ token = _mint_access_token(session, principal)
186
+ set_refresh_cookie(response, rotation.token)
187
+ return AccessToken(access_token=token)
188
+
189
+ if revoke_sessions is not None or refresh_enabled:
190
+
191
+ @router.post("/logout", status_code=204)
192
+ def logout(
193
+ session: SessionDep,
194
+ principal: Principal | None = Depends(get_principal),
195
+ ) -> Response:
196
+ # Logout is idempotent: with no (or an already-revoked) token there is nothing
197
+ # to invalidate. Otherwise bump the caller's epoch (the wired revoke also kills
198
+ # the refresh families), and always drop the refresh cookie.
199
+ if principal is not None and revoke_sessions is not None:
200
+ revoke_sessions(session, principal.id)
201
+ response = Response(status_code=204)
202
+ if refresh_enabled:
203
+ clear_refresh_cookie(response)
204
+ return response
205
+
206
+ return router
207
+
208
+
209
+ def build_login_module(
210
+ authenticate: Authenticator,
211
+ *,
212
+ name: str = "auth",
213
+ tenant_resolver: LoginTenantResolver | None = None,
214
+ token_version_resolver: TokenVersionResolver | None = None,
215
+ revoke_sessions: TokenRevoker | None = None,
216
+ throttle: LoginThrottle | None = None,
217
+ refresh_issuer: RefreshIssuer | None = None,
218
+ refresh_rotator: RefreshRotator | None = None,
219
+ principal_resolver: PrincipalResolver | None = None,
220
+ require_refresh: bool = False,
221
+ ) -> ModuleSpec:
222
+ """Build the auth ``ModuleSpec`` (public login + optional logout / refresh endpoints).
223
+
224
+ When the refresh seams are wired, the configured refresh-cookie path must match where
225
+ this module is mounted (``/api/v1/<name>``) — a path-scoped cookie the browser never
226
+ sends to ``/refresh`` would make refresh silently never work, so the mismatch is
227
+ refused here, at construction (fail-closed).
228
+ """
229
+ refresh_enabled = (
230
+ refresh_issuer is not None
231
+ and refresh_rotator is not None
232
+ and principal_resolver is not None
233
+ )
234
+ mount_prefix = f"/api/v1/{name}"
235
+ if refresh_enabled and settings.REFRESH_COOKIE_PATH != mount_prefix:
236
+ raise ValueError(
237
+ f"REFRESH_COOKIE_PATH ({settings.REFRESH_COOKIE_PATH!r}) does not match this "
238
+ f"module's mount prefix ({mount_prefix!r}); the browser would never send the "
239
+ "refresh cookie to /refresh (ADR 0054)."
240
+ )
241
+ return ModuleSpec(
242
+ name=name,
243
+ router=build_login_router(
244
+ authenticate,
245
+ tenant_resolver=tenant_resolver,
246
+ token_version_resolver=token_version_resolver,
247
+ revoke_sessions=revoke_sessions,
248
+ throttle=throttle,
249
+ refresh_issuer=refresh_issuer,
250
+ refresh_rotator=refresh_rotator,
251
+ principal_resolver=principal_resolver,
252
+ require_refresh=require_refresh,
253
+ ),
254
+ policy=Policy.public_write(
255
+ reason="authentication endpoints must be reachable without a token"
256
+ ),
257
+ )
258
+
259
+
260
+ def build_me_router(resolve_current_user: CurrentUserResolver) -> APIRouter:
261
+ """Build the ``GET /me`` (who-am-I) router via the *resolve_current_user* seam.
262
+
263
+ The route is **self-scoped**: it reports only the authenticated caller's own identity
264
+ (read from ``principal.id``), so it takes no id parameter and cannot be turned into a
265
+ read of another subject. Mounted behind ``Policy.default()`` (any authenticated
266
+ caller), it answers through the wired principal provider — the revocable one in the
267
+ bundled stack — so a deactivated / demoted / re-tenanted token is already rejected and
268
+ the response reflects the live record rather than stale token claims.
269
+ """
270
+ router = APIRouter(tags=["auth"])
271
+
272
+ @router.get("/", response_model=CurrentUser)
273
+ def me(
274
+ session: SessionDep,
275
+ principal: Principal | None = Depends(get_principal),
276
+ ) -> CurrentUser:
277
+ # The module guard authorizes against Policy.default() (rejecting anonymous)
278
+ # before this handler runs; the explicit check keeps the router correct — a clean
279
+ # 401, never an AttributeError — even if it were ever mounted without that guard.
280
+ if principal is None:
281
+ raise AuthenticationError()
282
+ return resolve_current_user(session, principal)
283
+
284
+ return router
285
+
286
+
287
+ def build_me_module(
288
+ resolve_current_user: CurrentUserResolver, *, name: str = "me"
289
+ ) -> ModuleSpec:
290
+ """Build the who-am-I ``ModuleSpec`` (``GET /api/v1/me``, any authenticated caller)."""
291
+ return ModuleSpec(
292
+ name=name,
293
+ router=build_me_router(resolve_current_user),
294
+ policy=Policy.default(),
295
+ )
296
+
297
+
298
+ __all__ = [
299
+ "Authenticator",
300
+ "CurrentUserResolver",
301
+ "LoginTenantResolver",
302
+ "PrincipalResolver",
303
+ "RefreshIssuer",
304
+ "RefreshRotator",
305
+ "TokenRevoker",
306
+ "TokenVersionResolver",
307
+ "build_login_module",
308
+ "build_login_router",
309
+ "build_me_module",
310
+ "build_me_router",
311
+ ]
@@ -0,0 +1,38 @@
1
+ """Auth request/response DTOs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+
7
+ from sqlmodel import Field
8
+
9
+ from terp.core import BaseSchema
10
+
11
+
12
+ class LoginRequest(BaseSchema):
13
+ email: str = Field(max_length=320)
14
+ password: str = Field(max_length=256)
15
+
16
+
17
+ class AccessToken(BaseSchema):
18
+ access_token: str # arch-allow-schemas-exclude-sensitive-fields: the bearer token the login endpoint exists to mint
19
+ token_type: str = "bearer"
20
+
21
+
22
+ class CurrentUser(BaseSchema):
23
+ """The authenticated caller's own identity — the ``/me`` (who-am-I) response.
24
+
25
+ The frontend's session contract pairs this with :class:`AccessToken`. It is the
26
+ server-validated current identity (resolved through the wired principal provider —
27
+ the revocable one in the bundled stack), so it reflects the live store, not just the
28
+ token's claims. The caller's role is on the wire as both the numeric ``role_rank``
29
+ (the comparable primitive, ADR 0004 / 0022) and a human-readable ``role_name``.
30
+ """
31
+
32
+ id: uuid.UUID
33
+ email: str
34
+ role_rank: int
35
+ role_name: str
36
+
37
+
38
+ __all__ = ["AccessToken", "CurrentUser", "LoginRequest"]
@@ -0,0 +1,132 @@
1
+ """Per-account login lockout — a fail-closed brute-force throttle (ADR 0031, L3).
2
+
3
+ A login-specific complement to the generic rate limiter: it counts *failed* logins per
4
+ account and locks the account for a window once a threshold is crossed, so credential
5
+ stuffing against a single user is throttled even when the generic rate limit (which
6
+ counts all requests from all callers) would not bite.
7
+
8
+ The lockout state lives in a pluggable :class:`~terp.core.ThrottleStore` (ADR 0036):
9
+ the default :class:`~terp.core.InMemoryThrottleStore` is per app instance / in process —
10
+ unchanged behaviour — while a multi-instance deployment passes the same shared store the
11
+ rate limiter uses, so the lockout is correct across workers. A store error fails closed
12
+ (the account is treated as locked). It is on by default; an app turns it off only with
13
+ an explicit, reason-bearing :meth:`LoginThrottle.disabled`.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import datetime
19
+ import logging
20
+
21
+ from terp.core import AppError, InMemoryThrottleStore, ThrottleStore
22
+
23
+ _logger = logging.getLogger("terp.capabilities.auth.throttle")
24
+
25
+
26
+ class AccountLockedError(AppError):
27
+ """429 — too many failed login attempts; the account is temporarily locked."""
28
+
29
+ status_code = 429
30
+ code = "account_locked"
31
+ default_message = (
32
+ "Too many failed login attempts. This account is temporarily locked; "
33
+ "please wait and try again."
34
+ )
35
+
36
+
37
+ def _utc_now() -> datetime.datetime:
38
+ """UTC ``now`` provider — kept private so tests can monkeypatch the clock."""
39
+ return datetime.datetime.now(datetime.UTC)
40
+
41
+
42
+ class LoginThrottle:
43
+ """Per-account failed-login lockout over a pluggable :class:`ThrottleStore`.
44
+
45
+ After *max_attempts* failed logins for one identifier within *window*, the
46
+ identifier is locked for *lockout*; while locked, even a correct credential is
47
+ refused (the login route calls :meth:`check` *before* verifying the password). A
48
+ successful login clears the counter. State is keyed by a normalized identifier
49
+ (trimmed + lower-cased) so case variants of an email cannot dodge the count. With no
50
+ *store* the default per-instance in-memory store is used (clocked off :func:`_utc_now`
51
+ so tests can drive it); a multi-instance app passes a shared store for one correct
52
+ global counter.
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ *,
58
+ max_attempts: int = 5,
59
+ window: datetime.timedelta = datetime.timedelta(minutes=15),
60
+ lockout: datetime.timedelta = datetime.timedelta(minutes=15),
61
+ store: ThrottleStore | None = None,
62
+ ) -> None:
63
+ if max_attempts < 1:
64
+ raise ValueError("LoginThrottle.max_attempts must be >= 1")
65
+ self.enabled = True
66
+ self.disabled_reason = ""
67
+ self._max_attempts = max_attempts
68
+ self._window = int(window.total_seconds())
69
+ self._lockout = int(lockout.total_seconds())
70
+ self._store = store if store is not None else InMemoryThrottleStore(
71
+ clock=lambda: _utc_now().timestamp()
72
+ )
73
+
74
+ @classmethod
75
+ def disabled(cls, *, reason: str) -> LoginThrottle:
76
+ """An explicitly-disabled throttle (no lockout). *reason* is required (fail-closed).
77
+
78
+ Mirrors ``CorsPolicy.disabled`` / ``AuditPolicy.disabled``: turning a
79
+ secure-by-default control off is a visible, justified act, never a silent
80
+ omission, so an empty reason is refused.
81
+ """
82
+ if not reason.strip():
83
+ raise ValueError("LoginThrottle.disabled requires a non-empty reason")
84
+ throttle = cls()
85
+ throttle.enabled = False
86
+ throttle.disabled_reason = reason
87
+ return throttle
88
+
89
+ def check(self, identifier: str) -> None:
90
+ """Raise :class:`AccountLockedError` if *identifier* is currently locked."""
91
+ if not self.enabled:
92
+ return
93
+ try:
94
+ locked = self._store.locked(self._key(identifier)) > 0
95
+ except Exception as exc: # a shared store outage fails closed
96
+ raise AccountLockedError() from exc
97
+ if locked:
98
+ raise AccountLockedError()
99
+
100
+ def record_failure(self, identifier: str) -> None:
101
+ """Record a failed attempt; lock *identifier* once the threshold is reached."""
102
+ if not self.enabled:
103
+ return
104
+ key = self._key(identifier)
105
+ try:
106
+ count, _ = self._store.hit(key, self._window)
107
+ if count >= self._max_attempts:
108
+ self._store.lock(key, self._lockout)
109
+ except Exception as exc: # a shared store outage fails closed
110
+ raise AccountLockedError() from exc
111
+
112
+ def record_success(self, identifier: str) -> None:
113
+ """Clear *identifier*'s failure state after a successful login."""
114
+ if not self.enabled:
115
+ return
116
+ try:
117
+ self._store.clear(self._key(identifier))
118
+ except Exception as exc: # best-effort cleanup: never block an already-valid login
119
+ _logger.warning("login_throttle_clear_failed", exc_info=exc)
120
+
121
+ def reset(self) -> None:
122
+ """Clear all tracked state (a test seam; per-instance state otherwise persists)."""
123
+ reset = getattr(self._store, "reset", None)
124
+ if callable(reset):
125
+ reset()
126
+
127
+ @staticmethod
128
+ def _key(identifier: str) -> str:
129
+ return f"lt:{identifier.strip().lower()}"
130
+
131
+
132
+ __all__ = ["AccountLockedError", "LoginThrottle"]
@@ -0,0 +1,139 @@
1
+ """Signed JWT access tokens, keyed on the kernel ``SECRET_KEY``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ import uuid
7
+ from dataclasses import dataclass
8
+ from typing import Final
9
+
10
+ import jwt
11
+
12
+ from terp.core import AuthenticationError, Role, Roles, as_role, settings
13
+
14
+ _ALGORITHM = "HS256"
15
+ DEFAULT_ACCESS_TOKEN_TTL = datetime.timedelta(minutes=15)
16
+
17
+ #: The ``iss`` / ``aud`` claims every Terp access token carries and every decode
18
+ #: **requires** (ADR 0076): a JWT minted by any other issuer for any other audience
19
+ #: — even one signed with the same shared secret (another service reusing the key,
20
+ #: an OIDC provider, a future token type) — is refused, never confused for an
21
+ #: access credential.
22
+ TOKEN_ISSUER: Final[str] = "terp.auth"
23
+ TOKEN_AUDIENCE: Final[str] = "terp.api"
24
+
25
+ # Every claim an access token is refused without: the registered pair above plus
26
+ # the identity/lifetime pair — a token missing any of them never authenticates.
27
+ _REQUIRED_CLAIMS: Final[tuple[str, ...]] = ("exp", "iat", "sub", "aud", "iss")
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class AccessTokenClaims:
32
+ """The trusted claims carried by a decoded access token."""
33
+
34
+ subject: uuid.UUID
35
+ role: Role
36
+ tenant: uuid.UUID | None = None
37
+ token_version: int = 0
38
+
39
+
40
+ def create_access_token(
41
+ *,
42
+ subject: uuid.UUID,
43
+ role: Role | Roles,
44
+ tenant: uuid.UUID | None = None,
45
+ token_version: int = 0,
46
+ expires_in: datetime.timedelta = DEFAULT_ACCESS_TOKEN_TTL,
47
+ ) -> str:
48
+ """Issue a short-lived HS256 access token for *subject* with *role*.
49
+
50
+ *role* may be a typed :class:`~terp.core.Role` or the legacy ``Roles`` enum;
51
+ its name and rank are both signed in, so a consumer-defined role round-trips
52
+ without being coerced to a fixed tier. When *tenant* is given it is signed as
53
+ the ``tenant`` claim, so a tenant-aware app can bind request scope from the
54
+ verified token (see ``terp.capabilities.tenancy.TenantMiddleware``).
55
+
56
+ *token_version* signs the subject's current **token epoch** as the ``tv`` claim
57
+ (ADR 0031). A principal provider wired with a validator rejects the token once the
58
+ stored epoch moves past it, so deactivating, demoting, re-tenanting, resetting the
59
+ password of, or logging out a user invalidates their outstanding tokens at once —
60
+ sign the user's current epoch here (default ``0`` = revocation inactive).
61
+
62
+ Every token also signs the fixed :data:`TOKEN_ISSUER` / :data:`TOKEN_AUDIENCE`
63
+ pair (ADR 0076), which :func:`decode_access_token` requires — scoping the
64
+ credential to this framework's API even under a shared signing key. Signing
65
+ always uses the **current** ``SECRET_KEY`` (never a fallback).
66
+ """
67
+ typed = as_role(role)
68
+ now = datetime.datetime.now(datetime.UTC)
69
+ payload: dict[str, object] = {
70
+ "sub": str(subject),
71
+ "role": typed.name,
72
+ "rank": typed.rank,
73
+ "tv": token_version,
74
+ "iss": TOKEN_ISSUER,
75
+ "aud": TOKEN_AUDIENCE,
76
+ "iat": now,
77
+ "exp": now + expires_in,
78
+ }
79
+ if tenant is not None:
80
+ payload["tenant"] = str(tenant)
81
+ return jwt.encode(payload, settings.SECRET_KEY, algorithm=_ALGORITHM)
82
+
83
+
84
+ def _verify(token: str) -> dict:
85
+ """Verify *token* against the current key, then each rotation fallback.
86
+
87
+ ``SECRET_KEY`` verifies first; a **signature** mismatch (and only that) moves
88
+ on to the next ``SECRET_KEY_FALLBACKS`` entry, so an access token issued just
89
+ before a key rotation stays valid through the configured window (ADR 0076).
90
+ Any other verification failure — expired, wrong ``aud``/``iss``, a missing
91
+ required claim — is final and never retried against another key.
92
+ """
93
+ keys = (settings.SECRET_KEY, *settings.SECRET_KEY_FALLBACKS)
94
+ rejected: jwt.InvalidSignatureError | None = None
95
+ for key in keys:
96
+ try:
97
+ return jwt.decode(
98
+ token,
99
+ key,
100
+ algorithms=[_ALGORITHM],
101
+ audience=TOKEN_AUDIENCE,
102
+ issuer=TOKEN_ISSUER,
103
+ options={"require": list(_REQUIRED_CLAIMS)},
104
+ )
105
+ except jwt.InvalidSignatureError as exc:
106
+ rejected = exc
107
+ except jwt.PyJWTError as exc:
108
+ raise AuthenticationError() from exc
109
+ raise AuthenticationError() from rejected
110
+
111
+
112
+ def decode_access_token(token: str) -> AccessTokenClaims:
113
+ """Verify + decode *token*. Raises :class:`AuthenticationError` if invalid.
114
+
115
+ Verification is fail-closed on the registered claims: the signature (current
116
+ key or a rotation fallback), the ``exp``/``iat`` lifetime, and the exact
117
+ :data:`TOKEN_ISSUER` / :data:`TOKEN_AUDIENCE` pair must all hold.
118
+ """
119
+ payload = _verify(token)
120
+ try:
121
+ raw_tenant = payload.get("tenant")
122
+ return AccessTokenClaims(
123
+ subject=uuid.UUID(payload["sub"]),
124
+ role=Role(str(payload["role"]), int(payload["rank"])),
125
+ tenant=uuid.UUID(raw_tenant) if raw_tenant is not None else None,
126
+ token_version=int(payload.get("tv", 0)),
127
+ )
128
+ except (KeyError, ValueError, TypeError) as exc:
129
+ raise AuthenticationError() from exc
130
+
131
+
132
+ __all__ = [
133
+ "AccessTokenClaims",
134
+ "DEFAULT_ACCESS_TOKEN_TTL",
135
+ "TOKEN_AUDIENCE",
136
+ "TOKEN_ISSUER",
137
+ "create_access_token",
138
+ "decode_access_token",
139
+ ]