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