initial commit
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled
This commit is contained in:
commit
951dd8d26b
175 changed files with 50041 additions and 0 deletions
101
docs/adr/0003-stateful-sessions.md
Normal file
101
docs/adr/0003-stateful-sessions.md
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
# ADR 0003: Stateless signed session cookies
|
||||
|
||||
- **Status:** accepted
|
||||
- **Date:** 2026-10-04
|
||||
|
||||
## Context
|
||||
|
||||
The administration API needs a session. The service may run more than one replica
|
||||
behind a load balancer, and the login callback may be handled by a different pod
|
||||
from the request that started it.
|
||||
|
||||
## Decision
|
||||
|
||||
A session is a signed JSON payload in an `HttpOnly`, `Secure`, `SameSite` cookie.
|
||||
There is no server-side session table.
|
||||
|
||||
## Rationale
|
||||
|
||||
**The load-balancer problem.** With a server-side session store, a session created
|
||||
by one replica must be readable by another. That means either sticky sessions (which
|
||||
lose the pod on every rollout) or a shared store (Redis or a database table), which
|
||||
is the operational surface [ADR 0001](0001-embedded-sqlite.md) set out to avoid.
|
||||
A signed cookie removes the question entirely.
|
||||
|
||||
**The transaction state has the same problem.** The OIDC flow carries a PKCE
|
||||
verifier and a return path from the authorize redirect to the callback. Keeping that
|
||||
in server-side state reintroduces the same requirement. It is a second signed cookie,
|
||||
read and consumed exactly once.
|
||||
|
||||
**The lifetime is short.** An administrative session is eight hours by default.
|
||||
The cost of not being able to revoke a session before it expires is bounded by that
|
||||
window.
|
||||
|
||||
**What it costs.** Two things, stated plainly:
|
||||
|
||||
1. **Revocation.** A stolen session cannot be invalidated before it expires. The
|
||||
mitigations are an eight-hour lifetime and, for a full invalidation, rotating the
|
||||
signing key, which logs out every session at once. Rotating is a deployment, not
|
||||
a button.
|
||||
2. **Payload size.** The cookie carries identity and roles. That is a few hundred
|
||||
bytes, comfortably inside the 4 kB browser limit. A larger claim set would push
|
||||
against it.
|
||||
|
||||
## What was rejected
|
||||
|
||||
**A server-side session table in SQLite.** Would work, and would add revocation.
|
||||
Rejected because it adds a table, a write on every login, a sweep job, and a
|
||||
migration path for a problem the eight-hour window already bounds.
|
||||
|
||||
**Redis.** Would work. Rejected as operational surface for the same reason the
|
||||
render cache stayed process-local.
|
||||
|
||||
**JWT as a bearer token in localStorage.** Rejected on security grounds. A bearer
|
||||
token in `localStorage` is readable by any script on the page, so one XSS bug
|
||||
becomes a credential theft. An `HttpOnly` cookie is not readable by script, and
|
||||
`SameSite=Lax` plus the session cookie's scope mean a cross-site form post cannot
|
||||
ride it. The session is a cookie precisely so that it is not script-accessible.
|
||||
|
||||
**Signed but not encrypted.** The payload is readable by the user holding the
|
||||
cookie. It contains a subject, an email, a display name and roles — no secrets. It
|
||||
is signed, not encrypted, so that a client can read it and debugging is possible.
|
||||
Encrypting it would add a key for no confidentiality gain on this content.
|
||||
|
||||
## Implementation requirements
|
||||
|
||||
Every one of these is a security property, and each has a test:
|
||||
|
||||
- HMAC-SHA256 signature, compared with `subtle.ConstantTimeCompare`. A
|
||||
byte-by-byte early return leaks the correct prefix length to an attacker measuring
|
||||
responses.
|
||||
- `HttpOnly`, so script cannot read it.
|
||||
- `Secure` in any non-development environment, enforced by configuration
|
||||
validation.
|
||||
- `SameSite`, defaulted to `Lax`. `None` requires `Secure`, enforced by validation,
|
||||
because browsers reject the combination and the session would silently never
|
||||
survive a redirect.
|
||||
- Payload expiry, checked server-side on every request. The cookie's own `Max-Age`
|
||||
is a client hint, not a control.
|
||||
- An absent or invalid role set defaults to viewer. A cookie carrying no roles must
|
||||
never be treated as carrying privileges.
|
||||
- The signing secret is at least 32 bytes, and an ephemeral one is generated in
|
||||
development rather than requiring configuration that does not matter locally.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Good**
|
||||
|
||||
- Any replica can authenticate any request, with no stickiness and no shared store.
|
||||
- A rollback of the Deployment does not log anyone out.
|
||||
- Nothing to sweep, nothing to migrate.
|
||||
|
||||
**Bad**
|
||||
|
||||
- No per-session revocation. Bounded by the session lifetime.
|
||||
- Rotating the key logs out everyone, not one session.
|
||||
|
||||
**Neutral**
|
||||
|
||||
- Logout must clear the cookie rather than delete a record, so a client that keeps
|
||||
a copy can still replay until expiry. Mitigated by the short lifetime.
|
||||
- The payload is readable by its holder. Acceptable because it contains no secrets.
|
||||
Loading…
Add table
Add a link
Reference in a new issue