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:
@@ -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.
|
||||
Reference in New Issue
Block a user