sage: Q175 SSO design r3 for #412, Filbert r2 C1 (shared loader, config.json inside the phase 2 window, rollback order, test 25) and C2 (401 signed-out page, test 24), notes and Q-E

This commit is contained in:
2026-10-20 13:39:21 -05:00
parent 5d019d8768
commit 817e8f51b0
+121 -31
View File
@@ -1,9 +1,23 @@
# Q175 design: the Console verifies SSO itself (Vikunja #412)
Author Sage, 2026-10-20, r2. Reviewer Filbert, and the review comes before any code. Line numbers are at
Author Sage, 2026-10-20, r3. Reviewer Filbert, and the review comes before any code. Line numbers are at
refactor 4ca8d6e94 unless a line says otherwise. This replaces Part B (row 178 proxy proof, #385 comments 345
and 353). #385 stays open until the Console stops trusting `X-authentik-username`.
## Changes from r2 (Filbert's r2 review, CHANGES)
- C1: the `console` key goes into the shared loader, scripts/mosaic-config.mjs, which today refuses any
unknown top-level key (:113). Section 4 names the blast radius. Jason step 4 moves inside the phase 2
window, after the pin and before the Console starts. The phase 2 rollback removes `console` first. A
test-config.sh case covers it (test 25). Mos confirms the sequence before phase 1 (section 5).
- C2: under `--sso` the page treats a 401 `sign-in required` as signed out and links to
`/auth/login?return=<path>` (section 5 phase 1, test 24).
- Notes: tests 7 and 15 are built so that only the record rule refuses; test 9a is an alg test; Q-B's two
classes are defined, and test 23 adds a 200 HTML page; the phase 2 scratch Console uses a scratch config,
a dummy slot, no `--send` and no `--tester-port`.
- Q-E: settled as Filbert ruled. The `subject-unknown` line keeps iss and sub, JSON-escaped and capped
(section 1, tests 10 and 19).
## Changes from r1 (Filbert's r1 review, CHANGES)
- B1: the provider config moves to `~/.config/mosaic-dev/config.json` (section 4). Mos agreed (mos-0973). The
@@ -117,8 +131,19 @@ Logging (B3). A refusal logs one line: a fixed code from a closed list (`login-r
`alg-refused`, `issuer-mismatch`, `subject-unknown`, `discovery-failed`, `jwks-failed`) and the provider id.
It never logs the exception, its message or its `cause`. oauth4webapi's errors carry token bodies and claims
in `cause`, so even a message can leak. The one extra field is on `subject-unknown`: it logs `iss` and `sub`,
because Jason step 3 reads his `sub` from that line. Neither is a credential. Test 19 asserts that no log
line holds a token, code, state, nonce, verifier, client secret or session id.
because Jason step 3 reads his `sub` from that line (Q-E, Filbert's ruling). Authentik's default subject mode
is a hashed user id that its admin view doesn't show, so the line is the practical source. Neither value is a
credential. Three conditions hold:
- Only a token that passed steps 3-6 up to the subject check reaches this line. Every earlier refusal logs
its own code and the provider id only.
- Both values are written with `JSON.stringify` and each is cut to 255 characters first. Any account at the
provider can get a signed token, so a `sub` may hold a newline, and an unescaped one would let a sign-in
forge a log line.
- Test 10 uses a `sub` holding `\n` and a fake `refused sign-in` suffix and asserts one escaped line.
Test 19 asserts that no log line holds a token, code, state, nonce, verifier, client secret or session id.
It exempts only the `iss` and `sub` fields of the `subject-unknown` line.
### 2. Library: oauth4webapi 3.8.8, pinned exactly
@@ -191,8 +216,26 @@ Location (B1, Filbert's ruling, Mos agreed in mos-0973): a `console.sso` key in
through `organizationWriter` (the tester verbs and the chief's `organization.set`), so a provider entry there
could be changed by the agents the Console is meant to keep out. config.json is user-authored and never
auto-written: `scripts/bootstrap.sh` creates it and nothing overwrites it. Adding the key is a Jason step
(section 10), and the config loader's schema gains an optional `console` key with a strict validator. Files
without it still load.
(section 10).
The loader (C1). Every tool reads config.json through one loader, scripts/mosaic-config.mjs. Its `validate`
refuses any top-level key other than `configVersion`, `environment`, `dataRoot` and `execution` with
`unsupported configuration key` (:113). The business, seat, cli, discord, bootstrap, land-work, stack-copy and
conductor-apply code all use it. So phase 1 changes the shared loader, not a Console-only reader:
- mosaic-config.mjs accepts an optional `console` key, and the strict `console.sso` validator (the config
checks below) runs inside the loader. The fail-closed canon holds for every reader.
- The blast radius is the whole stack. A typo in `console.sso` makes every tool refuse the config, not just
the Console. That's the price of one loader, and the reason step 4 writes the key inside a window with a
backup first.
- The other direction breaks too. A copy older than phase 1 refuses a config that has the key. If the key
went in before the phase 2 pin, the live copy (490e2771d3d0 or later) would refuse its own config on the
next read, and the bus, seats and host would stop. After phase 2, a rollback to a pre-phase-1 copy fails
the same way until the key is removed. This is the same trap as the plug-in kind before a rollback.
- Files without the key still load, on old and new copies.
Test 25 in scripts/test-config.sh: a config with a valid `console.sso` loads, a malformed one fails with
the bad key path named, and a config without `console` loads.
No secret value goes in this file. The client secret is a slot reference.
@@ -245,14 +288,20 @@ Discovery checks, run per provider when its discovery succeeds:
- `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.
Unreachable provider (Q-B, Filbert's ruling). Config errors still refuse the start. A provider whose
discovery or JWKS can't be fetched doesn't stop the Console: it starts, that provider's `/auth/login` gets
503 `sign-in unavailable`, and a retry runs with backoff (5 s, doubling, capped at 5 minutes). `/healthz`
stays 200 and adds `sso: { <id>: "ready" | "unreachable" | "refused" }`, so the gates still pass and an
operator can see the state. A provider whose discovery answers but fails a discovery check is `refused`: at
start that refuses the start, because it's a configuration problem, and on a later retry it stays disabled
with the `discovery-failed` code until a restart. Sessions already made keep working while a provider is
unreachable, since a session check needs no network. Test 23 covers the degraded start and the recovery.
Unreachable provider (Q-B, Filbert's ruling). Config errors still refuse the start. Each fetch of the
discovery document or the JWKS lands in one of two classes:
- Unreachable: a network error, a timeout, a status other than 200, or a body that doesn't parse as JSON. An
Authentik maintenance page served as 200 HTML is unreachable, not refused, so it can't block a restart.
- Refused: a JSON document that fails the issuer, alg, S256 or JWKS check.
An unreachable provider doesn't stop the Console. It starts, that provider's `/auth/login` gets 503
`sign-in unavailable`, and a retry runs with backoff (5 s, doubling, capped at 5 minutes). `/healthz` stays
200 and adds `sso: { <id>: "ready" | "unreachable" | "refused" }`, so the gates still pass and an operator
can see the state. A refused provider at start refuses the start, because it's a configuration problem. On a
later retry it stays disabled with the `discovery-failed` code until a restart. Sessions already made keep
working while a provider is unreachable, since a session check needs no network. Test 23 covers the degraded
start, a 200 HTML page, and the recovery.
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.
@@ -262,13 +311,40 @@ them. Each entry has its own issuer, client, algs and subjects, and a session re
1. **Phase 1 (code row).** Add `--sso` to `cli.mjs serve` and `webui-service.sh`. It reads `console.sso` from
config.json. `--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.
old preview set. Phase 1 also carries the loader change and its test-config.sh case (section 4, test 25).
Nothing live changes until the re-pin.
The page (C2). Today app.js signs out only on `res.type === 'opaqueredirect'` (packages/webui/src/public/
app.js:111 and :305), which is the outpost's redirect. Under `--sso` the Console answers an `/api/*`
request without a session with 401 `sign-in required`, so the page would show an error on every 10 s
refresh and never offer a way back. Phase 1 makes both fetch paths treat a 401 whose body has
`error: 'sign-in required'` as signed out, and the signed-out notice links to
`/auth/login?return=<current path and query>`. Other 401s stay errors. The opaqueredirect branch stays
until phase 3, because the preview flags still run behind the outpost until then. Test 24 covers it.
Before phase 1 starts, Mos confirms the config.json sequence in phase 2 (C1).
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 and signs out. The forged-header check (a
request with `X-authentik-username` and no cookie gets 401) runs either on a scratch Console started from
the same copy with the same flags on another port, where an agent may send it, or on live by Mos or Jason
(B6). No agent sends the auth header to the live 7332 (section 8).
instead of the preview flags, after Jason steps 1 to 3. Backup first, STATE after (Q173). The order
inside the window:
1. Back up the store, the pointer, the unit and config.json.
2. Pin the phase 1 copy with the Console stopped. The copy's loader accepts a config with or without
`console`, so the bus and seats keep reading the old config.
3. Jason step 4: the `console.sso` key goes into config.json, written by Jason or by an agent on his go
for that one key. The new loader reads it back (`scripts/mosaic-config.mjs` validate) before anything
starts.
4. Start the Console with `--sso --send --tester-port 7333`.
Acceptance: Jason signs in through the Console flow, sends one message and signs out. The forged-header
check (a request with `X-authentik-username` and no cookie gets 401) runs either on a scratch Console or
on live by Mos or Jason (B6). No agent sends the auth header to the live 7332 (section 8). The scratch
Console runs from the same copy as `serve --sso --port <scratch port>`, with `MOSAIC_CONFIG` pointing at a
scratch config that names the real issuer and a dummy client secret in a scratch slot. It has no
`--send` and no `--tester-port`. Discovery and the JWKS load from the real issuer, and the forged request
needs no sign-in, so the dummy secret is never sent.
Rollback for phase 2: remove the `console` key from config.json first (restore the step 1 backup), then
roll the pointer back. An older copy's loader refuses a config holding the key (section 4), so the other
order stops the bus and seats. The window plan lists that order, and the STATE check after a rollback
reads config.json for the key's absence.
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.
@@ -351,12 +427,14 @@ 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.
and two login cookies. For the replay, the fake OP is set to accept the same code twice, so the second
callback reaches the record lookup and only the take-and-delete rule refuses it.
8. A provider that demands no PKCE. The token request must carry `code_verifier`, and the fake OP refuses a
wrong one.
9. The signature check (B5), three cases: (a) `alg: none`; (b) an RS256 token whose header names a real kid in
the JWKS, with a changed payload and the original signature; (c) an RS256 token signed by a key whose kid
isn't in the JWKS, even after the refetch. (b) is the case only the signature check catches.
isn't in the JWKS, even after the refetch. (a) is refused by the alg check in step 4, so it's an alg test
like test 2. Only (b) and (c) reach step 5, and (b) is the case only the signature check catches.
10. A valid token for a `sub` not in `subjects`: 403 and one `subject-unknown` log line with the provider id,
iss and sub only.
11. Start refusals: `algs` holding `HS256` or `none`, HS-only discovery (the mosaic-preview shape), an empty
@@ -373,7 +451,9 @@ More refusals:
14. Open redirect: `return=//evil.example`, `return=https://evil.example`, `return=/\evil.example` and
`return=/%09/evil.example` all land on `/`, and `return=/board?x=1` lands there.
15. Provider mix-up. Two providers, A and B. A login started for A whose callback carries B's `iss` and a
code from B is refused, and a `provider=` query on the callback is ignored.
code from B is refused, and a `provider=` query on the callback is ignored. B's code is issued against
A-record's PKCE challenge, so the verifier matches and only the record rule (the callback belongs to A)
refuses it.
16. Several algs: a provider with `algs: ["RS256", "ES256"]` takes a token signed with either, and refuses
PS256 even when the JWKS holds a usable PS256 key.
17. Login record cap: 33 started logins leave 32 records, and the first login's callback finds no record.
@@ -387,8 +467,16 @@ More refusals:
22. Host: under `--sso`, a loopback Host on `/`, `/api/board` and a write each get 403; `/healthz` on
loopback gets 200.
23. Degraded start: the fake OP is down at start; the Console starts, `/auth/login` is 503, `/healthz` reports
`unreachable`; the OP comes up and the next retry makes sign-in work. A provider that answers with HS-only
discovery at start refuses the start.
`unreachable`; the OP comes up and the next retry makes sign-in work. A discovery URL that answers 200
with an HTML page also counts as `unreachable` and the Console starts. A provider that answers with
HS-only discovery at start refuses the start.
24. Signed-out page (C2). A page test in the board-held.test.mjs style stubs fetch to answer 401
`{ error: 'sign-in required' }`: the page shows the signed-out notice, its link is
`/auth/login?return=<current path and query>`, and the refresh stops. A 401 with another error stays an
error. The existing opaqueredirect case still signs out.
25. Loader (C1), in scripts/test-config.sh: a config with a valid `console.sso` loads; a malformed one (an HS
alg, a missing slot, an unknown key under `console`) fails and names the bad key path; a config without
`console` loads.
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.
@@ -419,17 +507,19 @@ Mutants for the gate, each killed by the named test:
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 `subject-unknown` log line on his
first sign-in attempt.
4. The `console.sso` entry in `~/.config/mosaic-dev/config.json`. He writes it, or he gives an agent the go to
write that one key from the values in steps 2 and 3, with a backup of the file first. config.json is
user-authored, so this isn't covered by Q173.
4. The `console.sso` entry in `~/.config/mosaic-dev/config.json`, inside the phase 2 window, after the pin
and before the Console starts (section 5). He writes it, or he gives an agent the go to write that one
key from the values in steps 2 and 3, with a backup of the file first. config.json is user-authored, so
this isn't covered by Q173. Writing it earlier stops every tool on the live copy, whose loader refuses
the key.
5. 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.
6. The phase 2 re-pin window go.
## Questions for review
Q-A to Q-D are settled (Filbert r1, Mos mos-0973). One new point for Filbert:
Q-A to Q-E are settled (Filbert r1 and r2, Mos mos-0973). One open point, for Mos:
- Q-E: the `subject-unknown` log line carries `iss` and `sub` (section 1, Logging), as the only exception to
"fixed code and provider id only", so Jason step 3 can work. Keep it, or log only the provider id and have
Jason read his `sub` from Authentik's admin view?
- The config.json sequence (C1): the key goes in only inside the phase 2 window, after the pin and before
the Console starts, with a backup first, and a rollback removes it before the pointer moves (section 5).
Mos confirms this before phase 1 starts.