caselaw-auth-server 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,12 @@
1
+ .env
2
+ .DS_Store
3
+ target/
4
+ *.log
5
+ node_modules/
6
+ packages/*/dist/
7
+
8
+ # Python package: local virtualenv and build output
9
+ packages/python/.venv/
10
+ packages/python/dist/
11
+ packages/python/**/__pycache__/
12
+ packages/python/*.egg-info/
@@ -0,0 +1,139 @@
1
+ Metadata-Version: 2.5
2
+ Name: caselaw-auth-server
3
+ Version: 0.1.0
4
+ Summary: Server-side OIDC for Case Law Explorer products, interchangeable with the caselaw-auth npm package.
5
+ Project-URL: Homepage, https://github.com/MaastrichtU-BISS/caselaw-auth#readme
6
+ Project-URL: Repository, https://github.com/MaastrichtU-BISS/caselaw-auth
7
+ Project-URL: Issues, https://github.com/MaastrichtU-BISS/caselaw-auth/issues
8
+ License: MIT
9
+ Requires-Python: >=3.10
10
+ Requires-Dist: httpx>=0.27
11
+ Requires-Dist: pyjwt[crypto]>=2.8
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=8.0; extra == 'dev'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # caselaw-auth-server
17
+
18
+ Server-side OIDC for Python products, interchangeable with the
19
+ [`caselaw-auth`](https://www.npmjs.com/package/caselaw-auth) npm package's
20
+ `/server` entry point.
21
+
22
+ A session cookie sealed by either implementation unseals in the other, byte for
23
+ byte. Both are written against `packages/contract/contract.json`, and both load
24
+ `packages/contract/conformance/cases.json` at test time — which is what keeps
25
+ them from drifting.
26
+
27
+ ```bash
28
+ pip install caselaw-auth-server
29
+ ```
30
+
31
+ ## Why
32
+
33
+ `caselaw-auth/server` is TypeScript, and half this estate is not: caselaw-access
34
+ is FastAPI and citations-api is a Python HTTP server. Without a Python
35
+ implementation, those two would have stayed on browser-side sessions purely
36
+ because of the language their backend happens to be written in — with the
37
+ refresh token in `localStorage`, where page script can read it.
38
+
39
+ ## Usage
40
+
41
+ ```python
42
+ from caselaw_auth_server import create_server_auth, create_pkce_pair, random_token
43
+
44
+ auth = create_server_auth(
45
+ issuer=os.environ["AUTH_ISSUER"], # .../realms/caselaw
46
+ client_id=os.environ["AUTH_CLIENT_ID"],
47
+ redirect_uri=os.environ["AUTH_REDIRECT_URI"],
48
+ session_secret=os.environ["AUTH_SESSION_SECRET"],
49
+ client_secret=os.environ.get("AUTH_CLIENT_SECRET"), # confidential clients
50
+ )
51
+ ```
52
+
53
+ Start a sign-in:
54
+
55
+ ```python
56
+ verifier, challenge = create_pkce_pair()
57
+ state = random_token()
58
+ # state and verifier must survive the round trip and stay unreadable by page
59
+ # script — a short httpOnly cookie, not anything the browser can see.
60
+ url = auth.authorization_url(state=state, code_challenge=challenge)
61
+ ```
62
+
63
+ Complete it:
64
+
65
+ ```python
66
+ tokens = auth.exchange_code(code=code, code_verifier=verifier)
67
+ claims = auth.verify_token(tokens["access_token"])
68
+ session = auth.session_from_claims(claims, id_token=tokens.get("id_token"))
69
+ cookie = auth.seal_session(session)
70
+ ```
71
+
72
+ Read it on any request — an HMAC check, no network call:
73
+
74
+ ```python
75
+ session = auth.unseal_session(request.cookies.get("caselaw_session"))
76
+ if not session or "admin" not in session["roles"]:
77
+ raise Forbidden()
78
+ ```
79
+
80
+ End it. `id_token_hint` is what makes this a real single sign-out:
81
+
82
+ ```python
83
+ auth.end_session_url(id_token=session.get("idToken"), return_to=site)
84
+ ```
85
+
86
+ ## Mapping to the TypeScript package
87
+
88
+ Method names are idiomatic per language. **Session keys are camelCase in both**,
89
+ because they are wire format rather than API surface: renaming them per language
90
+ would break the property the whole contract exists to guarantee.
91
+
92
+ | TypeScript | Python |
93
+ |---|---|
94
+ | `createServerAuth(config)` | `create_server_auth(...)` |
95
+ | `authorizationUrl(options)` | `authorization_url(...)` |
96
+ | `exchangeCode(options)` | `exchange_code(...)` |
97
+ | `verifyToken(token, options)` | `verify_token(token, ...)` |
98
+ | `rolesFromClaims(claims)` | `roles_from_claims(claims)` |
99
+ | `sessionFromClaims(claims, options)` | `session_from_claims(claims, ...)` |
100
+ | `sealSession(session, name)` | `seal_session(session, name)` |
101
+ | `unsealSession(value)` | `unseal_session(value)` |
102
+ | `cookieOptions(maxAge, options)` | `cookie_options(max_age, ...)` |
103
+ | `endSessionUrl(options)` | `end_session_url(...)` |
104
+ | `createPkcePair()` | `create_pkce_pair()` |
105
+
106
+ Errors are typed here rather than string-matched: `InvalidToken`,
107
+ `AuthBackendUnavailable` and `SessionTooLarge`, all under `AuthError`. The split
108
+ between the first two is deliberate — collapsing them sends users to
109
+ re-authenticate during an outage, which cannot help and doubles the load on the
110
+ thing already failing.
111
+
112
+ ## Behaviour worth knowing
113
+
114
+ - **`session_from_claims` does not keep the access token.** Where one is
115
+ genuinely needed, put it in a cookie of its own; a token in a cookie leaks
116
+ with the cookie.
117
+ - **The session carries its own TTL**, not the token's few minutes.
118
+ - **`seal_session` raises above 4096 bytes** rather than returning a cookie the
119
+ browser will silently discard.
120
+ - **`verify_token` does not check `aud` by default.** Keycloak puts `account`
121
+ there for a public client's access token and names the client in `azp`.
122
+ - **JWKS is cached and retried once**; a token that is itself invalid is never
123
+ retried.
124
+
125
+ ## Tests
126
+
127
+ ```bash
128
+ pip install -e ".[dev]"
129
+ pytest
130
+ ```
131
+
132
+ The suite loads the shared conformance cases by relative path. Running it from a
133
+ checkout of this repository is therefore required — the cases are not vendored
134
+ into the package.
135
+
136
+ ## Related
137
+
138
+ - [caselaw-auth](https://github.com/MaastrichtU-BISS/caselaw-auth) — the realm, themes, and both implementations
139
+ - [docs/SERVER_SIDE_AUTH.md](https://github.com/MaastrichtU-BISS/caselaw-auth/blob/main/docs/SERVER_SIDE_AUTH.md) — the full walkthrough
@@ -0,0 +1,124 @@
1
+ # caselaw-auth-server
2
+
3
+ Server-side OIDC for Python products, interchangeable with the
4
+ [`caselaw-auth`](https://www.npmjs.com/package/caselaw-auth) npm package's
5
+ `/server` entry point.
6
+
7
+ A session cookie sealed by either implementation unseals in the other, byte for
8
+ byte. Both are written against `packages/contract/contract.json`, and both load
9
+ `packages/contract/conformance/cases.json` at test time — which is what keeps
10
+ them from drifting.
11
+
12
+ ```bash
13
+ pip install caselaw-auth-server
14
+ ```
15
+
16
+ ## Why
17
+
18
+ `caselaw-auth/server` is TypeScript, and half this estate is not: caselaw-access
19
+ is FastAPI and citations-api is a Python HTTP server. Without a Python
20
+ implementation, those two would have stayed on browser-side sessions purely
21
+ because of the language their backend happens to be written in — with the
22
+ refresh token in `localStorage`, where page script can read it.
23
+
24
+ ## Usage
25
+
26
+ ```python
27
+ from caselaw_auth_server import create_server_auth, create_pkce_pair, random_token
28
+
29
+ auth = create_server_auth(
30
+ issuer=os.environ["AUTH_ISSUER"], # .../realms/caselaw
31
+ client_id=os.environ["AUTH_CLIENT_ID"],
32
+ redirect_uri=os.environ["AUTH_REDIRECT_URI"],
33
+ session_secret=os.environ["AUTH_SESSION_SECRET"],
34
+ client_secret=os.environ.get("AUTH_CLIENT_SECRET"), # confidential clients
35
+ )
36
+ ```
37
+
38
+ Start a sign-in:
39
+
40
+ ```python
41
+ verifier, challenge = create_pkce_pair()
42
+ state = random_token()
43
+ # state and verifier must survive the round trip and stay unreadable by page
44
+ # script — a short httpOnly cookie, not anything the browser can see.
45
+ url = auth.authorization_url(state=state, code_challenge=challenge)
46
+ ```
47
+
48
+ Complete it:
49
+
50
+ ```python
51
+ tokens = auth.exchange_code(code=code, code_verifier=verifier)
52
+ claims = auth.verify_token(tokens["access_token"])
53
+ session = auth.session_from_claims(claims, id_token=tokens.get("id_token"))
54
+ cookie = auth.seal_session(session)
55
+ ```
56
+
57
+ Read it on any request — an HMAC check, no network call:
58
+
59
+ ```python
60
+ session = auth.unseal_session(request.cookies.get("caselaw_session"))
61
+ if not session or "admin" not in session["roles"]:
62
+ raise Forbidden()
63
+ ```
64
+
65
+ End it. `id_token_hint` is what makes this a real single sign-out:
66
+
67
+ ```python
68
+ auth.end_session_url(id_token=session.get("idToken"), return_to=site)
69
+ ```
70
+
71
+ ## Mapping to the TypeScript package
72
+
73
+ Method names are idiomatic per language. **Session keys are camelCase in both**,
74
+ because they are wire format rather than API surface: renaming them per language
75
+ would break the property the whole contract exists to guarantee.
76
+
77
+ | TypeScript | Python |
78
+ |---|---|
79
+ | `createServerAuth(config)` | `create_server_auth(...)` |
80
+ | `authorizationUrl(options)` | `authorization_url(...)` |
81
+ | `exchangeCode(options)` | `exchange_code(...)` |
82
+ | `verifyToken(token, options)` | `verify_token(token, ...)` |
83
+ | `rolesFromClaims(claims)` | `roles_from_claims(claims)` |
84
+ | `sessionFromClaims(claims, options)` | `session_from_claims(claims, ...)` |
85
+ | `sealSession(session, name)` | `seal_session(session, name)` |
86
+ | `unsealSession(value)` | `unseal_session(value)` |
87
+ | `cookieOptions(maxAge, options)` | `cookie_options(max_age, ...)` |
88
+ | `endSessionUrl(options)` | `end_session_url(...)` |
89
+ | `createPkcePair()` | `create_pkce_pair()` |
90
+
91
+ Errors are typed here rather than string-matched: `InvalidToken`,
92
+ `AuthBackendUnavailable` and `SessionTooLarge`, all under `AuthError`. The split
93
+ between the first two is deliberate — collapsing them sends users to
94
+ re-authenticate during an outage, which cannot help and doubles the load on the
95
+ thing already failing.
96
+
97
+ ## Behaviour worth knowing
98
+
99
+ - **`session_from_claims` does not keep the access token.** Where one is
100
+ genuinely needed, put it in a cookie of its own; a token in a cookie leaks
101
+ with the cookie.
102
+ - **The session carries its own TTL**, not the token's few minutes.
103
+ - **`seal_session` raises above 4096 bytes** rather than returning a cookie the
104
+ browser will silently discard.
105
+ - **`verify_token` does not check `aud` by default.** Keycloak puts `account`
106
+ there for a public client's access token and names the client in `azp`.
107
+ - **JWKS is cached and retried once**; a token that is itself invalid is never
108
+ retried.
109
+
110
+ ## Tests
111
+
112
+ ```bash
113
+ pip install -e ".[dev]"
114
+ pytest
115
+ ```
116
+
117
+ The suite loads the shared conformance cases by relative path. Running it from a
118
+ checkout of this repository is therefore required — the cases are not vendored
119
+ into the package.
120
+
121
+ ## Related
122
+
123
+ - [caselaw-auth](https://github.com/MaastrichtU-BISS/caselaw-auth) — the realm, themes, and both implementations
124
+ - [docs/SERVER_SIDE_AUTH.md](https://github.com/MaastrichtU-BISS/caselaw-auth/blob/main/docs/SERVER_SIDE_AUTH.md) — the full walkthrough
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "caselaw-auth-server"
7
+ version = "0.1.0"
8
+ description = "Server-side OIDC for Case Law Explorer products, interchangeable with the caselaw-auth npm package."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ dependencies = ["httpx>=0.27", "pyjwt[crypto]>=2.8"]
13
+
14
+ [project.urls]
15
+ Homepage = "https://github.com/MaastrichtU-BISS/caselaw-auth#readme"
16
+ Repository = "https://github.com/MaastrichtU-BISS/caselaw-auth"
17
+ Issues = "https://github.com/MaastrichtU-BISS/caselaw-auth/issues"
18
+
19
+ [project.optional-dependencies]
20
+ dev = ["pytest>=8.0"]
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["src/caselaw_auth_server"]
@@ -0,0 +1,29 @@
1
+ """Server-side OIDC for Python products, interchangeable with ``caselaw-auth/server``."""
2
+
3
+ from .server import (
4
+ COOKIE_BYTE_LIMIT,
5
+ AuthBackendUnavailable,
6
+ AuthError,
7
+ InvalidToken,
8
+ ServerAuth,
9
+ ServerAuthConfig,
10
+ SessionTooLarge,
11
+ create_pkce_pair,
12
+ create_server_auth,
13
+ random_token,
14
+ )
15
+
16
+ __all__ = [
17
+ "COOKIE_BYTE_LIMIT",
18
+ "AuthBackendUnavailable",
19
+ "AuthError",
20
+ "InvalidToken",
21
+ "ServerAuth",
22
+ "ServerAuthConfig",
23
+ "SessionTooLarge",
24
+ "create_pkce_pair",
25
+ "create_server_auth",
26
+ "random_token",
27
+ ]
28
+
29
+ __version__ = "0.1.0"
@@ -0,0 +1,450 @@
1
+ """Server-side OIDC for Python products, matching ``caselaw-auth/server``.
2
+
3
+ The two implementations are interchangeable rather than merely similar: a
4
+ session cookie sealed by either unseals in the other, byte for byte. Both are
5
+ written against ``packages/contract/contract.json`` and both load
6
+ ``packages/contract/conformance/cases.json`` at test time, which is what stops
7
+ them drifting — a behaviour with no case there is a behaviour that can drift.
8
+
9
+ This exists because ``caselaw-auth/server`` is TypeScript, and half this estate
10
+ is not: caselaw-access is FastAPI and citations-api is a Python HTTP server.
11
+ Without it, those two would have stayed on browser-side sessions purely because
12
+ of the language their backend happens to be written in.
13
+
14
+ Idiomatic Python on the surface, identical on the wire. Method names are
15
+ snake_case; **session keys stay camelCase in both languages**, because they are
16
+ wire format rather than API surface.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import base64
22
+ import hashlib
23
+ import hmac
24
+ import json
25
+ import secrets
26
+ import time
27
+ from dataclasses import dataclass, field
28
+ from typing import Any, Iterable, Mapping
29
+ from urllib.parse import urlencode
30
+
31
+ import httpx
32
+ import jwt
33
+ from jwt import InvalidTokenError, PyJWKClient, PyJWKClientError
34
+
35
+ __all__ = [
36
+ "ServerAuth",
37
+ "ServerAuthConfig",
38
+ "AuthError",
39
+ "InvalidToken",
40
+ "AuthBackendUnavailable",
41
+ "SessionTooLarge",
42
+ "create_server_auth",
43
+ "create_pkce_pair",
44
+ "random_token",
45
+ "COOKIE_BYTE_LIMIT",
46
+ ]
47
+
48
+ #: Browsers cap one cookie at 4096 bytes and drop anything larger in silence.
49
+ COOKIE_BYTE_LIMIT = 4096
50
+
51
+ _DEFAULT_SCOPE = "openid profile email"
52
+ _DEFAULT_SESSION_TTL = 8 * 3600
53
+ _DEFAULT_JWKS_CACHE = 300
54
+ _ALGORITHMS = ["RS256", "RS384", "RS512", "ES256"]
55
+
56
+
57
+ class AuthError(Exception):
58
+ """Base for every error this module raises."""
59
+
60
+
61
+ class InvalidToken(AuthError):
62
+ """The token itself is wrong. Never worth retrying — it says the same thing twice."""
63
+
64
+
65
+ class AuthBackendUnavailable(AuthError):
66
+ """The identity provider could not be reached, after one retry.
67
+
68
+ Distinct from :class:`InvalidToken` on purpose. Collapsing the two sends
69
+ users to re-authenticate during an outage, which cannot help and doubles the
70
+ load on the thing that is already failing.
71
+ """
72
+
73
+
74
+ class SessionTooLarge(AuthError):
75
+ """The sealed cookie would exceed what a browser will keep."""
76
+
77
+
78
+ def _b64url_encode(raw: bytes) -> str:
79
+ return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
80
+
81
+
82
+ def _b64url_decode(value: str) -> bytes:
83
+ return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
84
+
85
+
86
+ def random_token(num_bytes: int = 32) -> str:
87
+ """URL-safe random string, for ``state`` and PKCE verifiers."""
88
+ return _b64url_encode(secrets.token_bytes(num_bytes))
89
+
90
+
91
+ def create_pkce_pair() -> tuple[str, str]:
92
+ """``(verifier, challenge)``, with the challenge as base64url SHA-256.
93
+
94
+ PKCE is not only for public clients. A confidential client that also sends a
95
+ challenge is protected even if its secret leaks, and this realm's clients
96
+ are configured to require S256, so a server flow without it is refused.
97
+ """
98
+ verifier = random_token(64)
99
+ challenge = _b64url_encode(hashlib.sha256(verifier.encode("utf-8")).digest())
100
+ return verifier, challenge
101
+
102
+
103
+ def _dumps(payload: Any) -> str:
104
+ """JSON exactly as ``JSON.stringify`` produces it.
105
+
106
+ Both arguments are load-bearing and pinned by the contract. Python's default
107
+ separators insert spaces and its default ``ensure_ascii`` escapes non-ASCII
108
+ to ``\\uXXXX``; JavaScript does neither. Left alone, the two implementations
109
+ would seal the same session to different bytes, and the first person to
110
+ notice would be whoever tried to hand a cookie from one service to another.
111
+ """
112
+ return json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
113
+
114
+
115
+ @dataclass
116
+ class ServerAuthConfig:
117
+ """See ``packages/contract/contract.json`` for the authoritative definitions."""
118
+
119
+ issuer: str
120
+ client_id: str
121
+ redirect_uri: str
122
+ session_secret: str
123
+ #: Confidential clients only. Never reachable from a browser.
124
+ client_secret: str | None = None
125
+ scope: str = _DEFAULT_SCOPE
126
+ #: The application's own session length, deliberately not the token's few
127
+ #: minutes: reading expiry off a token makes session length hostage to clock
128
+ #: drift between two machines.
129
+ session_ttl_seconds: int = _DEFAULT_SESSION_TTL
130
+ jwks_cache_seconds: int = _DEFAULT_JWKS_CACHE
131
+ timeout_seconds: float = 10.0
132
+
133
+ def __post_init__(self) -> None:
134
+ if not self.issuer:
135
+ raise ValueError("caselaw-auth-server: issuer is required")
136
+ if not self.session_secret:
137
+ raise ValueError("caselaw-auth-server: session_secret is required")
138
+ self.issuer = self.issuer.rstrip("/")
139
+
140
+
141
+ @dataclass
142
+ class ServerAuth:
143
+ config: ServerAuthConfig
144
+ _discovery: dict[str, Any] | None = field(default=None, init=False, repr=False)
145
+ _jwks: tuple[PyJWKClient, float] | None = field(default=None, init=False, repr=False)
146
+
147
+ # ── discovery ────────────────────────────────────────────────────────────
148
+
149
+ def discover(self) -> dict[str, Any]:
150
+ """Fetches and memoises the realm's discovery document."""
151
+ if self._discovery is not None:
152
+ return self._discovery
153
+ url = f"{self.config.issuer}/.well-known/openid-configuration"
154
+ try:
155
+ response = httpx.get(url, timeout=self.config.timeout_seconds)
156
+ response.raise_for_status()
157
+ except httpx.HTTPError as exc:
158
+ raise AuthBackendUnavailable(f"discovery failed: {exc}") from exc
159
+ self._discovery = response.json()
160
+ return self._discovery
161
+
162
+ # ── sign-in ──────────────────────────────────────────────────────────────
163
+
164
+ def authorization_url(
165
+ self,
166
+ *,
167
+ state: str,
168
+ code_challenge: str | None = None,
169
+ nonce: str | None = None,
170
+ prompt: str | None = None,
171
+ login_hint: str | None = None,
172
+ ) -> str:
173
+ """Where to send the browser to start a sign-in."""
174
+ params = {
175
+ "response_type": "code",
176
+ "client_id": self.config.client_id,
177
+ "redirect_uri": self.config.redirect_uri,
178
+ "scope": self.config.scope,
179
+ "state": state,
180
+ }
181
+ if code_challenge:
182
+ params["code_challenge"] = code_challenge
183
+ params["code_challenge_method"] = "S256"
184
+ if nonce:
185
+ params["nonce"] = nonce
186
+ if prompt:
187
+ params["prompt"] = prompt
188
+ if login_hint:
189
+ params["login_hint"] = login_hint
190
+ return f"{self.discover()['authorization_endpoint']}?{urlencode(params)}"
191
+
192
+ def exchange_code(self, *, code: str, code_verifier: str | None = None) -> dict[str, Any]:
193
+ """Redeems the authorization code. Server-side, so the secret stays here."""
194
+ body = {
195
+ "grant_type": "authorization_code",
196
+ "code": code,
197
+ "redirect_uri": self.config.redirect_uri,
198
+ "client_id": self.config.client_id,
199
+ }
200
+ if code_verifier:
201
+ body["code_verifier"] = code_verifier
202
+ if self.config.client_secret:
203
+ body["client_secret"] = self.config.client_secret
204
+
205
+ try:
206
+ response = httpx.post(
207
+ self.discover()["token_endpoint"],
208
+ data=body,
209
+ timeout=self.config.timeout_seconds,
210
+ )
211
+ except httpx.HTTPError as exc:
212
+ raise AuthBackendUnavailable(f"token exchange failed: {exc}") from exc
213
+
214
+ if response.status_code >= 400:
215
+ # The body carries `error_description`, which is the difference
216
+ # between "redirect URI does not match" and "client secret is wrong"
217
+ # — both of which otherwise arrive as an opaque 400.
218
+ raise AuthError(f"token exchange failed with {response.status_code}: {response.text}")
219
+ return response.json()
220
+
221
+ # ── verification ─────────────────────────────────────────────────────────
222
+
223
+ def _jwks_client(self, force: bool = False) -> PyJWKClient:
224
+ now = time.time()
225
+ if self._jwks and not force and now - self._jwks[1] < self.config.jwks_cache_seconds:
226
+ return self._jwks[0]
227
+ url = self.discover().get(
228
+ "jwks_uri", f"{self.config.issuer}/protocol/openid-connect/certs"
229
+ )
230
+ client = PyJWKClient(url, timeout=int(self.config.timeout_seconds))
231
+ self._jwks = (client, now)
232
+ return client
233
+
234
+ def verify_token(
235
+ self,
236
+ token: str,
237
+ *,
238
+ audience: str | None = None,
239
+ azp: str | None = None,
240
+ nonce: str | None = None,
241
+ ) -> dict[str, Any]:
242
+ """Verifies a token's signature and claims against the realm.
243
+
244
+ Offline against the cached key set, so this is not a network call per
245
+ request. It retries once against a freshly fetched set, because the
246
+ cache expires and a single blip on the refetch would otherwise fail a
247
+ request with nothing wrong with it — the access service learned that by
248
+ turning a filled-in form into a bare 503.
249
+
250
+ ``audience`` is off by default on purpose: Keycloak puts ``account``
251
+ there for a public client's access token and names the requesting client
252
+ in ``azp``, so checking it rejects every valid token.
253
+ """
254
+ last_error: Exception | None = None
255
+ for attempt in (0, 1):
256
+ try:
257
+ key = self._jwks_client(force=bool(attempt)).get_signing_key_from_jwt(token).key
258
+ claims = jwt.decode(
259
+ token,
260
+ key,
261
+ algorithms=_ALGORITHMS,
262
+ issuer=self.config.issuer,
263
+ audience=audience,
264
+ options={"verify_aud": audience is not None},
265
+ )
266
+ break
267
+ except InvalidTokenError as exc:
268
+ raise InvalidToken(str(exc)) from exc
269
+ except (PyJWKClientError, httpx.HTTPError, ValueError) as exc:
270
+ last_error = exc
271
+ self._jwks = None
272
+ if attempt:
273
+ raise AuthBackendUnavailable(str(last_error)) from last_error
274
+ if not isinstance(claims, dict) or not claims.get("sub"):
275
+ raise InvalidToken("token carries no subject")
276
+ if azp and claims.get("azp") != azp:
277
+ raise InvalidToken("token was issued to a different client")
278
+ if nonce and claims.get("nonce") != nonce:
279
+ raise InvalidToken("nonce does not match")
280
+ return claims
281
+
282
+ # ── sessions ─────────────────────────────────────────────────────────────
283
+
284
+ def roles_from_claims(self, claims: Mapping[str, Any]) -> list[str]:
285
+ """Realm roles plus this client's roles, de-duplicated, realm first."""
286
+ realm = (claims.get("realm_access") or {}).get("roles") or []
287
+ client = ((claims.get("resource_access") or {}).get(self.config.client_id) or {}).get(
288
+ "roles"
289
+ ) or []
290
+ seen: dict[str, None] = {}
291
+ for role in [*realm, *client]:
292
+ seen.setdefault(role, None)
293
+ return list(seen)
294
+
295
+ def session_from_claims(
296
+ self, claims: Mapping[str, Any], *, id_token: str | None = None
297
+ ) -> dict[str, Any]:
298
+ """Builds the session a callback route should store, from verified claims.
299
+
300
+ Note what is absent: the access token. Keeping one bloats the cookie
301
+ toward the browser's limit and buys nothing unless something downstream
302
+ actually calls an API as the user — and a token in a cookie leaks with
303
+ the cookie. Where one is genuinely needed, put it in a cookie of its own.
304
+ """
305
+ username = claims.get("preferred_username")
306
+ session: dict[str, Any] = {
307
+ "user": {
308
+ "id": str(claims["sub"]),
309
+ "email": claims.get("email") or username or None,
310
+ "name": claims.get("name") or username or None,
311
+ },
312
+ "roles": self.roles_from_claims(claims),
313
+ "expiresAt": int(time.time()) + self.config.session_ttl_seconds,
314
+ }
315
+ if id_token:
316
+ session["idToken"] = id_token
317
+ return session
318
+
319
+ def _signature(self, payload: str) -> str:
320
+ return _b64url_encode(
321
+ hmac.new(
322
+ self.config.session_secret.encode("utf-8"),
323
+ payload.encode("utf-8"),
324
+ hashlib.sha256,
325
+ ).digest()
326
+ )
327
+
328
+ def seal_session(self, session: Mapping[str, Any], cookie_name: str = "caselaw_session") -> str:
329
+ """Signs a session into a cookie value.
330
+
331
+ Signed, not encrypted: the contents are the user's own identity, which
332
+ they already know. What matters is that they cannot change it — an
333
+ unsigned cookie lets anyone promote themselves by editing a role.
334
+
335
+ Raises above the browser's limit rather than returning something the
336
+ browser will drop. A dropped cookie presents as the application signing
337
+ the user straight back out, which reads as broken authentication rather
338
+ than an oversized session.
339
+ """
340
+ payload = _b64url_encode(_dumps(session).encode("utf-8"))
341
+ value = f"{payload}.{self._signature(payload)}"
342
+ size = len(f"{cookie_name}={value}".encode("utf-8"))
343
+ if size > COOKIE_BYTE_LIMIT:
344
+ raise SessionTooLarge(
345
+ f"session cookie is {size} bytes, over the {COOKIE_BYTE_LIMIT}-byte browser "
346
+ "limit. Something oversized is in the session — an access token is the usual "
347
+ "culprit."
348
+ )
349
+ return value
350
+
351
+ def unseal_session(self, value: str | None) -> dict[str, Any] | None:
352
+ """Verifies and decodes a cookie value.
353
+
354
+ Returns ``None`` for anything untrusted or expired, and never raises: a
355
+ tampered cookie and an absent one lead to the same place, and telling
356
+ them apart only informs an attacker which half they got right.
357
+ """
358
+ if not value or value.count(".") != 1:
359
+ return None
360
+ payload, signature = value.split(".", 1)
361
+ if not payload or not signature:
362
+ return None
363
+ if not hmac.compare_digest(signature, self._signature(payload)):
364
+ return None
365
+ try:
366
+ session = json.loads(_b64url_decode(payload).decode("utf-8"))
367
+ except (ValueError, UnicodeDecodeError):
368
+ return None
369
+ if not isinstance(session, dict):
370
+ return None
371
+ user = session.get("user")
372
+ if not isinstance(user, dict) or not user.get("id"):
373
+ return None
374
+ expires_at = session.get("expiresAt")
375
+ if not isinstance(expires_at, (int, float)) or expires_at <= time.time():
376
+ return None
377
+ return session
378
+
379
+ # ── cookies and sign-out ─────────────────────────────────────────────────
380
+
381
+ def cookie_options(
382
+ self, max_age_seconds: int, *, secure: bool = True, path: str = "/"
383
+ ) -> dict[str, Any]:
384
+ """Attributes for the session cookie.
385
+
386
+ ``httpOnly`` is the whole point: script on the page cannot read it, so an
387
+ XSS that would walk off with a ``localStorage`` session gets nothing.
388
+ ``sameSite='lax'`` still allows the top-level redirect back from the
389
+ identity provider, which ``strict`` would drop — leaving the user signed
390
+ in everywhere except the page they just landed on.
391
+ """
392
+ return {
393
+ "httpOnly": True,
394
+ "sameSite": "lax",
395
+ "secure": secure,
396
+ "path": path,
397
+ "maxAge": max(0, max_age_seconds),
398
+ }
399
+
400
+ def serialize_cookie(self, name: str, value: str, options: Mapping[str, Any]) -> str:
401
+ """A ``Set-Cookie`` value, for frameworks without a cookie helper."""
402
+ parts = [
403
+ f"{name}={value}",
404
+ f"Path={options.get('path', '/')}",
405
+ f"Max-Age={options.get('maxAge', 0)}",
406
+ "SameSite=Lax",
407
+ ]
408
+ if options.get("httpOnly"):
409
+ parts.append("HttpOnly")
410
+ if options.get("secure"):
411
+ parts.append("Secure")
412
+ return "; ".join(parts)
413
+
414
+ def end_session_url(self, *, id_token: str | None = None, return_to: str | None = None) -> str:
415
+ """Where to send the browser to sign out.
416
+
417
+ Passing ``id_token_hint`` is what makes this a real single sign-out.
418
+ Without it Keycloak keeps its own session and the next sign-in completes
419
+ with no prompt, which looks like the sign-out silently failed.
420
+ """
421
+ endpoint = self.discover().get("end_session_endpoint")
422
+ if not endpoint:
423
+ return return_to or "/"
424
+ params: dict[str, str] = {}
425
+ if id_token:
426
+ params["id_token_hint"] = id_token
427
+ if return_to:
428
+ params["post_logout_redirect_uri"] = return_to
429
+ if not id_token and return_to:
430
+ params["client_id"] = self.config.client_id
431
+ return f"{endpoint}?{urlencode(params)}"
432
+
433
+
434
+ def create_server_auth(
435
+ issuer: str,
436
+ client_id: str,
437
+ redirect_uri: str,
438
+ session_secret: str,
439
+ **kwargs: Any,
440
+ ) -> ServerAuth:
441
+ """Mirrors ``createServerAuth`` in the TypeScript package."""
442
+ return ServerAuth(
443
+ ServerAuthConfig(
444
+ issuer=issuer,
445
+ client_id=client_id,
446
+ redirect_uri=redirect_uri,
447
+ session_secret=session_secret,
448
+ **kwargs,
449
+ )
450
+ )
@@ -0,0 +1,180 @@
1
+ """The Python half of the shared conformance suite.
2
+
3
+ Cases are loaded from ``packages/contract/conformance/cases.json`` by relative
4
+ path, never copied here. One copy is the entire anti-drift guarantee: a case
5
+ inlined into a language's own tests is a case that stops constraining the other
6
+ implementation the moment someone edits it.
7
+
8
+ The TypeScript half reads the same file.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import time
15
+ from pathlib import Path
16
+
17
+ import pytest
18
+
19
+ from caselaw_auth_server import (
20
+ COOKIE_BYTE_LIMIT,
21
+ SessionTooLarge,
22
+ create_pkce_pair,
23
+ create_server_auth,
24
+ )
25
+ from caselaw_auth_server.server import _b64url_encode, _dumps
26
+
27
+ CONTRACT_DIR = Path(__file__).resolve().parents[2] / "contract"
28
+ CASES = json.loads((CONTRACT_DIR / "conformance" / "cases.json").read_text())
29
+ CONTRACT = json.loads((CONTRACT_DIR / "contract.json").read_text())
30
+
31
+
32
+ def auth(client_id: str = "conformance-client", secret: str | None = None):
33
+ return create_server_auth(
34
+ issuer="https://auth.example.org/realms/caselaw",
35
+ client_id=client_id,
36
+ redirect_uri="https://example.org/auth/callback",
37
+ session_secret=secret or CASES["secret"],
38
+ )
39
+
40
+
41
+ # ── sealing ──────────────────────────────────────────────────────────────────
42
+
43
+
44
+ @pytest.mark.parametrize("case", CASES["seal"]["cases"], ids=lambda c: c["name"])
45
+ def test_seal_matches_the_pinned_payload(case):
46
+ """Byte-exact, not merely round-trippable.
47
+
48
+ Two implementations that each read their own cookies but write different
49
+ ones would pass a weaker test and still be unable to hand a session to each
50
+ other.
51
+ """
52
+ sealed = auth().seal_session(case["session"])
53
+ assert sealed.split(".")[0] == case["expected"]
54
+
55
+
56
+ def test_seal_round_trips():
57
+ a = auth()
58
+ session = {
59
+ "user": {"id": "u", "email": None, "name": None},
60
+ "roles": ["admin"],
61
+ "expiresAt": int(time.time()) + 3600,
62
+ }
63
+ assert a.unseal_session(a.seal_session(session)) == session
64
+
65
+
66
+ def test_seal_refuses_an_oversized_session():
67
+ a = auth()
68
+ with pytest.raises(SessionTooLarge):
69
+ a.seal_session(
70
+ {
71
+ "user": {"id": "u", "email": None, "name": None},
72
+ "roles": [],
73
+ "expiresAt": int(time.time()) + 3600,
74
+ "bloat": "x" * COOKIE_BYTE_LIMIT,
75
+ }
76
+ )
77
+
78
+
79
+ # ── unsealing ────────────────────────────────────────────────────────────────
80
+
81
+
82
+ @pytest.mark.parametrize("case", CASES["unseal_rejects"]["cases"], ids=lambda c: c["name"])
83
+ def test_unseal_rejects(case):
84
+ assert auth().unseal_session(case["value"]) is None
85
+
86
+
87
+ def test_unseal_rejects_none():
88
+ assert auth().unseal_session(None) is None
89
+
90
+
91
+ def test_unseal_rejects_an_expired_session():
92
+ """Valid signature, past expiry. Must still yield nothing."""
93
+ a = auth()
94
+ sealed = a.seal_session(CASES["unseal_rejects_expired"]["session"])
95
+ assert a.unseal_session(sealed) is None
96
+
97
+
98
+ def test_unseal_rejects_a_session_signed_with_another_secret():
99
+ sealed = auth(secret="a-different-secret").seal_session(
100
+ {
101
+ "user": {"id": "u", "email": None, "name": None},
102
+ "roles": ["admin"],
103
+ "expiresAt": int(time.time()) + 3600,
104
+ }
105
+ )
106
+ assert auth().unseal_session(sealed) is None
107
+
108
+
109
+ def test_unseal_rejects_an_edited_payload():
110
+ """The attack the signature exists to stop: promote yourself by editing a role."""
111
+ a = auth()
112
+ session = {
113
+ "user": {"id": "u", "email": None, "name": None},
114
+ "roles": ["researcher"],
115
+ "expiresAt": int(time.time()) + 3600,
116
+ }
117
+ _, signature = a.seal_session(session).split(".")
118
+ forged = _b64url_encode(_dumps({**session, "roles": ["admin"]}).encode("utf-8"))
119
+ assert a.unseal_session(f"{forged}.{signature}") is None
120
+
121
+
122
+ # ── claim mapping ────────────────────────────────────────────────────────────
123
+
124
+
125
+ @pytest.mark.parametrize(
126
+ "case", CASES["session_from_claims"]["cases"], ids=lambda c: c["name"]
127
+ )
128
+ def test_session_from_claims(case):
129
+ a = auth(client_id=CASES["session_from_claims"]["clientId"])
130
+ session = a.session_from_claims(case["claims"])
131
+ assert session["user"] == case["expected"]["user"]
132
+ assert session["roles"] == case["expected"]["roles"]
133
+
134
+
135
+ def test_session_expiry_is_the_applications_own_ttl():
136
+ """Not the token's. Reading expiry off a token makes session length hostage
137
+ to clock drift between two machines."""
138
+ a = auth()
139
+ session = a.session_from_claims({"sub": "u", "exp": 1})
140
+ assert session["expiresAt"] > time.time() + 7 * 3600
141
+
142
+
143
+ def test_session_does_not_carry_an_access_token():
144
+ session = auth().session_from_claims({"sub": "u"}, id_token="id-token")
145
+ assert "accessToken" not in session
146
+ assert session["idToken"] == "id-token"
147
+
148
+
149
+ # ── PKCE and URLs ────────────────────────────────────────────────────────────
150
+
151
+
152
+ def test_pkce_challenge_matches_the_pinned_verifier():
153
+ import hashlib
154
+
155
+ verifier = CASES["pkce"]["verifier"]
156
+ challenge = _b64url_encode(hashlib.sha256(verifier.encode()).digest())
157
+ assert challenge == CASES["pkce"]["challenge"]
158
+
159
+
160
+ def test_pkce_pair_is_self_consistent():
161
+ import hashlib
162
+
163
+ verifier, challenge = create_pkce_pair()
164
+ assert challenge == _b64url_encode(hashlib.sha256(verifier.encode()).digest())
165
+
166
+
167
+ def test_cookie_options_match_the_contract():
168
+ options = auth().cookie_options(3600)
169
+ for key, value in CASES["cookie_options"]["expected"].items():
170
+ assert options[key] == value
171
+
172
+
173
+ def test_serialised_cookie_carries_the_flags():
174
+ a = auth()
175
+ header = a.serialize_cookie("caselaw_session", "v", a.cookie_options(3600))
176
+ assert "HttpOnly" in header and "Secure" in header and "SameSite=Lax" in header
177
+
178
+
179
+ def test_contract_and_cases_agree_on_the_byte_limit():
180
+ assert CONTRACT["session_cookie"]["byte_limit"] == COOKIE_BYTE_LIMIT