sage: Q175 SSO design for #412, OIDC relying party on oauth4webapi 3.8.8, for Filbert review (mos-0970, mos-0971)

This commit is contained in:
2026-10-20 13:13:38 -05:00
parent 4ca8d6e949
commit 7f57419b38
+315
View File
@@ -0,0 +1,315 @@
# Q175 design: the Console verifies SSO itself (Vikunja #412)
Author Sage, 2026-10-20. Reviewer Filbert, and the review comes before any code. Line numbers are at refactor
4ca8d6e94. This replaces Part B (row 178 proxy proof, #385 comments 345 and 353). #385 stays open until the
Console stops trusting `X-authentik-username`.
## The ruling this implements
Jason, DECISIONS Q175, verbatim: "Q1: yes. This should be a requirement for SSO usage. Whether we are using
Pocket ID, Authentik, WorkOS, or another provider (we should support multiple), the system should use best
practices for jwt or other methods of validation that do not rely on hacks to pass the auth." He added "Zero
trust with verification" and confirmed five points (mos-0970):
1. The stack verifies every SSO login itself. It never takes identity from a proxy header or a shared secret.
2. The Console is an OIDC relying party: discovery from the issuer URL, authorization code with PKCE, the ID
token checked against the provider JWKS (signature, iss, aud, exp, nonce), then its own session cookie.
3. Several providers can be configured side by side.
4. Only asymmetric signing counts. HS256 is refused.
5. A proxy-forwarded token such as `X-authentik-jwt` counts only through the same checks. It is optional and
never the only way in.
## What exists today
The live Console runs from copies/490e2771d3d0 with `--public-host mosaic.woltje.com --auth-header
X-authentik-username --allow-user jason.woltje --send --tester-port 7333`.
- `previewOptions` (packages/webui/src/serve.mjs:205) builds `{ host, header, user }`.
- A request is `named` when its Host equals the public name (:493). A named request passes sign-in when it
carries exactly one auth header value equal to the user (:517-521). That header value is the whole identity.
- Requests with a loopback Host on 7332 get the unauthenticated read path (:509-512). Writes need `named` (:526-546).
- `proofOptions` (:217) is the row 178 shared-secret header. Q175 point 1 rules it out, so it retires with the
auth header.
- The tester listener (`--tester-port`, row 176) binds 127.0.0.1. Its writer is fixed as
`{ tester: true, user: 'tester' }` (:522), it allows only the instance, route, name and chat writes (:523),
and it refuses the auth and proof headers (:486-487). 7332 refuses `x-mosaic-tester` (:485).
- The public path is Traefik, then the Authentik proxy outpost (provider mosaic-preview, HS256, empty JWKS),
then chisel `R:0.0.0.0:7332:127.0.0.1:7332`, then the Console. Every public request reaches the Console from
127.0.0.1 with whatever Host the outpost forwards.
## Design
### 1. The Console becomes an OIDC relying party
New module `packages/webui/src/sso.mjs`, used only by the human listener (7332). Routes:
| Route | Method | What it does |
|---|---|---|
| `/auth/login` | GET | One provider configured: start its flow. Several: a plain page listing them, each a link to `/auth/login?provider=ID`. |
| `/auth/callback` | GET | Finish the flow, create the session, 303 to the saved return path. |
| `/auth/logout` | POST | Same-origin checks, delete the session, 303 to the provider's `end_session_endpoint`. |
| `/auth/signed-out` | GET | Static "signed out" page with a sign-in link. |
The flow per provider:
1. Start. Generate a state, a nonce and a PKCE verifier (`generateRandomState`, `generateRandomNonce`,
`generateRandomCodeVerifier`), and a challenge with `calculatePKCECodeChallenge` (S256 only). Put them, the
provider id and the return path in a server-side login record keyed by a random id. Set
`__Host-mosaic_login` to that id. 303 to `authorization_endpoint` with `response_type=code`,
`scope=openid profile email`, `code_challenge_method=S256`, `redirect_uri`, `state` and `nonce`.
2. Callback. Read exactly one `__Host-mosaic_login` cookie, then take and delete its record (single use, so a
replayed callback finds nothing). Expire the cookie in the response. The provider comes from the record,
never from the query, which stops a mix-up between providers.
3. `validateAuthResponse(as, client, url, record.state)` checks state, refuses an error response, and checks
the `iss` parameter when the provider advertises RFC 9207.
4. `authorizationCodeGrantRequest(as, client, clientAuth, params, redirectUri, record.verifier)`, then
`processAuthorizationCodeResponse(as, client, response, { expectedNonce: record.nonce, requireIdToken: true })`.
That checks iss, aud (and azp when aud has several values), exp, iat and nonce, and checks `alg` against
`client.id_token_signed_response_alg`.
5. Signature. oauth4webapi does not verify the signature of an ID token that came straight from the token
endpoint over TLS (OIDC Core 3.1.3.7 allows that, and the library docs for
`validateApplicationLevelSignature` say so). Point 2 asks for the JWKS check, so the callback always calls
`validateApplicationLevelSignature(as, response)` after step 4. It fetches the JWKS from the discovered
`jwks_uri`, picks the key by `kid` and `alg`, and refuses HS* outright.
6. Our own checks on top of the library: `header.alg` is in the provider's configured `algs`, the token's
`iss` equals the configured issuer exactly, and `(iss, sub)` is in the provider's `subjects` list. Anything
else is a 403 that logs `refused sign-in iss=... sub=...` (never a token, code or cookie).
7. Create the session (section 3) and 303 to the return path. The return path must start with one `/` and
is otherwise replaced with `/`.
Every refusal in steps 2-6 ends at a 403 page saying sign-in failed, with a link back to `/auth/login`. No
step falls back to a header.
### 2. Library: oauth4webapi 3.8.8, pinned exactly
Mos's steer (mos-0971) was to prefer oauth4webapi over hand-rolled verification on `node:crypto` unless I
could name a defect or a dependency cost that outweighs it. I found neither.
- MIT, no dependencies, one ESM file (build/index.js, 99,856 bytes). Tarball sha256 `bac088aecc7c...`,
integrity `sha512-8N28E+a/oxfXW...` (the full values go in package-lock.json).
- `supported()` covers PS/ES/RS 256/384/512, Ed25519/EdDSA and ML-DSA. No HS*. RSA keys under 2048 bits are
refused.
- It does discovery with an issuer equality check (`processDiscoveryResponse(expectedIssuer, ...)`), PKCE,
state, the RFC 9207 `iss` parameter, nonce, and JWKS caching.
- The one gap is the default skip of the token-endpoint signature check (section 1 step 5). Calling one
exported function closes it, so it isn't a reason to hand-roll.
Hand-rolling would mean writing JOSE parsing, JWK import, key selection by `kid`, the claim checks and the
discovery checks ourselves, with the same tests and more places to get it wrong.
Pin: `"oauth4webapi": "3.8.8"` in the root package.json `dependencies`, next to the pi pin, with the lockfile
entry. Root is right because `scripts/stack-copy.sh:23-31` copies only the root package.json and lock and runs
`npm ci` there (:55-56). packages/webui/src resolves the import from the copy's root node_modules. This is the
webui's first runtime dependency. The root dependency rule (exact pins, never unversioned) applies to it.
Upgrades are reviewed commits.
### 3. Sessions, cookies and logout
- Session id: 32 random bytes, base64url. The store keys on its sha256, so a heap dump doesn't hold usable ids.
- Cookie `__Host-mosaic_session=<id>; Secure; HttpOnly; SameSite=Lax; Path=/`, no Domain, no Max-Age (a
browser-session cookie). The `__Host-` prefix makes the browser refuse it unless it was set over https on
this exact host.
- Login cookie `__Host-mosaic_login`, same attributes plus `Max-Age=600`. The login record also expires at
10 minutes server-side.
- Lax, not Strict, because the callback is a top-level navigation from the provider's site, and a Strict
cookie set there would be missing on the redirect to `/`. Lax still keeps the session off cross-site POSTs.
The write routes keep their exact `Origin` and `Sec-Fetch-Site: same-origin` checks (:171, :250, :297, :340,
:387, :425), so cross-site writes are refused twice.
- Lifetime: 8 h absolute, 1 h idle, both server-side. The store is in memory with a cap of 64 sessions
(oldest goes first), so a Console restart signs everyone out. That's acceptable for one human and keeps
sessions off disk.
- The session holds `{ provider, iss, sub, name, idToken, created, seen }`. `name` is the subject entry's
label. It goes where `preview.user` goes today (send logs, settings, seat routes), so the downstream writer
shape stays as it is. `idToken` is kept only as the logout hint and never leaves the server.
- Every request on 7332 except `/healthz` and `/auth/*` needs a valid session. Without one, `/api/*` gets
401 JSON and other GETs get a 303 to `/auth/login?return=<path>`.
- Logout: `POST /auth/logout` with the same-origin checks. It deletes the session, expires the cookie, and
303s to `end_session_endpoint?id_token_hint=...&post_logout_redirect_uri=https://mosaic.woltje.com/auth/signed-out&client_id=...`.
If the provider has no `end_session_endpoint`, it goes straight to `/auth/signed-out`.
- Limit: signing out at the provider does not end a Console session until idle or absolute expiry. Back-channel
logout is a follow-up, not phase 1.
### 4. Provider config: shape and location
No secret value goes in this file. The client secret is a slot reference.
```json
"console": {
"sso": {
"publicOrigin": "https://mosaic.woltje.com",
"providers": [
{
"id": "authentik",
"issuer": "https://auth.diversecanvas.com/application/o/mosaic-stack/",
"clientId": "<from Jason>",
"clientSecret": { "slot": "console-sso-authentik" },
"algs": ["RS256"],
"subjects": [ { "sub": "<from Jason>", "name": "jason.woltje" } ]
}
]
}
}
```
Location: a new optional `console` key in the business file (`~/.config/mosaic-dev/businesses/<id>.json`). The
Console already serves one business (`--business`), the identities map to that business's human, and Q173
lets agents edit the test business with a backup first. Adding the key means adding `console` to `TOP_KEYS`
(packages/business/src/business.mjs:21) with a strict validator. It's additive, and existing files without
it still load. The alternative is `~/.config/mosaic-dev/config.json`, which is user-authored and never
auto-written. That fits a deployment setting well, but it puts per-business identity in system config.
**Filbert, rule on this one.**
The slot is `~/.config/mosaic-dev/secrets/mosaic-stack/<slot>`, read at start. The Console refuses to start if
the file isn't a regular file, isn't owned by the Console's uid, has any group or other mode bits, or
resolves under the repo or a stack copy. The value is never logged or put in a refusal message.
`stack-release.sh rehearse` copies a backup onto a scratch root. A rehearsed Console must not use the live
redirect URI, so rehearse strips `console.sso` from the scratch business file and the rehearsed Console runs
without SSO (tester listener only). That's a row 163 follow-up, named in the build brief.
Startup checks for each provider, all fail-closed (the Console doesn't start, and the message names the
provider and the check):
- `issuer` is https with no query or fragment. Discovery returns that issuer exactly.
- `id_token_signing_alg_values_supported` shares at least one alg with `algs`, and `algs` holds only
asymmetric algs from the library's list. mosaic-preview advertises HS256 only, so the Console refuses it at
start.
- `code_challenge_methods_supported` includes `S256`.
- `jwks_uri` returns at least one key usable for one of `algs`. The empty mosaic-preview JWKS fails here too.
- `subjects` is non-empty, and each `sub` is 1..255 visible characters.
- `client.id_token_signed_response_alg` is set to `algs[0]`, so the library enforces it per token.
- Provider ids are unique and `[a-z0-9-]{1,32}`.
Starting while the provider is unreachable refuses the start. A Console with no working sign-in serves
nothing, so refusing loudly costs nothing, and the unit restarts it. The tradeoff is that an IdP outage
keeps the Console down until the IdP is back. With every route behind a session, it would be unusable anyway.
Several providers: Pocket ID, WorkOS and other compliant OPs use the same entry shape. The login page lists
them. Each entry has its own issuer, client, algs and subjects, and a session records which provider made it.
### 5. Retiring `--auth-header`, `--allow-user` and the proof header
1. **Phase 1 (code row).** Add `--sso` to `cli.mjs serve` and `webui-service.sh`. It reads `console.sso` from
the business file. `--sso` cannot be combined with `--auth-header`, `--allow-user` or `--proof-*`. Under
`--sso`, `named` means "has a session", and `writer` comes from the session. `--send` needs `--sso` or the
old preview set. Nothing live changes until the re-pin.
2. **Phase 2 (Jason-gated window).** Re-pin to the phase 1 commit with `--sso --send --tester-port 7333`
instead of the preview flags, after the Jason steps below. Backup first, STATE after (Q173). Acceptance:
Jason signs in through the Console flow, sends one message, signs out, and a forged-header request with no
cookie gets 401.
3. **Phase 3 (code row).** Remove `previewOptions`, `proofOptions` and their tests. `cli.mjs` and
`webui-service.sh` refuse the old flags with `--auth-header was retired by Q175; use --sso`. Close #385
with the phase 2 STATE and the phase 3 commit.
The Authentik proxy outpost can stay in front. It still sends `X-authentik-*` headers, and the Console never
reads them. A test proves that a request carrying them gets nothing without a session cookie.
### 6. The tester port and the N3 tester identity
The tester listener exists so agents can test as the tester and never as Jason (mos-0870 N3). Under SSO:
- It never mounts `/auth/*` (404), never reads a cookie, and refuses any request carrying `Authorization` or a
`__Host-mosaic_session` / `__Host-mosaic_login` cookie (403 `tester listener takes no sign-in`). The same
goes for `X-authentik-jwt` and the old auth and proof header names.
- Its writer stays the fixed `{ tester: true, user: 'tester' }`, and it allows only the row 175 writes, as
today. No session or header can change it.
- Its bus calls stay on `TESTER_CLI`. The bus checks the tester proof and refuses human verbs from it, and the
broker refuses a business whose human is `tester` and any role named `tester`. So nothing that reaches 7333
can become the human.
- 7332 keeps refusing `x-mosaic-tester` for every Host (:485).
- `subjects` validation refuses an entry named `tester`.
### 7. Point 5, the proxy-forwarded token
Not in phase 1. Point 5 makes it optional and the code flow covers the ruling. If it's built later:
- Verify with jose (MIT, exact pin), against the JWKS from the configured issuer's own discovery. Never use
the `X-authentik-meta-jwks` header, because it arrives with the request and the forger controls it.
- Use the same alg allow list, iss, aud (the proxy provider's client id), exp and subject list.
- The mosaic-preview provider signs with HS256 and has an empty JWKS, so its `X-authentik-jwt` fails every
check until Jason gives it an RSA or EC key (Jason step 1).
### 8. Interim exposure until phase 2
Today, with `--send` live, any same-uid process on dragon-lin can connect to 127.0.0.1:7332 with
`Host: mosaic.woltje.com`, `X-authentik-username: jason.woltje`, the matching Origin and
`Sec-Fetch-Site: same-origin`, and send as Jason. That includes agent seats. Through the tunnel, the outpost
strips client copies of the header, so a remote forger needs local code execution first. Users on other uids
can reach the loopback port too (P19 333).
Recommendation: Mos restarts the Console without `--send` until phase 2. Reads stay up, the header path stops
carrying writes, and only Jason's sends through the Console stop. That's a Mos call (Console start belongs to
Jason or Mos). Until then, no agent sends a request with the auth header to 7332. The tests use fixture servers.
To be clear about the limit: SSO protects Console ingress. It doesn't protect the bus's human boundary. The
human proof is path-bound (#225), so a same-uid process can already run the tree's `human-cli.mjs` and act
as the human without touching the Console. Q175 doesn't change that. P19 333 owns it.
### 9. Tests
All tests run against a fake OP in `packages/webui/tests/helpers/fake-oidc.mjs`. It's a loopback https
server with a self-signed cert. Each test passes the library a `customFetch` that trusts only that cert, so
production code needs no insecure-request flag. The fake OP has discovery, a
JWKS, and authorization, token and end-session endpoints, with keys made by `node:crypto` (RSA 2048, P-256, and an
HMAC secret for the forgeries). No test reaches a real provider. Each refusal test asserts the status, that no
`__Host-mosaic_session` was set, and that the bus send stub was never called. Each test also passes once
unmutated, as the control.
Refusals Mos named:
1. Forged header. `X-authentik-username: jason.woltje` with `Host: mosaic.woltje.com`, the right Origin and
Fetch Metadata, and no cookie: 401 on `/api/bus/message`, `/api/settings` and `/api/config`, and a 303 to
login on `/`.
2. HS256 ID token. The fake OP signs the ID token with HS256, using the client secret as the key (the classic
confusion) and `kid` set to a real RSA kid: refused at the callback.
3. Wrong aud. A correctly signed RS256 token with `aud` set to another client id: refused.
4. Expired token. A correctly signed token with `exp` 10 minutes ago (beyond the library's clock tolerance):
refused.
More refusals:
5. Wrong `iss`, both a different issuer and a trailing-slash variant.
6. Nonce mismatch or missing nonce.
7. State mismatch, a missing login cookie, a replayed callback (record already taken), an expired login record,
and two login cookies.
8. A provider that demands no PKCE. The token request must carry `code_verifier`, and the fake OP refuses a
wrong one.
9. `alg: none`, and an RS256 token signed by a key not in the JWKS.
10. A valid token for a `sub` not in `subjects`: 403 and one log line with iss and sub only.
11. Startup refusals: HS-only discovery (the mosaic-preview shape), an empty JWKS, no S256, an issuer mismatch,
a slot with mode 0644, and `--sso` together with `--auth-header`.
12. Tester port: a valid session cookie (taken from a 7332 sign-in), `Authorization: Bearer <valid id token>`,
and `X-authentik-jwt` each get 403. `/auth/login` on 7333 gets 404.
13. Session: idle expiry, absolute expiry, a logout followed by reuse of the old cookie, and logout without
same-origin (refused, session kept).
14. Open redirect: `return=//evil.example` and `return=https://evil.example` both land on `/`.
Positive paths: an RS256 and an ES256 provider each complete sign-in and one send, and two providers configured
together each work with their own subjects.
Mutants for the gate: drop the `validateApplicationLevelSignature` call (test 2 or 9 must fail), drop the
`subjects` check, accept HS* in `algs`, take the provider from the query, let the tester listener read
cookies, and skip the login record delete.
### 10. Jason-gated steps
1. Authentik mosaic-preview: give it an RSA or EC signing key or retire it, and confirm by an admin read that
client_id `GEQ84kBbt2RG6fhYQGgZl2J9jgjw6wRJ3oSWxqZQ` belongs to it. (Ask B matched it only on scopes and
redirect_uri.)
2. Authentik mosaic-stack provider: register redirect URI `https://mosaic.woltje.com/auth/callback` and
post-logout URI `https://mosaic.woltje.com/auth/signed-out`, keep RS256, and give the client id. Put the
client secret into slot `console-sso-authentik` (or say an agent may create the slot file from a value he
puts in place, per the Q173 secrets rule). Agents never see the value.
3. His `sub` for that provider, or a go to read it from the Console's one refusal log line on his first
sign-in attempt.
4. Any Traefik or outpost change. None is needed while the outpost stays in front. Dropping the outpost
later, so the Console is the only gate, changes who can reach the Console from outside and is his call.
5. The phase 2 re-pin window go.
## Questions for review
- Q-A: the config location, business file `console` key or config.json (section 4).
- Q-B: refuse to start when the IdP is unreachable, or start and refuse sign-ins (section 4).
- Q-C: should the loopback-Host read path on 7332 also go behind the session, as proposed? Public traffic
arrives from 127.0.0.1 too, and only Host keeps it off that path today.
- Q-D: 8 h absolute and 1 h idle, or shorter.