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.
- caselaw_auth_server-0.1.0/.gitignore +12 -0
- caselaw_auth_server-0.1.0/PKG-INFO +139 -0
- caselaw_auth_server-0.1.0/README.md +124 -0
- caselaw_auth_server-0.1.0/pyproject.toml +23 -0
- caselaw_auth_server-0.1.0/src/caselaw_auth_server/__init__.py +29 -0
- caselaw_auth_server-0.1.0/src/caselaw_auth_server/server.py +450 -0
- caselaw_auth_server-0.1.0/tests/test_conformance.py +180 -0
|
@@ -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
|