Compare commits

..
23 changed files with 541 additions and 1812 deletions
-178
View File
@@ -1,178 +0,0 @@
# Row 192: Q175 phase 1, the Console verifies sign-in itself (`--sso`)
Launchpad, 2026-10-20. Author Launchpad, reviewer Webby, gate owner Sage. Issue #1690. Brief
`docs/plans/2026-10-20_sso-phase1.md`. Design `agents/sage/work/q175-sso/DESIGN.md` (blob 9e3eadb15c80).
Rulings sage-1340 (F1 to F6) and sage-1347 (F7, F8). Plan launchpad-0001.
Base 53bb59462 (`base.txt`). Candidate: branch `launchpad/row192`, r1 83b32d8d8 and the round 2 commit on it.
`candidate-manifest.sha256` lists the 18 changed code, test and doc paths; the packet files are not in it. Drift
check at the r2 commit: `git log 53bb59462..origin/refactor` (origin/refactor ae3c36f24) touches none of the 18 paths.
No request in any test goes to auth.diversecanvas.com, mosaic.woltje.com or 127.0.0.1:7332. Every provider
is the fake OP in `tests/helpers/fake-oidc.mjs`: https on 127.0.0.1 with a throwaway self-signed certificate,
and a fetch that refuses any other origin. The public Host in the tests is `console.example.test`, sent as a
header to the test's own loopback listener. No test reads or writes `~/.mosaic-dev`, `~/.config/mosaic-dev`,
`~/.mosaic`, a slot file outside its temp dir, or a live unit.
## Files
| Path | Change |
| --- | --- |
| `package.json`, `package-lock.json` | `oauth4webapi` 3.8.8, exact, at the root (design: the copy runs `npm ci` there). No other dependency. |
| `scripts/mosaic-config.mjs` | Accepts an optional `console.sso` key and checks it with `checkConsole`. Without the key, output is byte-identical (F2). |
| `scripts/test-config.sh` | Test 25: a good `console.sso` passes; each refusal class refuses. 31/31. |
| `packages/webui/src/sso-config.mjs` (new) | `checkConsole`: shape, `publicOrigin`, 1 to 8 providers, issuer https with a trailing slash, slot names, `ALGS` (asymmetric only), 1 to 64 subjects. |
| `packages/webui/src/sso.mjs` (new) | The relying party: `readSlot`, discovery and JWKS with retry, `/auth/login`, `/auth/callback`, `/auth/logout`, sessions, `health()`, `summary()`, `formTargets()`. |
| `packages/webui/src/serve.mjs` | Under `sso`: public Host only on the human listener (F4), `/auth/*` routes, session check before every page and API route, CSP `form-action` adds the end-session origins, tester listener refusals (F8). |
| `packages/webui/src/cli.mjs` | `--sso`, its refusals, slot reads, the start lines, `close()` on stop. |
| `packages/webui/src/chat-http.mjs` | Under `--sso` the session check already ran, so the header-user check is skipped; the Origin check uses `publicOrigin`. |
| `packages/webui/src/public/app.js`, `bus.js`, `index.html` | 401 `sign-in required` signs the page out with a link to `/auth/login?return=...` (design test 24, F1). The Sign out form shows only when `/api/config` says `sso` (F7). |
| `scripts/webui-service.sh` | Passes `--sso` through; `--send` refusal text names `--sso`. |
| `packages/webui/tests/service.test.mjs` | The `--send` refusal regex follows the new text. |
| `packages/webui/README.md` | New section "Sign-in in the Console (Q175 phase 1, #1690)". |
| `packages/webui/tests/helpers/fake-oidc.mjs` (new) | The fake OP. |
| `packages/webui/tests/sso.test.mjs` (new) | 28 tests: three positives and design tests 1 to 23 (some split in parts). |
| `packages/webui/tests/sso-browser.test.mjs` (new) | 2 tests: design test 24 with F1, and F7. |
## Tests run (single files, this worktree, final tree)
| File | Pass | Fail |
| --- | --- | --- |
| sso | 28 | 0 |
| sso-browser | 2 | 0 |
| pinned-copy | 10 | 0 |
| serve | 4 | 0 |
| preview | 11 | 0 |
| proof | 4 | 0 |
| proof-cli | 3 | 0 |
| send | 13 | 0 |
| service | 31 | 0 |
| tester-listener | 10 | 0 |
| tester-cli | 2 | 0 |
| board-held | 3 | 0 |
| send-browser | 2 | 0 |
| docs-browser | 1 | 0 |
| chat | 23 | 0 |
| chat-browser | 11 | 0 |
| bus-browser | 2 | 0 |
| settings-write | 4 | 0 |
| project-file | 10 | 0 |
| instances | 5 | 0 |
| routes | 5 | 0 |
| `scripts/test-config.sh` | 31 | 0 |
The first singles pass failed `pinned-copy` 1 of 10: `ERR_MODULE_NOT_FOUND: Cannot find package
'oauth4webapi'` from a copy's `sso.mjs`. The test's fixture repo writes an empty package.json and lock, so the
copy's `npm ci` installs nothing, and `sso.mjs` imported oauth4webapi at module load. `cli.mjs` imports
`sso.mjs` on every start, so plain `serve` broke. Fix: `sso.mjs` loads oauth4webapi inside `createSso`
(`oauth ??= await import('oauth4webapi')`). A Console without `--sso` never resolves the package. A real copy
still has it from the root lock. Mutant 21 restores the static import and `pinned-copy` kills it. After the
fix: pinned-copy, sso, sso-browser and serve rerun green (counts above).
Not run by me (slot work, busy at the time): the full webui package suite, the cli suite, and
`scripts/test-*.sh` other than test-config. `packages/cli/tests/stack-copy.test.mjs` and
`stack-release.test.mjs` also build copies with an empty lock and are the ones to watch for the same
failure. They need Sage's gate.
## Mutants (`mutants.py`, run as `python3 mutants.py <tree> [N ...]`)
Each mutant edits one source file, runs one test file filtered to one test name, and restores the file
checked by sha256. Final run on the final tree: `/mnt/storage/scratch/launchpad-r192/mutants-final.log`.
| # | Mutant | Test | Result |
| --- | --- | --- | --- |
| 1 | drop validateApplicationLevelSignature | sso 9 | KILLED |
| 2 | wrap the signature call in a continuing try/catch | sso 9 | KILLED |
| 3 | drop the subjects check | sso 10 | KILLED |
| 4 | accept HS256 and none in ALGS | sso 11 start | KILLED |
| 5 | take the provider from the query | sso 15 | KILLED |
| 6 | tester listener reads sign-in cookies (F8) | sso 12 | KILLED |
| 7 | tester listener takes Authorization (F8) | sso 12 | KILLED |
| 8 | tester listener takes x-authentik-* (F8) | sso 12 | KILLED |
| 9 | skip the login record delete | sso 7 | KILLED |
| 10 | drop HttpOnly from the session cookie | sso 20 and 21 | KILLED |
| 11 | refresh last-seen on every request | sso 13 idle | KILLED |
| 12 | build redirect_uri from the request Host | sso 22 | KILLED |
| 13 | serve the loopback read path under --sso (F4) | sso 22 | KILLED |
| 14 | bus.js fetchRead ignores 401 sign-in required (F1) | sso-browser 1 | KILLED |
| 15 | bus.js fetchApi ignores 401 sign-in required (F1) | sso-browser 1 | KILLED |
| 16 | app.js getJSON ignores 401 sign-in required | sso-browser 1 | KILLED |
| 17 | app.js api() ignores 401 sign-in required | sso-browser 1 | KILLED |
| 18 | signInRequired takes any 401 | sso-browser 1 | KILLED |
| 19 | Sign out shown without the sso condition (F7) | sso-browser 2 | KILLED |
| 20 | CSP form-action without the end-session origins | sso 13 CSP | KILLED |
| 21 | import oauth4webapi at module load | pinned-copy | KILLED |
| 22 | restore the r1 relative returnPath (round 2, B1) | sso 14 | KILLED |
| 23 | drop the second origin check (round 2, sage-1403) | sso 14 | SURVIVED, equivalent, guard kept (sage-1407) |
Mutant 16 survived its first run: test 1 had no `getJSON` path. The History click on
`/api/conversations` was added and it is now killed.
## Deviations from the design
- **F1 (sage-1340).** `bus.js` `fetchRead` and `fetchApi` apply the same 401 `sign-in required` rule as
`app.js`, even when `/api/config` has no `sso`. Tested in sso-browser 1, mutants 14 and 15.
- **F7 (sage-1347).** A Sign out form (`POST /auth/logout`) in the command bar, hidden unless `/api/config`
says `sso`. Tested in sso-browser 2, mutant 19.
- **Proof header on 7333 under `--sso` (webby-0019 N2).** `cli.mjs` passes `testerProofHeader: null` under
`--sso`, since `--sso` refuses the proof pair and there is no header name to refuse. A request to 7333 that
still carries the old proof header is not refused for it. It gains nothing: under `--sso` no listener
reads a proof header (`proof` is null in `startServer`). Left as is.
## Assumptions and choices
- **F8 (sage-1347).** Under `--sso` the tester listener refuses a request carrying `Authorization`, either
`__Host-mosaic` cookie or any `x-authentik-*` header, case-insensitive (Node lower-cases header names).
sso 12 sends one of each; mutants 6 to 8.
- **oauth4webapi loads lazily** inside `createSso` (see the pinned-copy failure above).
- **Client authentication** is `ClientSecretBasic`; the fake OP's token endpoint checks Basic auth.
- **Caps**: 1 to 8 providers, 1 to 64 subjects per provider, 64 sessions, 32 pending logins. The 33rd login
drops the oldest record (sso 17).
- **CSP**: under `--sso`, `form-action` adds each provider's end-session origin. Chrome applies
`form-action` to the redirect after the Sign out POST, so without it the logout stops at the Console.
Tested in sso 13 CSP, mutant 20. Without `--sso` the CSP string is unchanged.
- **`--send` refusal text** in cli.mjs and webui-service.sh now ends "or --sso".
- **Start lines**: one `Sign-in provider:` line per provider with id, issuer, subject count and state
(`ready`, `refused` or `unreachable`). No client id, secret or sub.
- **Test 1** uses `console.example.test` as the public Host, not mosaic.woltje.com (no request goes there).
- **Test 11 slot files**: the owner check is not tested; the test can't create a file owned by another uid
without privilege. Symlink, 0644, 0640 and a file under the repository are tested.
- **Test 23** covers a provider down at start: the Console starts, `/auth/login` is 503, loopback `/healthz`
is 200 with `sso: { a: 'unreachable' }`, one `discovery-failed` line, and the retry makes sign-in work. An
HS-only discovery at start refuses the start (in test 23 and in test 11 start).
- **Test 22**: a Host of bare `localhost` without a port is not in the table. It hits the older
`non-local Host refused` check before the sso check; a comment in the test says so.
## Round 2 (webby-0019)
- **B1, open redirect after sign-in.** `returnPath` took any URL on `publicOrigin`, and dot segments can
normalize to a pathname of `//evil.example`, which a browser reads as scheme-relative in a relative
Location. Fix per sage-1403: `returnURL` (renamed) builds an absolute URL, `publicOrigin + pathname +
search`, checks its origin again, and returns its href. Anything else is `publicOrigin/`. The callback's
Location is now always absolute on the Console. Test 14 asserts the exact Location and its origin for each
case. It adds Webby's three rows (`/.//evil.example`, `/x/..//evil.example`,
`https://console.example.test//evil.example`), each now `https://console.example.test//evil.example`, a path
on the Console. It also adds `/board/./x`, which lands on `/board/x`. The positive test expects
`https://console.example.test/board?x=1`.
- **Mutant 22** restores the r1 relative `returnPath`; test 14 kills it.
- **Mutant 23** drops the second origin check. It SURVIVES, and I read it as equivalent: `checkConsole`
holds `publicOrigin` to a bare https origin, and a parsed https pathname always starts with `/`, so
`publicOrigin + pathname` can't parse to another origin. Webby agrees (webby-0022). Sage's ruling
(sage-1407): the check stays as an unreachable guard, and 23 is listed here as equivalent. That is a packet
note, not a candidate change.
- **N1.** The design cite is now blob 9e3eadb15c80 (`git rev-parse 83b32d8d8:agents/sage/work/q175-sso/DESIGN.md`).
- **N2.** Listed under Deviations.
- **N3.** `SIGN_IN_COOKIE` has its own comment, above the row 177 comment and `TESTER_CHAT_WRITE`.
- **N4.** Row 200 (#1698) covers it: `stack-release.sh` refuses `config-console-key` before any stop when the
target's loader refuses the key, on activate and rollback.
- **N5.** No change.
Round 2 runs, this worktree, final tree: `sso.test.mjs` 28/28 (`/mnt/storage/scratch/launchpad-r192/sso-r2.log`);
mutants 22 and 23 (`/mnt/storage/scratch/launchpad-r192/mutants-r2.log`).
## Not done here
- Row 200 (#1698, `stack-release.sh activate --sso`) is chained on this candidate and starts only after the
manifest is with Webby (sage-1369).
- Full suites are for Sage's gate.
-1
View File
@@ -1 +0,0 @@
53bb59462120a03a9f54b02881895e3f575e501c
@@ -1,18 +0,0 @@
95d9a48812eebc415e3605239788e5b0746f1457a3bf0f9b9651a8c745e8b0f6 package.json
390cbb0ef88de6cbc6d8f43b9e212abfdf0ef0e1c88fa02f5ab790caabc53eed package-lock.json
726b71bcfdb4d9409cefc86bed2fbe8e678c6efb50bcf5bb22344aeda1eee201 packages/webui/README.md
f7b6b68906f13a644854b8083287dfa2936e8defc6207d05739e89763cc91440 packages/webui/src/chat-http.mjs
4a2d226bdbfee98b180103f71b3cb8122303cfac4aeece42bd30ce167ba6ff55 packages/webui/src/cli.mjs
41ee8798c34ee491b80ae1b42c3e96cd5d12fb1603b1ab88ac2dd10b5fb27964 packages/webui/src/public/app.js
e6c8f838eb7cdc2ae556c04451ca4c8f2d433117a17f285a144cfc7dff03e2a4 packages/webui/src/public/bus.js
f276fa440ec938638cb3e0f1b812fafa929460a6b96f1dab4cf1076a7c0c18ea packages/webui/src/public/index.html
333d474b1c7e753938aedc639c08f9db0e787faf56aa8068d013fd304a0b3b7e packages/webui/src/serve.mjs
37b01ab0479d5f2a7ab9aac790931c6d88c790465392d9c0b65a105513f3e36b packages/webui/src/sso-config.mjs
b624f044d361c648cefa161abe5f2b96e0cd8a83bc7816c9d501a2cd840988ca packages/webui/src/sso.mjs
84e31d4beba0de5ed6d192921bd7b016296c8b2a42b3fcf1176663e182320d9e packages/webui/tests/helpers/fake-oidc.mjs
ac01037743fcbf1efa641794d3878f113b6206033d2f66cde072a52ccfae1a51 packages/webui/tests/service.test.mjs
9358e3755e174b61532e2c528e733ffcdb48941cbadb825f1b0254bc55d1920e packages/webui/tests/sso-browser.test.mjs
3f8527fd7ae1ffb2bffce3450971f125bd616954b534577ef54249c87f36e248 packages/webui/tests/sso.test.mjs
f8307d4bd998634467fbed8748157754132b76affac051f320196161ce385e4a scripts/mosaic-config.mjs
3301c1fabf616bb961d62f6decd0a46dadb0f399b8ba7f3c2ca60023d2b24c78 scripts/test-config.sh
7678bb6b45861c3dc6d775fc99269b1db7350ced60b89517d6ebeb4cc161781c scripts/webui-service.sh
@@ -1,59 +0,0 @@
# Row 192 (#1690) mutants: `python3 mutants.py <tree> [N ...]`. Each applies to the tree's source, runs one
# test file (optionally one name pattern) and restores the file from a saved copy, checked by sha256.
# Killed = test exit != 0.
import hashlib, os, subprocess, sys, signal
W = os.path.join(os.path.abspath(sys.argv[1]), 'packages', 'webui')
if not os.path.isfile(os.path.join(W, 'src', 'sso.mjs')): sys.exit(f'ABORT no packages/webui/src/sso.mjs under {sys.argv[1]}')
S, B, C, F = 'src/sso.mjs', 'src/serve.mjs', 'src/sso-config.mjs', 'src/public/'
T, P = 'tests/sso.test.mjs', 'tests/sso-browser.test.mjs'
M = [
('drop validateApplicationLevelSignature (9b)', S, [("try { await oauth.validateApplicationLevelSignature(entry.as, response, options()); } catch (err) {", "try { } catch (err) {")], T, '^9:'),
('wrap the signature call in a continuing try/catch (9b)', S, [("try { await oauth.validateApplicationLevelSignature(entry.as, response, options()); } catch (err) {", "try { try { await oauth.validateApplicationLevelSignature(entry.as, response, options()); } catch {} } catch (err) {")], T, '^9:'),
('drop the subjects check (10)', S, [("if (name === undefined) {", "if (false) {")], T, '^10:'),
('accept HS* in algs (11)', C, [("export const ALGS = Object.freeze(['PS256',", "export const ALGS = Object.freeze(['HS256', 'none', 'PS256',")], T, '^11: start'),
('take the provider from the query (15)', S, [("const entry = record && providers.get(record.provider);", "const entry = record && providers.get(query.get('provider') ?? record.provider);")], T, '^15:'),
('let the tester listener read cookies (12, F8)', B, [("SIGN_IN_COOKIE.test(req.headers.cookie ?? '') ||", "")], T, '^12:'),
('tester listener takes Authorization (12, F8)', B, [("Object.hasOwn(req.headers, 'authorization') ||", "")], T, '^12:'),
('tester listener takes x-authentik-* (12, F8)', B, [(" || Object.keys(req.headers).some(h => h.startsWith('x-authentik-'))", "")], T, '^12:'),
('skip the login record delete (7)', S, [("if (key !== null) logins.delete(key);\n // The provider", "// The provider")], T, '^7:'),
('drop HttpOnly from the session cookie (21)', S, [("`${SESSION_COOKIE}=${id}; ${ATTRS}`", "`${SESSION_COOKIE}=${id}; Path=/; Secure; SameSite=Lax`")], T, '^20 and 21'),
('refresh seen on every request (13)', B, [("sso.session(req, { touch: req.method === 'POST' || (req.method === 'GET' && !path.startsWith('/api/')) })", "sso.session(req, { touch: true })")], T, '^13: idle'),
('build redirect_uri from the request Host (22)', S, [("const redirectUri = `${publicOrigin}/auth/callback`;", "let redirectUri = `${publicOrigin}/auth/callback`;"), (" const query = new URLSearchParams(search);\n", " const query = new URLSearchParams(search);\n redirectUri = `https://${req.headers.host}/auth/callback`;\n")], T, '^22:'),
('serve the loopback read path under --sso (22)', B, [("if (sso !== null && !named && !(path === '/healthz' && req.method === 'GET')) return json(res, 403, { error: 'public Host required' });", "")], T, '^22:'),
('bus.js fetchRead ignores 401 sign-in required (F1)', F+'bus.js', [(" if (signInRequired(res, body)) { signOutSso(); throw signedOut(); }\n if (!res.ok || !body || !Array.isArray(body.rows))", " if (!res.ok || !body || !Array.isArray(body.rows))")], P, '^a 401'),
('bus.js fetchApi ignores 401 sign-in required (F1)', F+'bus.js', [(" if (signInRequired(res, body)) { signOutSso(); throw signedOut(); }\n if (!res.ok || !body || typeof body !== 'object'", " if (!res.ok || !body || typeof body !== 'object'")], P, '^a 401'),
('app.js getJSON ignores 401 sign-in required (24)', F+'app.js', [(" if (signInRequired(res, body)) { sso = true; signOut(); throw signedOut(); }\n return { ok: res.ok, body };", " return { ok: res.ok, body };")], P, '^a 401'),
('app.js api() ignores 401 sign-in required (24)', F+'app.js', [(" if (signInRequired(res, result)) { sso = true; signOut(); throw signedOut(); }\n", "")], P, '^a 401'),
('signInRequired takes any 401 (24)', F+'app.js', [("res.status === 401 && body?.error === 'sign-in required'", "res.status === 401")], P, '^a 401'),
('Sign out shown without the sso condition (F7)', F+'app.js', [("if (config.sso === true) { sso = true; $('sign-out').hidden = out(); }", "{ $('sign-out').hidden = out(); }")], P, '^the Sign out'),
('form-action without the end-session origins (CSP)', B, [("form-action 'self'${sso ? sso.formTargets().map(o => ` ${o}`).join('') : ''}`", "form-action 'self'`")], T, '^13: the CSP'),
('import oauth4webapi at module load (copies)', S, [("let oauth;\n", "import * as oauth from 'oauth4webapi';\n"), (" oauth ??= await import('oauth4webapi');\n", "")], 'tests/pinned-copy.test.mjs', '^serve runs from a copy'),
('restore the r1 relative returnPath (14, webby-0019 B1)', S, [(" if (u.origin !== publicOrigin) return home;\n const out = new URL(`${publicOrigin}${u.pathname}${u.search}`);\n return out.origin === publicOrigin ? out.href : home;", " return u.origin === publicOrigin ? u.pathname + u.search : '/';")], T, '^14:'),
('drop the second origin check (14, sage-1403)', S, [(" return out.origin === publicOrigin ? out.href : home;", " return `${publicOrigin}${u.pathname}${u.search}`;")], T, '^14:'),
]
sha = lambda b: hashlib.sha256(b).hexdigest()
only = sys.argv[2:]
os.chdir(W)
survived = []
for i, (name, path, pairs, test, pat) in enumerate(M, 1):
if only and str(i) not in only: continue
orig = open(path, 'rb').read(); h = sha(orig)
src = orig.decode()
for a, b in pairs:
if src.count(a) != 1: sys.exit(f'ABORT {i} {name}: pattern found {src.count(a)} times')
src = src.replace(a, b)
try:
open(path, 'w').write(src)
p = subprocess.Popen(['node', '--test', f'--test-name-pattern={pat}', test], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True)
try: out, _ = p.communicate(timeout=240)
except subprocess.TimeoutExpired:
os.killpg(p.pid, signal.SIGKILL); out, _ = p.communicate(); out += b'\nTIMEOUT'
text = out.decode(errors='replace')
ran = [l for l in text.splitlines() if l.startswith('ℹ tests ')]
verdict = 'KILLED' if p.returncode != 0 else 'SURVIVED'
if verdict == 'SURVIVED': survived.append(name)
print(f'{i:2} {verdict} {name} [{ran[0] if ran else "no count"}]', flush=True)
finally:
open(path, 'wb').write(orig)
if sha(open(path, 'rb').read()) != h: sys.exit(f'ABORT restore failed for {path}')
print('survived:', survived or 'none')
+482
View File
@@ -0,0 +1,482 @@
# Row 193 design: many humans per business, human roles, private and shared threads
Morgana (Opus 5.5), 2026-10-20. Revision 5: sage-1373 item 1 (mos-1023 item 2), the user sentence names every record the owner sees. Revision 4: duckworth-0011 B3 and N5. Revision 3: duckworth-0008 B1, B2 and N1-N4, sage-1368. Revision 2: sage-1354 (5.7 private from the owner, 193b split) and gizmoduck-0018 (coverage and the R4 predicate in FED r4.1). Gitea #1691, queue row 193, brief `docs/plans/2026-10-20_human-users.md`
(blob f7472d1e47b1). Reviewer Duckworth, gate owner Sage. Base: `refactor` at 53bb59462.
Read for this design: COMMON.md (53bb59462), the brief, Gizmoduck's federation design r4.4 (b67bc6de9 on
gizmoduck/federation-design, cited as FED; r3.1 a2840a6be was read first), Duckworth's federation reviews (b402197ff, 03a663cd7,
45cbe5896 on duckworth/reviews), Sage's Q175 SSO design (`agents/sage/work/q175-sso/DESIGN.md`), the row 192
and row 196 briefs, Darkwing's row 190 packet (uncommitted, sha256 5493c9df3ec0...), and the bus, business,
cli and webui code at the lines cited. Messages: morgana-0001 to 0003, sage-1338, gizmoduck-0009,
gander-0002 and gander-0005.
## 1. Summary
- Three human roles: **owner**, **collaborator**, **viewer**. The owner is the business file's one `human`,
as today. Collaborators and viewers are access.json users, the same store federation uses (FED 3.5).
Their role is the `level` of a grant, so a role is per project and instance, not per person.
- Every human principal is minted by one bus verifier, owned by row 196 (Gander). Row 193 states what it
needs from that verifier (section 6) and adds no second mechanism.
- Threads stay private to their owner by default. A thread owner may share a thread, read-only, with a
named user, with the owner, or with everyone granted on it. Shares live in a new append-only bus table,
`thread_shares`. This is a deviation from brief point 4, agreed by Sage (sage-1338), for Duckworth to rule
on (section 5.1).
- A share never widens a grant. It counts only while the reader's live grant covers the thread's project
and instance, and while the thread's owner still has coverage. Revocation, expiry and unsharing take
effect on the next request.
- Existing businesses keep working unchanged: every existing thread is already owned by the business
human, and an instance without access.json has exactly one human, the owner.
- The build splits so the part without a schema change can land first. `thread_shares` needs schema 3j and
a `--migrate` window with Jason.
## 2. What exists today (measured at 53bb59462)
- One `human` per business (`packages/business/src/business.mjs:160`). `bindHuman` refuses any other name
(`packages/bus/src/broker.mjs:532-538`).
- The owner's CLI proof is path-bound (#225): `verifyHuman` checks uid, start time, nonce
(`MOSAIC_BUS_CLI_NONCE`), `argv[1]` equal to the tree's `human-cli.mjs`, then walks parents and refuses
agent markers, launch records and agent binaries (`packages/bus/src/human.mjs:64-96`).
`authenticateHuman` binds the proof to `businesses[business].human` (`packages/bus/src/runtime.mjs:175-182`).
- Threads: `threads(seq,id,at,business,project,instance,owner,name)`, append-only
(`packages/bus/schema.sql:247`, triggers at :310-312). `#owner(s)` returns `tester` or `s.human`
(broker.mjs:364-366). `#thread` refuses a non-owner with `thread-not-owned` (:357-362).
- Thread verbs are gated `if (!s.tester) this.#human(s)`: `project.list` (:2318), `thread.create` (:2325),
`thread.list` (:2351, owner filter), `thread.open` (:2365), `thread.send` (:2374, writes `human.input`
for the human only), `thread.messages` (:2401).
- Board reads are business-wide and human or reader only: `inbox`, `agents`, `tasks`, `trail` (:2143-2167),
`decisions` (:2174), `credentials` (:2195), `messages` (:2217, `to_role='human'`), `replies` (:2422).
`trail` and `replies` return any message in the business by id (:2679-2700, :2422-2432).
- The Console reads threads by polling the same verbs (`packages/webui/src/chat.mjs:22-26`). It has no
stream, so a refusal on the next poll ends access.
- The launcher restarts a thread with pending messages as the business human
(`packages/cli/src/launcher.mjs:533-542`).
## 3. The human model (brief point 1)
### 3.1 Who the humans are
| Human | Where it is declared | How many | Identity |
|---|---|---|---|
| owner | business file `human` (unchanged) | exactly one per business | the business human's name |
| user (collaborator or viewer) | access.json `users[]` with `home: "local"` (or a peer id under federation) | any number | `user:<id>` |
| tester | not a human; row 177 test principal | one | `tester` |
The owner stays outside access.json (FED 9 Q2). A business has no second owner and no admin level. Adding
one is out of scope; section 9 lists it as a follow-up for Jason.
### 3.2 How a sign-in or a call becomes exactly one human
| Entry | Becomes | Checked by |
|---|---|---|
| Console SSO session whose `(provider, sub)` is in `console.sso.providers[].subjects` (row 192) | the owner | the Console after OIDC checks, then the bus verifier (row 196) |
| Console SSO session whose `(provider, sub)` is a `users[].subjects` entry in access.json with a live grant | that one user | same |
| Console SSO session matching neither | nobody: refused | the Console, and the bus refuses any later claim |
| CLI call through `human-cli.mjs` that passes the human proof | the owner | the bus (`verifyHuman`) |
| Any other process, including an agent | nobody | the bus |
Why each is exactly one:
- The R1 loader refuses a `(provider, sub)` pair that appears twice in access.json or also in
`console.sso.subjects` (FED 3.5). So one subject maps to at most one human.
- A user id can't be `human`, `tester` or any business's human (FED 3.5). So a user can't be named as the
owner.
- A terminal maps only to the owner. The only identity the bus can verify for a local process is the
instance's OS user, and that is the owner's. A user has no CLI path in row 193. This follows Mos's B1 rule
(mos-0985): the principal comes from what the bus verifies, never the process's own claim.
- The bus never reads a user name from a request body as identity. The verifier returns the principal
(section 6).
## 4. Human roles (brief point 2)
### 4.1 The three roles
- **owner**: everything the business human does today. Only the owner restructures the organization
(business file writer, project and role changes) and only the owner manages users and grants.
- **collaborator**: FED 3.5's level, unchanged. Creates threads in granted projects on granted instances,
sends into and opens its own threads, and in row 193 also shares its own threads.
- **viewer**: new. Reads threads shared with it, within its grant. It creates, opens and sends nothing, so
it never drives an instance. FED 3.5 notes that a collaborator's message drives the instance with the
owner's role authority (F10). A viewer has none of that.
The word "collaborator" stays, so FED's tests and help text need no rename. The brief's example "member" is
this level.
A role is the `level` on a grant, and a grant is per business, project set and instance set. A user may
hold a collaborator grant on one project and a viewer grant on another. Every check asks "does a live
grant of a level that allows this verb cover this project and instance". A user has no single role.
### 4.2 R1 widening
FED R1 refuses any level but `collaborator`, because the 10-27 slice ships only that (FED 9 Q2).
Row 193 widens R1's level check to `collaborator` or `viewer`, with its own test that each is accepted and
anything else refuses. Nothing else in the access file changes. If R1 has not landed when row 193 builds,
this one-line widening goes into the row 193a candidate chained on R1's candidate (Q177).
### 4.3 Bus verbs per role
Every verb not in a user's column fails `access-refused` before its own checks, as FED 3.5 requires for
collaborators. That covers every owner verb, every board read, `message.*`, `decision.*`, project files,
`spec.read`, `assistant.stop`, `launch.*`, `name.set`, `usage.*` and `access.reload`.
| Verb | owner | collaborator | viewer |
|---|---|---|---|
| `project.list` | all | projects covered by any live grant | projects covered by any live grant |
| `thread.create` | yes | collaborator grant covers project and instance | refused |
| `thread.list` | own threads | own threads still covered | own threads still covered (from an earlier collaborator grant) |
| `thread.list {shared: true}` (new) | threads shared with the owner | threads shared with me and readable (5.4) | same |
| `thread.open` | own | own, collaborator grant still covers | refused |
| `thread.send` | own | own, collaborator grant still covers, no `human.input` | refused |
| `thread.messages` | own, or shared with the owner | own (any level still covering), or shared and readable | same as collaborator |
| `thread.share`, `thread.unshare` (new) | own threads | own threads, collaborator grant still covers | refused |
| `thread.shares` (new) | own threads | own threads | refused |
| `names` | all | instances covered by any live grant | instances covered by any live grant |
| every other verb | as today | refused | refused |
Notes:
- `thread.list` keeps its response shape exactly. Shared threads come only from `thread.list {shared: true}`,
whose rows add `owner`. The Console and existing tests read the plain list unchanged.
- A viewer can still read its own threads when a collaborator grant is lowered to viewer, but can't send
or open them. This is the downgrade path.
- The tester keeps its row 177 verbs. It can't share or be shared with: `thread.share`, `thread.unshare`
and `thread.shares` fail `tester-refused`, and a share target is never `tester`. Agent testers (Q173) test
sharing through the bus suites, not the live 7333 listener.
- `reader` sessions (Console read caps, broker.mjs:1303) are the owner's board view and stay owner-only.
A user never gets a reader session.
### 4.4 Console per role
- owner: as today, plus a share control on each own thread and a "Shared with me" list.
- collaborator: the chat view only, for granted projects (FED R6's granted-user sign-in), plus share
controls on its own threads and "Shared with me". No board.
- viewer: "Shared with me" and its own old threads, read-only. No composer, no open, no share control.
- The Console hides controls a role can't use, but the bus check is authoritative. A Console bug can't
widen a grant (FED 3.6). Each role gets a webui test with the UI check removed (mutant) that the bus still
refuses.
### 4.5 Who manages humans and roles
Only the owner, through `mosaic access` (FED R5), which checks the human proof and refuses tester and agent
callers. Row 193 needs from R5:
- `mosaic access user add <id> --home local --subject <provider>:<sub>` and `user remove <id>`;
- `--level viewer` on `grant`;
- the receipt line in `<dataRoot>/state/access-log.jsonl` and the `access.reload` call, as FED R5 has.
If R5 has no `user` verbs when row 193 builds, row 193c adds them on R5's candidate. The Console gets no
user or grant writes in row 193: a write from the Console would need a new owner verb that writes
access.json from the bus, and access.json is a config file only the owner's CLI writes. A read-only users
page for the owner is a follow-up (section 9).
The owner keeps every restructuring power (TARGET Case 1). Users can't touch the business file, projects,
roles or access.json.
## 5. Private and shared threads (brief point 3)
### 5.1 Deviation from brief point 4, agreed by Sage (sage-1338)
Brief point 4 says access.json is the one store. Users, roles and grants live there. Per-thread shares do
not, for three reasons (morgana-0002):
- F1. Thread ids are bus-generated UUIDs. A hand-typed share with a typo would fail the loader, and the
loader refuses the whole file on any failure, which revokes every user.
- F2. Only the owner writes access.json. A collaborator could never share its own thread.
- F3. Threads are append-only bus rows, and a share is a runtime act by a principal that wants a trail.
So shares go in an append-only bus table, gated by the grant. Sage agreed (sage-1338). Gizmoduck had no
objection (gizmoduck-0009) and recorded it as FED 9 Q2. Duckworth rules on it in this review.
### 5.2 Schema 3j
```sql
CREATE TABLE thread_shares (
seq INTEGER PRIMARY KEY AUTOINCREMENT,
at TEXT NOT NULL,
business TEXT NOT NULL,
thread TEXT NOT NULL REFERENCES threads(id),
op TEXT NOT NULL CHECK (op IN ('share','unshare')),
target TEXT NOT NULL,
by TEXT NOT NULL
) STRICT;
CREATE INDEX thread_shares_thread ON thread_shares (thread, target, seq);
-- no_update, no_delete and no_replace triggers, as thread_opens has (schema.sql:319-321).
```
- `target` is an owner string: `user:<id>`, the business human's name, or `*`. A share whose target is a
name that is no longer the business's `human` (after a rename) is void: rule 1 in 5.4 compares against
the current owner string, so it matches nobody (Duckworth N4).
- `by` is the sharer's owner string, which is always the thread's owner.
- A share's state is its latest row per `(thread, target)` (Duckworth N12, REVIEW-r3.md:74-76). Shares
are not assumed to only grow.
- No existing row changes. Existing threads are already owned by the business human (`#owner` at
broker.mjs:364-366), so they are the owner's and private. The brief's "all become the current human's"
is already true.
The migration `3i-3j.sql` creates the table, index and triggers. `thread_shares` joins every table list that
holds timed append-only tables: `timedTables` in store.mjs (:60-74), the broker's clock list
(broker.mjs:223-235), the restore guard's counted tables (row 181) and `COUNTED` in
`scripts/stack-release.sh:315`. The schema digest for 3j is pinned as the other versions are.
### 5.3 Verbs
- `thread.share {thread, to}`: the caller must own the thread (`thread-not-owned`). For a user, a
collaborator grant must still cover the thread. `to` is `user:<id>`, the business human's name or `*`.
A `user:<id>` target must have a live grant, of any level, covering the thread's project and instance,
else `share-not-covered`. Sharing with yourself fails `invalid-request`. A share that is already in force
fails `share-exists`. Writes one `share` row.
- `thread.unshare {thread, to}`: the caller must own the thread. The share must be in force, else
`share-not-found`. Writes one `unshare` row. Unsharing the owner's or `*` shares works the same way.
- `thread.shares {thread}`: the caller must own the thread. Returns the shares in force, latest row each.
- Both writes go through `refuse()` on failure, so each refusal records `action.refused` with `by` set
(FED 3.6 item 6).
### 5.4 The read check, one function
`#readable(s, t)` decides whether a session may read thread `t` it doesn't own. True only when all hold:
1. a share for `t` is in force whose target is the caller's owner string, or `*`;
2. the caller is the owner, or the caller has a live grant of any level covering `t.project` and
`t.instance`;
3. if `t.owner` is a user, that user still has a live grant covering `t` (a revoked owner hides the
thread, sage-1338);
4. `t.owner` is not `tester`.
`#thread(s, id)` keeps its owner check for `thread.open`, `thread.send`, `thread.share`, `thread.unshare`
and `thread.shares`. A new `#threadRead(s, id)` admits the owner or `#readable`, and serves only
`thread.messages`. A sharee fails `thread-not-owned` on every write verb.
### 5.5 Every read path and its filter
| Read path | Today | After row 193 |
|---|---|---|
| `thread.list` | `owner=?` | unchanged for the owner; for users also each row's thread still covered |
| `thread.list {shared: true}` | none | rows where `#readable` holds |
| `thread.messages` | `#thread` | `#threadRead` |
| `thread.open`, `thread.send` | `#thread` | `#thread`, plus a collaborator grant for users |
| `project.list`, `names` | business-wide | per FED 3.5 for users, unchanged for the owner |
| `trail`, `replies` | human, reader or tester; any message by id | users refused (`access-refused`); for the others, messages in other users' unshared threads hidden (5.7) |
| `messages`, `inbox`, `agents`, `tasks`, `decisions`, `credentials` | human or reader | users refused (`access-refused`); owner unchanged. A user-thread run can't send to the human (`user-thread-human`, FED 3.6), so `messages` holds none of a user's thread |
| `message.receive` by a run | its own thread (:1933) | unchanged: runs read their own thread, never a share |
| `threadsState` (launcher) | all threads | adds `revoked` per 5.6 |
### 5.6 Revocation, expiry and unsharing
Gizmoduck asked that a grant loss end access to threads shared with that user (gizmoduck-0009 point 2).
- A sharee holds no claim and no run. Its access is only reads, and every read runs `#readable` against the
access state the broker already refreshes on each request (mtime, size, inode), on its 30 s timer, and on
`access.reload` (FED 3.7). So a removed, expired or invalid grant ends share reads on the next request,
with nothing to tear down. The Console polls, so a revoked sharee's next poll fails.
- An unshare row ends access on the next request in the same way.
- A user who loses coverage of their own thread also loses its shares: rule 3 in 5.4.
The downgrade case. With viewer, "no live grant covers" would be wrong for runs: a viewer grant covers the
thread but must not let a run continue. FED puts this in R4: `revoked`, claim end and the launch check call
`covers` with level `collaborator`, so a downgrade to viewer ends the run within one launcher tick. FED r4.4
(b67bc6de9, mos-1020) chains R4 after row 193's viewer level (193a) and keeps the level-loss test and its
mutant in R4. Row 193 carries neither (Duckworth N3), so the mutant has one owner.
### 5.7 What "private" means, stated plainly
Sage ruled (sage-1354): a user's thread is private from the owner on the bus too. Jason's words are "private
agent sessions per user", and in Case 2 the owner of Melanie's hub is not the user whose thread it is.
Duckworth's B1 asked where the line sits, since a user-thread run acts with the owner's role authority (F10).
I took Duckworth's option (a): **the user's messages are private, and the run's governance is not.** The
run spends the owner's authority, so its decisions, task writes and claims are the owner's to see and
answer. Filtering them too (option b) would need a thread link on `decisions`, more schema, and would hide
from the owner what is done in the owner's name.
Every read an owner or reader session has, and its side of the line:
| Read | Shows | Side |
|---|---|---|
| `thread.list`, `thread.messages`, `thread.open` | own threads only, plus shares to the owner from 193b2 | private: other users' threads never appear |
| `trail` | events, messages, decisions and deliveries by subject | messages filtered (below); decision rows and their events stay visible |
| `replies` | messages replying to an id | filtered (below) |
| `messages`, `message.receive`, `message.read` | messages `to_role='human'` (broker.mjs:1931-1936, :1953) | private by construction: a user-thread run can't send to the human (`user-thread-human`, FED 3.6 item 7) |
| `inbox` | open decisions routed to the human | governance, visible |
| `decisions {list: roles \| recent}` | decision rows with question, options and recommendation | governance, visible |
| `agents` | role claims and each held role's state | governance, visible |
| `tasks`, `state` | tracker tasks and launch state | governance, visible |
| `credentials`, `usage` | credential state, usage records | governance, visible; neither holds message text |
| `project.files`, `project.file` | project files, including records a run writes with `record.write` (broker.mjs:1395-1407, :1441-1450) | governance, visible |
| `project.list`, `names` | projects and instance names | business-wide for the owner, visible |
So a decision a user-thread run raises shows in the owner's `inbox` or `decisions`, with the free text the
run wrote. Its role claims show in `agents`, its task writes in `tasks`, and a record it writes in
`project.file`. Any of these can quote the user, and 5.7 doesn't call them private. The users' help text
(R5) and the Console's share and thread pages say so in one sentence, which FED R6 reuses (mos-1023 item 2,
duckworth-0012 N21):
> The business owner sees your thread's agent's decisions, inbox entries, claims, tasks and project files,
> but not your messages.
The gated-raise route. A user-thread run's uncited gated raise or hard wait routes to the human (broker.mjs:1677-1680).
FED r4.5 (acd5f1f42, B9) settled this with no guard: the tester guard at :1683 (`tester-thread-human`) is not
copied, and the decision reaches the owner's inbox and no other (sage-1368, mos-1021, mos-1022). FED R3 tests it.
Row 193 adds nothing here.
The message filter. A message is hidden from a session when it is in a thread (`message_threads`) whose owner
is a `user:<id>` other than the caller, and `#readable(s, t)` is false. Threads owned by the business human or
by `tester` are not affected, so row 177's behaviour is unchanged.
- `trail`: drops hidden messages, their `deliveries` rows, and `events` rows whose `subject` is a hidden
message id. A `subject` that is itself a hidden message returns `message-not-found`, as an unknown id does,
so the reply doesn't confirm the id exists.
- `replies {id}`: a hidden `id` fails `message-not-found`, and hidden messages are dropped from the result.
- It applies to every session that can call these verbs: the owner, reader sessions and the tester.
- It lands in 193b1, with no schema (Duckworth B2). Before 193b2 there are no shares, so `#readable` is false
for anyone but the thread's owner and b1 hides every message in another user's thread. 193b2 adds only the
share exception. So no build order can put a user thread live without the filter.
What it does not do. The owner holds the store as the instance's OS user and can read it with sqlite. That is
the same-uid limit in section 6, not a bus path. An owner audit verb, explicit and logged, is a follow-up row
only if Jason asks for one (sage-1354).
## 6. The bus human boundary (brief point 5)
Row 196 (Gander, #1694, brief `docs/plans/2026-10-20_human-boundary.md` at 96f243faf) owns P19 333 and the
verifier. Row 193 cites it and designs no boundary of its own (sage-1338: one mechanism).
Agreed with Gander (gander-0002, morgana-0003, gander-0005):
- One bus verifier, `verifyPrincipal(request, connection) -> {kind, business, id}`, kind `owner`, `user` or
`tester`. It mints one transient principal per request. It returns no role. The broker reads the level
from the live grant on every request, so a grant change acts without a new principal.
- Gander recommends FED 3.6's shape for users (path-bound `user-cli.mjs`, direct parent the bus tree's
`packages/webui/src/cli.mjs serve`) plus kernel peer PID binding, and weighs bus-side ID-token
verification as the larger alternative.
- Correction from Gander, accepted: the tester proof doesn't run the full agent walk, because agents bind
tester by design (Q173). Managed runs still fail it. The tester listener moves from TCP 7333 to a private
Unix socket, with `bus.tester` unchanged (agreed by Sage with Gander).
What row 193 needs from row 196:
- H1. Owner on the CLI keeps today's proof. Owner in the Console is a `console.sso.subjects` entry.
- H2. A user principal comes only from an OIDC sign-in, through a path the bus verifies. No CLI path for
users.
- H3. The user binding fails closed on no live grant, an unknown subject, a subject also in
`console.sso.subjects`, or a missing or invalid access.json. A broken access.json never stops the owner.
- H4. Agent processes never get an owner or user principal.
- H5. Revocation ends a bound user within 30 s and at once on `access.reload`, from the same access state
row 193's share check reads.
The same-uid limit. Within one instance, a non-agent process on the instance's uid can claim the owner, and
can start its own Console copy and claim any granted user (FED 3.6). An unsandboxed same-uid agent that
detaches from its ancestry can do the same. The walk doesn't stop that, and Gander's design won't claim it
does (gander-0005). Several humans don't make this worse: a user principal is no stronger than the owner
principal that the same process could already take. Users themselves are not on the uid. They reach the
bus only through the Console, so the limit is about local processes, not other humans.
Does row 190 narrow it? Row 190 runs several instances under one uid, so no. Row 190b puts an instance (the
hub) under its own Linux user (mos-0986). That narrows reach across instances: another instance's uid fails
the uid check (human.mjs:67) and can't read the hub's store or access.json. It does not narrow reach within
one instance. Private threads are private from other humans using the bus, not from processes running as
the instance's uid.
## 7. Migration and rollback (brief point 6)
- No access.json: the business has exactly its owner, every verb behaves as today, and the share verbs
work for the owner only (target `*` or none). A broken access.json revokes users and leaves the owner.
- Existing threads: already owned by the business human. Nothing is rewritten.
- Schema: 3i to 3j adds `thread_shares` only. A re-pin past a schema bump needs a `--migrate` window with
Jason, and row 185's backup before activate covers it.
- Rollback before any share is written: activate the previous copy with the store restored from the
pre-activate backup. A 3i copy refuses a 3j store, as it does for every bump.
- Rollback after shares are written: the same restore loses shares made since the window. Grants are in
access.json and survive. Users re-share. Row 181's restore guard counts `thread_shares`, so the loss is
shown, not silent.
- To keep that window small, the build splits (section 9): 193a, 193b1, 193c and 193d have no schema
change and can go live in any window. 193b2 carries 3j.
## 8. Tests and mutants
Bus, each in `packages/bus/tests/` (193b1: viewer, mixed grants, board reads, the 5.7 message filter and governance pin; 193b2: shares, the share exception to the filter, migration):
- Viewer: each allowed verb works, every other verb refuses `access-refused`, including `thread.create`,
`thread.open`, `thread.send` and `thread.share`.
- Mixed grants: collaborator on project A and viewer on B. Create in A works, create in B refuses.
- Downgrade: not here. FED R4 owns the level-loss test and its mutant (r4.4, mos-1020, N3).
- Shares: owner shares with `user:<id>`, the user reads `thread.messages` and gets `thread-not-owned` on
`thread.send` and `thread.open`. Share with `*` reaches every covered user and the owner. Share with a user
whose grant doesn't cover the project or instance refuses `share-not-covered`.
- A share never widens: the target's grant drops the project, the next read refuses.
- Revoke, expiry and an invalid file each end share reads on the next request. The 30 s timer and
`access.reload` paths each have a case.
- A revoked thread owner hides the thread from sharees.
- Unshare ends access. Share, unshare, share again leaves it shared (latest row wins).
- Non-owner `thread.share`, `thread.unshare`, `thread.shares` refuse. Tester refuses on all three.
- `thread.list` plain shape unchanged. `thread.list {shared: true}` returns only readable threads.
- Board reads refuse users and viewers.
- Governance pin (B1, sage-1368): a user-thread run raises a decision; it shows in the owner's
`decisions recent` with its question text, and `inbox` lists it when routed to the human; the run's
role claim shows in the owner's `agents`, its task write in `tasks`, and its `record.write` in
`project.file` (B3, sage-1373); the user's message bodies stay hidden from the owner's `trail` and
`replies`. The test names each item of the 5.7 user sentence, so the sentence and the pin match.
- `trail`, for the owner, a reader session and the tester (193b1): a user's thread message as `subject`
fails `message-not-found`; a decision trail that reaches such a message omits it, its deliveries and its
events. Once the thread is shared with the owner, the owner's trail shows it.
- `replies`, same three sessions (193b1): a hidden `id` fails `message-not-found`; hidden replies are dropped. The
owner's own and the tester's threads are unchanged.
- Migration: a 3i store with threads migrates to 3j, row counts unchanged, `thread_shares` empty and
append-only (update, delete, replace each abort). Restore-guard and `COUNTED` include it.
Business (193a): R1 accepts `viewer`, refuses any other new level.
CLI (193c): `mosaic access user add/remove` and `grant --level viewer` refuse tester and agent callers,
write a receipt line and call `access.reload`.
Webui (193d): a viewer has no composer or share control. With each UI check removed, the bus still refuses.
Mutants, each must be killed:
1. `#readable` rule 1 dropped (any covered user reads any thread).
2. Rule 2 dropped (a share widens the grant).
3. Rule 3 dropped (a revoked owner's thread stays visible).
4. Latest-row lookup replaced by "any share row" (unshare ignored).
5. (Moved to FED R4, N3: the level-loss mutant is R4's.)
6. Viewer allowed `thread.send`.
7. `#threadRead` used for `thread.send` (sharee writes).
8. `share-not-covered` check removed.
9. `thread.share` owner check removed (sharee re-shares).
10. A user allowed `trail`.
11. The `trail` filter removed (the owner reads a user's unshared message by id).
12. The `replies` filter removed (the owner reads replies in a user's unshared thread).
## 9. Build rows and order
| Row | Work | Chains on | Schema |
|---|---|---|---|
| 193a | R1 level check widened to viewer | FED R1 candidate | none |
| 193b1 | Bus: viewer verb list, per-grant level checks, users refused on board reads, the 5.7 `trail` and `replies` filter with `#readable` as owner-only | FED R3, R4 candidates, row 196 verifier | none |
| 193b2 | Bus: `thread_shares`, share verbs, the share rules of `#readable`, `#threadRead`, the 5.5 share filters | 193b1 candidate | 3j |
| 193c | `mosaic access` user verbs and `--level viewer`, if R5 lacks them | FED R5 candidate | none |
| 193d | Console: shared list, share controls, viewer read-only | FED R6, row 192 candidates, 193b2 for share controls | none |
Sage ruled the split (sage-1354): 193b1 has no schema change and can go live first. 193b2 carries 3j, and
Sage takes its migrate window to Mos when 193b2 is near. The 5.7 message filter is in 193b1 (Duckworth B2,
sage-1368), so it is live before any user thread can be, whatever order FED R8 and 193b2 land in. 193b2 adds
only the share exception. FED R4 chains on 193a (viewer level), and 193b1 chains on R4.
Coverage, settled in FED r4.1 (73677b87d, gizmoduck-0018, still under Duckworth's review). A grant's
`projects` is `["*"]` or bus project UUIDs (`projects.id`, broker.mjs:2306; `threads.project` references it).
R1 exports one pure `covers(grant, {business, project, instance}, now, {level})`. Row 193 uses only that
function and adds no second one (Sage confirmed, sage-1354):
- share reads (5.4 rules 2 and 3) and `share-not-covered`: `covers(g, thread, now, {})`, any level;
- `thread.create`, `thread.open`, `thread.send`, `thread.share`: `covers(g, thread, now, {level: 'collaborator'})`;
- `project.list` and `names` for users: any level, as FED 3.5.
`covers` needs no further argument for row 193. If r4.1 changes in review, 193b1 follows it.
Follow-ups, not in this row:
- A read-only users and grants page for the owner in the Console.
- A second owner or admin level, if Jason wants one.
- An explicit, logged owner audit verb for user threads, only if Jason asks for one (sage-1354).
## 10. Gate
Per the brief: Duckworth approves this design, then the manifest. The bus, business, cli and webui suites and
every `scripts/test-*.sh` pass on Sage's gate, and the section 8 mutants are killed. No test touches the live
directories. No product code before Duckworth approves.
+1 -11
View File
@@ -9,8 +9,7 @@
"version": "0.0.3",
"license": "UNLICENSED",
"dependencies": {
"@earendil-works/pi-coding-agent": "0.85.1",
"oauth4webapi": "3.8.8"
"@earendil-works/pi-coding-agent": "0.85.1"
}
},
"node_modules/@anthropic-ai/sdk": {
@@ -1934,15 +1933,6 @@
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/oauth4webapi": {
"version": "3.8.8",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.8.tgz",
"integrity": "sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/openai": {
"version": "6.40.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.40.0.tgz",
+1 -2
View File
@@ -5,7 +5,6 @@
"description": "Minimal Mosaic Stack container proof of concept: one Pi agent, four local contract files, one real model request returning MOSAIC_HELLO_OK.",
"license": "UNLICENSED",
"dependencies": {
"@earendil-works/pi-coding-agent": "0.85.1",
"oauth4webapi": "3.8.8"
"@earendil-works/pi-coding-agent": "0.85.1"
}
}
-39
View File
@@ -129,45 +129,6 @@ Switching the Console, bus or wrapper, changing any unit, and starting or
stopping either service requires Mos's go for that live step. Agents build
and test fixtures; they stop before the live sequence.
## Sign-in in the Console (Q175 phase 1, #1690)
`serve --sso [--send] [--tester-port N]` makes the Console an OpenID Connect
relying party. It reads `console.sso` from the system config (`publicOrigin`
and one or more providers, each with its issuer, client id, a client secret
slot, the signing algorithms it accepts and the subjects allowed in). The
design is `agents/sage/work/q175-sso/DESIGN.md`. `--sso` can't be combined
with `--public-host`, `--auth-header`, `--allow-user` or `--proof-*`; `--send`
takes `--sso` in place of the preview set.
- The client secret is read once at start from
`<config dir>/secrets/mosaic-stack/<slot>`. The start refuses a slot that is
a symbolic link, not a regular file, owned by another user, has any group or
other mode bit, or sits in the repository, the `--checkout` directory or a
stack copy. The value is never printed.
- At start the Console fetches each provider's discovery document and JWKS. A
provider that can't be reached (a network error, a timeout, a status other
than 200, or a body that isn't JSON) leaves the Console up with that
provider's `/auth/login` at 503 and a retry every 5 s, doubling, capped at 5
minutes. A document that fails a check (issuer, algorithms, S256, a usable
key) refuses the start. `/healthz` adds `sso: { <id>: "ready" | "unreachable"
| "refused" }`.
- The Console listener serves only the Host of `publicOrigin`, whatever port
it listens on. Any other Host, a loopback one included, gets 403 `public
Host required`, except `GET /healthz`, which stays open on loopback for the
status and release gates. The loopback operator path in the preview section
above does not exist under `--sso`.
- Without a session, `/api/*` gets 401 `sign-in required` and a page load gets
a 303 to `/auth/login?return=<path>`. The page then shows the signed-out line
with a link back to sign-in. A session lasts at most 8 hours and ends after 1
hour without a page load or a write; a restart ends every session.
- The page shows Sign out only under `--sso`. It posts to `/auth/logout`, which
ends the session and goes to the provider's end-session endpoint when it has
one, or to `/auth/signed-out`.
- With `--tester-port`, the tester listener refuses any request carrying an
`Authorization` header, a `__Host-mosaic_session` or `__Host-mosaic_login`
cookie, or an `x-authentik-*` header (403 `tester listener takes no
sign-in`). It never serves `/auth/*`.
## Message a role (row 76, #1570)
```sh
+2 -3
View File
@@ -58,9 +58,8 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev
if (one(req, 'origin') !== preview.origin) return refuse(403, 'cross-origin');
} else {
if (!send || !named || !preview) return refuse(403, 'read-only');
// Q175 phase 1 (#1690): under --sso the session was checked before this route.
if (preview.sso !== true && one(req, preview.header) !== preview.user) return refuse(401, 'unauthenticated');
if (one(req, 'origin') !== (preview.origin ?? `https://${preview.host}`)) return refuse(403, 'cross-origin');
if (one(req, preview.header) !== preview.user) return refuse(401, 'unauthenticated');
if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin');
}
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site');
}
+8 -27
View File
@@ -7,23 +7,20 @@ import { views } from '../../bus/src/views.mjs';
import { loadSystem, socketPath, REPO, rolesDir } from '../../cli/src/config.mjs';
import { configDir } from '../../business/src/business.mjs';
import { consoleReads, redactor } from './reads.mjs';
import { createSso, readSlot } from './sso.mjs';
import { statSync } from 'node:fs';
import { isAbsolute } from 'node:path';
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
const args = process.argv.slice(2);
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-port N] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso [--send]] [--public-host NAME --auth-header HEADER --allow-user ID [--send] [--proof-header NAME --proof-file PATH]]';
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-port N] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--public-host NAME --auth-header HEADER --allow-user ID [--send] [--proof-header NAME --proof-file PATH]]';
try {
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); }
if (args.shift() !== 'serve') throw new Error(usage);
const options = { board: DEFAULT_BOARD, port: 7330 }, preview = {}, proofFlags = {};
let sendOn = false, ssoOn = false, testerPort = null;
let sendOn = false, testerPort = null;
while (args.length) {
const flag = args.shift();
// Row 76 (#1570): the one flag without a value.
if (flag === '--send') { sendOn = true; continue; }
// Q175 phase 1 (#1690): the Console signs people in itself, from console.sso in config.json.
if (flag === '--sso') { ssoOn = true; continue; }
const value = args.shift();
if (!value) throw new Error(`missing value for ${flag}`);
if (flag === '--port') {
@@ -54,10 +51,9 @@ try {
else throw new Error(`unknown option: ${flag}`);
}
if (testerPort !== null && (testerPort === options.port || testerPort === 7332)) throw new Error('--tester-port must differ from --port and cannot be the forwarded port 7332');
if (ssoOn && (Object.keys(preview).length || Object.keys(proofFlags).length)) throw new Error('--sso cannot be combined with --public-host, --auth-header, --allow-user or --proof-*');
options.preview = previewOptions(preview);
const proof = proofOptions(proofFlags, options.preview);
if (sendOn && !options.preview && !ssoOn) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso');
if (sendOn && !options.preview) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user)');
// The bus views (slice 1 S5) need the system config. Without it the
// board still serves and the bus routes answer not-configured.
// The Queue, Business and Settings reads (row 54) need no bus; without
@@ -81,46 +77,31 @@ try {
busLine = `Bus: ${options.business ?? 'the running bus host\'s business'}`;
} catch (err) {
const why = redactor({ configDir: configDir() })(err.message);
if (ssoOn) throw new Error(`--sso needs the system config (${why})`);
if (sendOn) throw new Error(`--send needs the bus configured (${why})`);
if (testerPort !== null) throw new Error(`--tester-port needs the bus configured (${why})`);
busLine = `Bus: not configured (${why})`;
}
const reads = consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: options.business ?? null });
// The sign-in config is checked again here, then each slot file, before anything listens. A
// provider that can't be reached starts degraded; one whose discovery is refused stops the start.
let sso = null;
if (ssoOn) {
if (!system.console?.sso) throw new Error('--sso needs console.sso in the system config');
const secrets = new Map(system.console.sso.providers.map(p => [p.id, readSlot(configDir(), p, { repo: REPO, checkout: options.checkout ?? null })]));
sso = await createSso(system.console.sso, { secrets });
}
let server;
try {
server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite });
} catch (err) { sso?.close(); throw err; }
const server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, proof, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite });
let testerServer = null;
if (testerPort !== null) {
const call = busCaller({ dataRoot: system.dataRoot, socket: socketPath(system.dataRoot), business: options.business ?? null, cli: TESTER_CLI });
const testerBus = { ...views({ call }), names: () => call('names'), instances: () => call('instances'), routes: () => call('routes') };
try {
testerServer = await startServer({ host: '127.0.0.1', port: testerPort, board: options.board,
tester: true, testerNoSignIn: ssoOn, testerProofHeader: proof?.header ?? null, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads,
tester: true, testerProofHeader: proof?.header ?? null, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads,
projectFiles: args => call('project.files', args), projectFile: args => call('project.file', args),
instancesWrite: args => call('instances.set', args), routesWrite: args => call('routes.set', args), nameWrite: args => call('name.set', args),
// Row 177 (#1675): the tester's own chat, through the tester CLI; 7332's stays the human's.
chat: createChat({ call }),
});
} catch (err) { sso?.close(); await new Promise(resolve => server.close(resolve)); throw err; }
} catch (err) { await new Promise(resolve => server.close(resolve)); throw err; }
}
const previewLine = options.preview ? `\nPreview: https://${options.preview.host}/ through the proxy, signed-in user from ${options.preview.header}, ${send ? 'reads and human messages to roles (--send); one line per send below' : 'read-only'}` : '';
const proofLine = `\nProxy: proxy proof ${proof ? 'on' : 'off'}`;
// One line per provider: id, issuer, subject count and state. No client id, secret or sub.
const health = sso?.health() ?? {};
const ssoLines = sso ? `\nSign-in: ${sso.publicOrigin}/ (--sso), ${send ? 'reads and human writes (--send)' : 'read-only'}` + sso.summary().map(p => `\nSign-in provider: ${p.id} issuer ${p.issuer} subjects ${p.subjects} (${health[p.id]})`).join('') : '';
const queueLine = options.checkout ? '\nQueue: read from the --checkout directory' : '';
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${ssoLines}${proofLine}${testerServer ? `\nTester Console: http://127.0.0.1:${testerServer.address().port}/ (binds the tester)` : ''}\nCtrl-C stops this server.`);
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { sso?.close(); server.close(); testerServer?.close(); });
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${proofLine}${testerServer ? `\nTester Console: http://127.0.0.1:${testerServer.address().port}/ (binds the tester)` : ''}\nCtrl-C stops this server.`);
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { server.close(); testerServer?.close(); });
} catch (err) {
console.error(`refused: ${err.message}`);
process.exitCode = 2;
+3 -14
View File
@@ -16,11 +16,6 @@
// The first one sets body.signed-out (the listener below); after that no Console fetch is made.
const out = () => document.body.classList.contains('signed-out');
const signOut = () => { if (!out()) document.dispatchEvent(new Event('mosaic-signed-out')); };
// Q175 phase 1 (#1690): under --sso the Console itself answers a read without a session with 401
// `sign-in required`; that signs the page out too, and the line links to the Console's sign-in.
// Any other 401 stays an error. `sso` comes from /api/config.
let sso = false;
const signInRequired = (res, body) => res.status === 401 && body?.error === 'sign-in required';
const signedOut = () => Object.assign(new Error('the sign-in proxy redirected it, so it never reached the Console'), { signedOut: true });
const labels = { registration: 'registered', 'first-user-message': 'first message', 'tmux-pane': 'tmux pane', 'session-cwd': 'session cwd', 'workspace-git-root': 'git root' };
const source = value => value ? `<span class="source">${esc(labels[value] || value)}</span>` : '';
@@ -115,7 +110,6 @@
const res = await fetch(path, { cache: 'no-store', redirect: 'manual' });
if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
let body; try { body = await res.json(); } catch { body = { error: `HTTP ${res.status}` }; }
if (signInRequired(res, body)) { sso = true; signOut(); throw signedOut(); }
return { ok: res.ok, body };
}
function blockNode(b) {
@@ -310,7 +304,6 @@
const res = await fetch(path, value === undefined ? { cache: 'no-store', redirect: 'manual' } : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value), redirect: 'manual' });
if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
const result = await res.json();
if (signInRequired(res, result)) { sso = true; signOut(); throw signedOut(); }
if (!res.ok) throw Object.assign(new Error(result.error || `HTTP ${res.status}`), { status: res.status });
return result;
}
@@ -459,7 +452,7 @@
});
// Signed out: everything drawn from a Console read goes, because none of it can be refreshed. The
// line says so once; the send form (bus.js) keeps its draft and receipt. Reload is the way back.
document.addEventListener('mosaic-signed-out', e => {
document.addEventListener('mosaic-signed-out', () => {
if (out()) return;
document.body.classList.add('signed-out');
clearTimeout(timer); convGen++;
@@ -468,13 +461,9 @@
for (const id of ['projects', 'waiting', 'seen', 'sessions']) $(id).replaceChildren();
$('refresh').disabled = $('pause').disabled = true;
const line = $('signed-out');
line.innerHTML = sso || e.detail?.signIn
? `<p><b>Signed out.</b> <a href="/auth/login?${esc(new URLSearchParams({ return: location.pathname + location.search }))}">Sign in again</a>.</p>`
: '<p><b>Signed out.</b> Reload the page to sign in.</p>';
line.hidden = false; $('sign-out').hidden = true;
line.innerHTML = '<p><b>Signed out.</b> Reload the page to sign in.</p>'; line.hidden = false;
// Focus moves to the line only if what had it is gone or disabled, never out of a form being typed in.
setTimeout(() => { const a = document.activeElement; if (!a || a === document.body || a.disabled || !a.getClientRects().length) line.focus(); });
});
// Sign out (sage-1347, F7): a plain same-origin form POST to /auth/logout, shown only under --sso.
api('/api/config').then(config => { board = config.board; $('board-url').textContent = `Board: ${board}`; if (config.sso === true) { sso = true; $('sign-out').hidden = out(); } }).catch(err => { if (machine) error(err); }).finally(refresh);
api('/api/config').then(config => { board = config.board; $('board-url').textContent = `Board: ${board}`; }).catch(err => { if (machine) error(err); }).finally(refresh);
})();
-6
View File
@@ -30,10 +30,6 @@
const out = () => document.body.classList.contains('signed-out');
const signOut = () => { if (!out()) document.dispatchEvent(new Event('mosaic-signed-out')); };
const signedOut = () => new ReadError('signed-out', 'The sign-in proxy redirected the read.', 0);
// Q175 phase 1 (#1690, sage-1340 F1): under --sso a read without a session gets 401 `sign-in
// required` from the Console itself. That signs out the same way; any other 401 stays an error.
const signInRequired = (res, body) => res.status === 401 && body?.error === 'sign-in required';
const signOutSso = () => { if (!out()) document.dispatchEvent(new CustomEvent('mosaic-signed-out', { detail: { signIn: true } })); };
// `replies` takes a message id as `id`; trail takes `subject`.
async function fetchRead(verb, subject) {
if (out()) throw signedOut();
@@ -42,7 +38,6 @@
catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
try { body = await res.json(); } catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
if (signInRequired(res, body)) { signOutSso(); throw signedOut(); }
if (!res.ok || !body || !Array.isArray(body.rows)) throw new ReadError(typeof body?.error === 'string' ? body.error : 'invalid-response', typeof body?.message === 'string' ? body.message : `HTTP ${res.status}`, res.status);
return { ...body, rows: body.rows, at: body.at };
}
@@ -55,7 +50,6 @@
try { res = await fetch(`/api/${path}`, { cache: 'no-store', redirect: 'manual' }); } catch { throw unreachable(); }
if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
try { body = await res.json(); } catch { throw unreachable(); }
if (signInRequired(res, body)) { signOutSso(); throw signedOut(); }
if (!res.ok || !body || typeof body !== 'object' || Array.isArray(body)) throw Object.assign(new ReadError(typeof body?.error === 'string' ? body.error : 'invalid-response', typeof body?.message === 'string' ? body.message : `HTTP ${res.status}`, res.status), { module: true });
return body;
}
+1 -1
View File
@@ -9,7 +9,7 @@
<header class="cmdbar"><a href="/" class="wordmark"><svg class="mark" viewBox="0 0 16 17" aria-hidden="true"><use href="/icons.svg#mark"/></svg>Mosaic<small>Console</small></a>
<button type="button" class="cmdk" id="cmdk" aria-haspopup="dialog" aria-controls="cmdk-dialog"><svg class="i" aria-hidden="true"><use href="/icons.svg#i-search"/></svg><span>Go to a view, decision or task</span><kbd>Ctrl K</kbd></button>
<p class="fresh" id="fresh"><span id="fresh-board"></span><span id="fresh-bus"></span></p>
<div class="cmd-right"><label>Palette<select id="palette"></select></label><label>Appearance<select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></label><button class="btn" id="refresh" type="button">Refresh</button><button class="btn" id="pause" type="button" aria-pressed="false">Pause</button><form id="sign-out" method="post" action="/auth/logout" hidden><button class="btn" type="submit">Sign out</button></form></div></header>
<div class="cmd-right"><label>Palette<select id="palette"></select></label><label>Appearance<select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></label><button class="btn" id="refresh" type="button">Refresh</button><button class="btn" id="pause" type="button" aria-pressed="false">Pause</button></div></header>
<div class="frame">
<nav class="tree" aria-label="Console"><h2 class="tree-title">Console</h2><ul class="s1-sections" id="sections"></ul><div id="board-tree"><h2 class="tree-title">Projects</h2><div id="projects"><p class="muted">Loading projects…</p></div><p class="small muted">Select a project to filter sessions. Waiting on you always shows all projects.</p></div></nav>
<main id="main" class="content" tabindex="-1"><div id="signed-out" class="banner ref" role="alert" tabindex="-1" hidden></div><div id="bus-down" class="banner err" role="alert" hidden></div><div class="page-head" id="board-head"><div><h1>Control board</h1><p id="status" class="small muted" role="status">Loading board…</p></div><div class="filters"><label><input id="show-machine" type="checkbox">Show machine sessions</label></div></div>
+26 -56
View File
@@ -61,8 +61,6 @@ async function body(req, max = 4096) {
// Row 65 (mos-0840) adds `routes`: each instance's harness, model, thinking and fallback.
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'usage', 'limits', 'names', 'instances', 'routes'];
const DECISION_LISTS = ['roles', 'recent'];
// Q175 phase 1 (#1690, F8): either sign-in cookie, which the tester listener refuses under --sso.
const SIGN_IN_COOKIE = /(?:^|;)\s*__host-mosaic_(?:session|login)\s*=/i;
// Row 177 (#1675): the chat writes the tester listener lets through, exactly the four chatHTTP routes.
const TESTER_CHAT_WRITE = /^\/api\/chat\/(?:projects|threads|threads\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/(?:messages|open))$/i;
async function busRead(res, bus, verb, search) {
@@ -162,18 +160,14 @@ async function busRead(res, bus, verb, search) {
// broker's to refuse. Nothing retries; an outcome the bus can't confirm is reported as unknown.
const SEND_KEYS = ['to', 'body', 'in_reply_to'];
const one = (req, name) => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null);
// Q175 phase 1 (#1690): under --sso the writer comes from a session the handler already checked,
// so it carries `sso: true` and the public origin; the preview writer still checks its header.
const signedIn = (req, p) => p.sso === true || one(req, p.header) === p.user;
const ownOrigin = p => p.origin ?? `https://${p.host}`;
async function busSend(req, res, { send, preview, log }) {
if (!signedIn(req, preview)) return json(res, 401, { error: 'sign-in required' });
if (one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required' });
let to = null;
// `to` is quoted and cut short so a crafted value can't forge a log line.
const line = outcome => log(`${new Date().toISOString()} send user=${JSON.stringify(preview.user)} to=${typeof to === 'string' ? JSON.stringify(to.slice(0, 64)) : 'none'} ${outcome}`);
// `sent` tells the page which it was: false, nothing was written; 'unknown', it may have been.
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'a send needs the Console\'s own Origin');
if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin request refused', 'a send needs the Console\'s own Origin');
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'a send needs Sec-Fetch-Site: same-origin');
let value;
try { value = JSON.parse((await body(req, 16384)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
@@ -247,10 +241,10 @@ export function proofOptions({ proofHeader, proofFile } = {}, preview = null) {
// REQ-WEB-1: same signed-in send boundary, a distinct strict settings payload.
async function settingsSend(req, res, { settingsWrite, preview, log }) {
const refuse = (status, error, message) => json(res, status, { error, message, sent: false });
if (!signedIn(req, preview)) {
if (one(req, preview.header) !== preview.user) {
return refuse(401, 'sign-in required', 'sign in again');
}
if (one(req, 'origin') !== ownOrigin(preview)) {
if (one(req, 'origin') !== `https://${preview.host}`) {
return refuse(403, 'cross-origin request refused', 'settings need the Console origin');
}
if (one(req, 'sec-fetch-site') !== 'same-origin') {
@@ -298,8 +292,8 @@ async function seatSend(req, res, { route, write, preview, log }) {
// The instance is quoted and cut short so a crafted value can't forge a log line.
const line = outcome => log(`${new Date().toISOString()} ${route.kind} user=${JSON.stringify(preview.user)} instance=${quoted(instance)} ${outcome}`);
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
if (!preview.tester && !signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
if (!preview.tester && one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== (preview.origin ?? `https://${preview.host}`)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'this needs same-origin Fetch Metadata');
let value;
try { value = JSON.parse((await body(req)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
@@ -341,8 +335,8 @@ async function instanceSend(req, res, { instancesWrite, preview, log }) {
// The instance is quoted and cut short so a crafted value can't forge a log line. Vars are never logged.
const line = outcome => log(`${new Date().toISOString()} instance user=${JSON.stringify(preview.user)} instance=${quoted(instance)} ${outcome}`);
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
if (!preview.tester && !signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
if (!preview.tester && one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== (preview.origin ?? `https://${preview.host}`)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'this needs same-origin Fetch Metadata');
let value;
try { value = JSON.parse((await body(req, 16384)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
@@ -388,8 +382,8 @@ async function routesSend(req, res, { routesWrite, preview, log }) {
let names = [];
const line = outcome => log(`${new Date().toISOString()} routes user=${JSON.stringify(preview.user)} instances=${quoted(names.join(','))} ${outcome}`);
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
if (!preview.tester && !signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
if (!preview.tester && one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== (preview.origin ?? `https://${preview.host}`)) return refuse(403, 'cross-origin request refused', 'this needs the Console\'s own Origin');
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'this needs same-origin Fetch Metadata');
let value;
try { value = JSON.parse((await body(req, 16384)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
@@ -426,8 +420,8 @@ async function projectFileSend(req, res, { projectSave, preview, log }) {
const line = outcome => log(`${new Date().toISOString()} project-file user=${JSON.stringify(preview.user)} project=${quoted(target?.project)} path=${quoted(target?.path)} ${outcome}`);
// `sent` tells the page which it was: false, nothing was written; 'unknown', it may have been.
const refuse = (status, error, message, sent = false) => { line(`error=${error}`); return json(res, status, { error, message, sent }); };
if (!signedIn(req, preview)) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== ownOrigin(preview)) return refuse(403, 'cross-origin request refused', 'a save needs the Console\'s own Origin');
if (one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required', message: 'sign in again', sent: false });
if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin request refused', 'a save needs the Console\'s own Origin');
if (one(req, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site request refused', 'a save needs same-origin Fetch Metadata');
let value;
try { value = JSON.parse((await body(req, FILE_SAVE_MAX)).toString('utf8')); } catch (err) { return refuse(400, 'invalid-request', err.message); }
@@ -466,17 +460,13 @@ async function projectFileRead(res, read, path, search) {
return json(res, busStatus(code), { error: code, message: err instanceof BusReadError ? err.message : code });
}
}
// `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode or `sso`.
// `sso` is createSso() from sso.mjs (Q175 phase 1): the human listener signs people in itself.
// `testerNoSignIn` makes the tester listener refuse anything that looks like a sign-in (--sso). `log`
// `send` is the bus write (row 76): `args => Promise<{id}>`, only with the preview mode. `log`
// takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
// tests; the field table is otherwise the committed packages/tasks/fields.json.
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerNoSignIn = false, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, tester = false, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
if (host === 'localhost') host = '127.0.0.1';
if (tester && preview !== null) throw new Error('tester listener cannot use preview mode');
if (tester && sso !== null) throw new Error('tester listener cannot use sso');
if (sso !== null && (preview !== null || proof !== null)) throw new Error('--sso cannot be combined with --auth-header, --allow-user or --proof-*');
if (tester && host !== '127.0.0.1') throw new Error('tester listener must bind 127.0.0.1');
// Row 177 (#1675): chat is allowed. The tester listener's chat binds the tester, so the bus keeps it on tester threads.
if (tester && [send, settingsWrite, projectSave, assistantStop].some(value => value !== null)) throw new Error('tester listener permits only instance, route, name and chat writes');
@@ -486,26 +476,21 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
throw new Error('preview mode needs --public-host, --auth-header and --allow-user together');
}
if (proof !== null && (preview === null || tester)) throw new Error('proxy proof needs preview mode');
if (send !== null && ((preview === null && sso === null) || typeof send !== 'function')) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso');
if (send !== null && (preview === null || typeof send !== 'function')) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user)');
const server = createServer(async (req, res) => {
res.setHeader('x-content-type-options', 'nosniff');
res.setHeader('referrer-policy', 'no-referrer');
// Under --sso the Sign out form's POST is redirected to the provider's end-session endpoint, and
// browsers apply form-action to that redirect, so those origins (from discovery) are allowed too.
res.setHeader('content-security-policy', `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'${sso ? sso.formTargets().map(o => ` ${o}`).join('') : ''}`);
res.setHeader('content-security-policy', "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'");
// Presence, even with an empty value: never turn the public listener into a tester ingress.
if (!tester && Object.hasOwn(req.headers, 'x-mosaic-tester')) return json(res, 403, { error: 'tester listener required' });
if (tester && testerAuthHeader && Object.hasOwn(req.headers, testerAuthHeader.toLowerCase())) return json(res, 403, { error: 'proxy header refused' });
if (tester && testerProofHeader && Object.hasOwn(req.headers, testerProofHeader.toLowerCase())) return json(res, 403, { error: 'proxy proof header refused' });
// Under --sso the tester listener reads no cookie and takes no sign-in of any kind (design section 6).
if (tester && testerNoSignIn && (Object.hasOwn(req.headers, 'authorization') || SIGN_IN_COOKIE.test(req.headers.cookie ?? '') || Object.keys(req.headers).some(h => h.startsWith('x-authentik-'))))
return json(res, 403, { error: 'tester listener takes no sign-in' });
let path, search;
// Node keeps the first of repeated Host headers; a second one (a loopback Host, then NAME) is
// never legitimate, so it is refused before any check reads the Host.
if ((req.headersDistinct.host?.length ?? 0) > 1) return json(res, 400, { error: 'repeated Host refused' });
// Preview mode: a request for the public name, exactly, as the proxy forwards it.
const named = preview !== null ? (req.headers.host ?? '').toLowerCase() === preview.host : sso !== null && (req.headers.host ?? '').toLowerCase() === sso.publicHost;
const named = preview !== null && (req.headers.host ?? '').toLowerCase() === preview.host;
// The proof precedes every auth-header read, including the wrong-Host fence.
if (proof !== null) {
const values = req.headersDistinct[proof.header];
@@ -527,34 +512,19 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
}
({ pathname: path, search } = new URL(req.url, 'http://localhost'));
} catch { return json(res, 400, { error: 'invalid URL' }); }
// Under --sso the human listener serves only the public Host, whatever its port; GET /healthz
// alone stays open on a loopback Host for the status and release gates (Q-C).
if (sso !== null && !named && !(path === '/healthz' && req.method === 'GET')) return json(res, 403, { error: 'public Host required' });
// No CORS. JSON content type and same-origin checks keep browser forms out.
if (req.headers.origin && req.headers.origin !== (named ? (sso ? sso.publicOrigin : `https://${preview.host}`) : `http://${req.headers.host}`)) return json(res, 403, { error: 'cross-origin request refused' });
let signer = preview;
if (sso !== null && named) {
if (path.startsWith('/auth/')) return sso.handle(req, res, path, search);
// Only a page load or a write keeps a session alive; the page's background reads don't (Q-D).
const session = path === '/healthz' ? null : sso.session(req, { touch: req.method === 'POST' || (req.method === 'GET' && !path.startsWith('/api/')) });
if (!session && path !== '/healthz') {
if (path.startsWith('/api/') || req.method !== 'GET') return json(res, 401, { error: 'sign-in required' });
res.writeHead(303, { location: `/auth/login?return=${encodeURIComponent(path + search)}`, 'cache-control': 'no-store' });
return res.end();
}
if (session) signer = { sso: true, user: session.name, host: sso.publicHost, origin: sso.publicOrigin };
}
if (named && preview !== null) {
if (req.headers.origin && req.headers.origin !== (named ? `https://${preview.host}` : `http://${req.headers.host}`)) return json(res, 403, { error: 'cross-origin request refused' });
if (named) {
// One value, equal to the user id; the proxy strips client copies and sets it after sign-in.
const values = req.headersDistinct[preview.header];
if (!values || values.length !== 1 || values[0] !== preview.user) return json(res, 401, { error: 'sign-in required' });
}
const writer = tester ? { tester: true, user: 'tester', origin: `http://127.0.0.1:${server.address().port}` } : signer;
const writer = tester ? { tester: true, user: 'tester', origin: `http://127.0.0.1:${server.address().port}` } : preview;
if (tester && !['GET', 'HEAD'].includes(req.method) && !['/api/instances', '/api/routes', '/api/name'].includes(path) && !TESTER_CHAT_WRITE.test(path))
return json(res, 403, { error: 'read-only', sent: false });
if(path==='/api/settings'&&req.method==='POST'){
if(!send||!settingsWrite||!named)return json(res,403,{error:'read-only',sent:false});
return settingsSend(req,res,{settingsWrite,preview:signer,log});
return settingsSend(req,res,{settingsWrite,preview,log});
}
if (Object.hasOwn(SEAT_ROUTES, path)) {
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
@@ -574,18 +544,18 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
}
if (path === '/api/project-file' && req.method === 'POST') {
if (!send || !projectSave || !named) return json(res, 403, { error: 'read-only', sent: false });
return projectFileSend(req, res, { projectSave, preview: signer, log });
return projectFileSend(req, res, { projectSave, preview, log });
}
// Message sending has its own bounded payload and repeats the identity check.
if (send && path === '/api/bus/message') {
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
return busSend(req, res, { send, preview: signer, log });
return busSend(req, res, { send, preview, log });
}
if (path === '/api/chat' || path.startsWith('/api/chat/')) {
return chatHTTP(req, res, { path, search, chat, named, send, preview: writer, log });
}
// Nothing else but GET and HEAD gets past here in preview mode, whatever the Host.
if ((preview !== null || sso !== null) && req.method !== 'GET' && req.method !== 'HEAD') return json(res, 403, { error: 'read-only', message: 'the Console preview is read-only' });
if (preview !== null && req.method !== 'GET' && req.method !== 'HEAD') return json(res, 403, { error: 'read-only', message: 'the Console preview is read-only' });
// The conversation routes (#1507, CHAT-02) keep their query; the board validates it.
const conversation = path === '/api/conversations' || path === '/api/conversation';
const allowed = path === '/api/board' || conversation ? 'GET' : ['/api/seen', '/api/reply'].includes(path) ? 'POST' : null;
@@ -637,8 +607,8 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
if (req.method !== 'GET' && req.method !== 'HEAD') return json(res, 405, { error: 'method not allowed' });
// `send`: the page shows the composer only where a send can pass (the public name, --send on).
// `edit`: the editor shows Save only where a save can pass, the same test as the route's.
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), send: !tester && Boolean(send) && named, edit: !tester && Boolean(send) && Boolean(projectSave) && named });
if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) });
if (path === '/api/config') return json(res, 200, { board: upstream, ...(tester ? { tester: true } : {}), send: !tester && Boolean(send) && named, edit: !tester && Boolean(send) && Boolean(projectSave) && named });
if (path === '/healthz') return json(res, 200, { ok: true });
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
const file = files.get(path);
if (!file) return json(res, 404, { error: 'not found' });
-75
View File
@@ -1,75 +0,0 @@
// Q175 phase 1 (#1690): the shape of config.json's optional `console` key. The one loader,
// scripts/mosaic-config.mjs, runs this on every read, so a bad key refuses the config for every
// tool (design section 4, C1). The Console's --sso start runs it again before it uses the value.
// Shape only: nothing here touches the network or a slot file. A refusal names the key path and
// never quotes a value, so a refusal can't print a client id or a subject.
// The asymmetric algorithms oauth4webapi 3.8.8 verifies (its supported(), build/index.js). No HS*
// and no none, so a client secret can never be a signing key.
export const ALGS = Object.freeze(['PS256', 'ES256', 'RS256', 'PS384', 'ES384', 'RS384', 'PS512', 'ES512', 'RS512', 'Ed25519', 'EdDSA', 'ML-DSA-44', 'ML-DSA-65', 'ML-DSA-87']);
const PROVIDER_ID = /^[a-z0-9-]{1,32}$/;
const SLOT = /^[a-z0-9-]{1,64}$/;
const SUB = /^[\x21-\x7e]{1,255}$/;
const CLIENT_ID = /^[\x21-\x7e]{1,255}$/;
// The name goes where the preview's --allow-user went (serve.mjs USER): visible ASCII, no comma.
const NAME = /^[\x21-\x2b\x2d-\x7e]{1,64}$/;
// The bus keeps these for the tester and the human role (section 6), so no subject can sign in as one.
const RESERVED_NAMES = ['tester', 'human'];
const object = v => typeof v === 'object' && v !== null && !Array.isArray(v);
function refuse(where, why) {
throw new Error(`${where} ${why}`);
}
function keys(value, where, allowed, required = allowed) {
if (!object(value)) refuse(where, 'must be a JSON object');
for (const key of Object.keys(value)) if (!allowed.includes(key)) refuse(`${where}.${key}`, 'is not a supported key');
for (const key of required) if (!Object.hasOwn(value, key)) refuse(`${where}.${key}`, 'is required');
}
function httpsURL(value, where) {
let url;
if (typeof value !== 'string' || value.length > 512) refuse(where, 'must be an https URL of at most 512 characters');
try { url = new URL(value); } catch { refuse(where, 'must be an https URL'); }
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || value.includes('?') || value.includes('#')) {
refuse(where, 'must be https with no credentials, query or fragment');
}
return url;
}
// Returns the value unchanged when it passes; throws an Error naming the bad key path otherwise.
export function checkConsole(value, where = 'console') {
keys(value, where, ['sso']);
const sso = value.sso, at = `${where}.sso`;
keys(sso, at, ['publicOrigin', 'providers']);
const origin = httpsURL(sso.publicOrigin, `${at}.publicOrigin`);
if (origin.origin !== sso.publicOrigin) refuse(`${at}.publicOrigin`, 'must be an https origin with no path, query, fragment or trailing slash');
if (!Array.isArray(sso.providers) || sso.providers.length === 0 || sso.providers.length > 8) refuse(`${at}.providers`, 'must be a list of 1 to 8 providers');
const ids = new Set();
sso.providers.forEach((p, i) => {
const pat = `${at}.providers[${i}]`;
keys(p, pat, ['id', 'issuer', 'clientId', 'clientSecret', 'algs', 'subjects']);
if (typeof p.id !== 'string' || !PROVIDER_ID.test(p.id)) refuse(`${pat}.id`, 'must match [a-z0-9-]{1,32}');
if (ids.has(p.id)) refuse(`${pat}.id`, 'repeats an earlier provider id');
ids.add(p.id);
httpsURL(p.issuer, `${pat}.issuer`);
if (typeof p.clientId !== 'string' || !CLIENT_ID.test(p.clientId)) refuse(`${pat}.clientId`, 'must be 1 to 255 visible characters');
keys(p.clientSecret, `${pat}.clientSecret`, ['slot']);
if (typeof p.clientSecret.slot !== 'string' || !SLOT.test(p.clientSecret.slot)) refuse(`${pat}.clientSecret.slot`, 'must match [a-z0-9-]{1,64}');
if (!Array.isArray(p.algs) || p.algs.length === 0) refuse(`${pat}.algs`, 'must be a non-empty list');
p.algs.forEach((alg, j) => {
if (!ALGS.includes(alg)) refuse(`${pat}.algs[${j}]`, `must be one of ${ALGS.join(', ')}; HS* and none are refused`);
});
if (new Set(p.algs).size !== p.algs.length) refuse(`${pat}.algs`, 'repeats an algorithm');
if (!Array.isArray(p.subjects) || p.subjects.length === 0 || p.subjects.length > 64) refuse(`${pat}.subjects`, 'must be a list of 1 to 64 subjects');
const subs = new Set();
p.subjects.forEach((s, j) => {
const sat = `${pat}.subjects[${j}]`;
keys(s, sat, ['sub', 'name']);
if (typeof s.sub !== 'string' || !SUB.test(s.sub)) refuse(`${sat}.sub`, 'must be 1 to 255 visible characters');
if (subs.has(s.sub)) refuse(`${sat}.sub`, 'repeats an earlier subject');
subs.add(s.sub);
if (typeof s.name !== 'string' || !NAME.test(s.name)) refuse(`${sat}.name`, 'must be 1 to 64 visible characters');
if (RESERVED_NAMES.includes(s.name.toLowerCase())) refuse(`${sat}.name`, `must not be ${RESERVED_NAMES.join(' or ')}`);
});
});
return value;
}
-347
View File
@@ -1,347 +0,0 @@
// Q175 phase 1 (#1690, design agents/sage/work/q175-sso/DESIGN.md): the Console as an OIDC relying
// party. Used only by the human listener under --sso; startServer refuses it on the tester listener.
// Every URL handed to a provider is built from the configured publicOrigin, never from a request's
// Host. A refusal logs one fixed code and the provider id, never an exception, its message or its
// cause: oauth4webapi's errors carry token bodies and claims there.
import { createHash, randomBytes } from 'node:crypto';
import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, realpathSync } from 'node:fs';
import { homedir } from 'node:os';
import { join, relative, isAbsolute, resolve } from 'node:path';
import { checkConsole } from './sso-config.mjs';
// oauth4webapi loads in createSso, not here: cli.mjs imports this module on every start, and a
// Console without --sso must not need the package (pinned-copy.test.mjs builds copies whose lock
// installs nothing). A real copy installs it from the root lock.
let oauth;
export const SESSION_COOKIE = '__Host-mosaic_session';
export const LOGIN_COOKIE = '__Host-mosaic_login';
const ATTRS = 'Path=/; Secure; HttpOnly; SameSite=Lax';
const LOGIN_MAX_AGE = 600;
const SESSION_ABSOLUTE = 8 * 3600 * 1000, SESSION_IDLE = 3600 * 1000;
const MAX_SESSIONS = 64, MAX_LOGINS = 32;
const FETCH_TIMEOUT = 10000;
const RETRY_CAP = 300000;
const CODES = new Set(['login-record-missing', 'state-mismatch', 'auth-error-response', 'grant-failed', 'id-token-invalid', 'signature-invalid', 'alg-refused', 'issuer-mismatch', 'subject-unknown', 'discovery-failed', 'jwks-failed']);
const sha256 = value => createHash('sha256').update(value).digest('hex');
const escape = text => String(text).replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`);
const under = (parent, child) => { const rel = relative(parent, child); return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel)); };
// The slot file holding a provider's client secret (design section 4, D1). Only the Console's --sso
// start calls this. A refusal names the provider and the check, never the path's contents.
export function readSlot(configDir, provider, { repo, checkout = null, copies = join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'mosaic-stack', 'copies') } = {}) {
const where = `console.sso provider ${provider.id} client secret slot`;
const file = join(resolve(configDir), 'secrets', 'mosaic-stack', provider.clientSecret.slot);
let st;
try { st = lstatSync(file); } catch { throw new Error(`${where} is missing`); }
if (st.isSymbolicLink()) throw new Error(`${where} must not be a symbolic link`);
let fd, real, text;
try {
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
st = fstatSync(fd);
if (!st.isFile()) throw new Error(`${where} must be a regular file`);
if (st.uid !== process.getuid()) throw new Error(`${where} must be owned by the Console's user`);
if (st.mode & 0o077) throw new Error(`${where} must have no group or other permission bits`);
real = realpathSync(file);
for (const root of [repo, checkout, copies].filter(Boolean)) {
let base;
try { base = realpathSync(root); } catch { base = resolve(root); }
if (under(base, real)) throw new Error(`${where} must not be inside the repository or a stack copy`);
}
text = readFileSync(fd, 'utf8');
} catch (err) {
throw err.message?.startsWith(where) ? err : new Error(`${where} is not readable`);
} finally {
if (fd !== undefined) closeSync(fd);
}
const secret = text.endsWith('\n') ? text.slice(0, -1) : text;
if (!secret || /[\r\n]/.test(secret)) throw new Error(`${where} must hold one non-empty line`);
return secret;
}
export function cookies(req, name) {
const found = [];
for (const part of (req.headers.cookie ?? '').split(';')) {
const at = part.indexOf('=');
if (at > 0 && part.slice(0, at).trim() === name) found.push(part.slice(at + 1).trim());
}
return found;
}
// The JWKS check at discovery, the same filter the library's key selection uses (kty, alg, use,
// key_ops, crv; RSA at least 2048 bits), without a kid since no token is at hand yet.
function usableKey(jwk, alg) {
const kty = { RS: 'RSA', PS: 'RSA', ES: 'EC', Ed: 'OKP', ML: 'AKP' }[alg.slice(0, 2)];
if (jwk === null || typeof jwk !== 'object' || jwk.kty !== kty) return false;
if (jwk.alg !== undefined && jwk.alg !== alg) return false;
if (jwk.use !== undefined && jwk.use !== 'sig') return false;
if (Array.isArray(jwk.key_ops) && !jwk.key_ops.includes('verify')) return false;
const crv = { ES256: 'P-256', ES384: 'P-384', ES512: 'P-521', Ed25519: 'Ed25519', EdDSA: 'Ed25519' }[alg];
if (crv && jwk.crv !== crv) return false;
if (kty === 'RSA' && (typeof jwk.n !== 'string' || Buffer.from(jwk.n, 'base64url').length * 8 < 2048)) return false;
return true;
}
class Unreachable extends Error {}
class Refused extends Error {}
const https = value => { try { return new URL(value).protocol === 'https:'; } catch { return false; } };
// One provider's discovery and JWKS. Throws Unreachable (network, timeout, a status other than 200,
// a body that isn't JSON) or Refused (a JSON document that fails a check). Q-B, design section 4.
async function discover(p, fetchOptions) {
let response, text;
const options = () => ({ ...fetchOptions, signal: AbortSignal.timeout(FETCH_TIMEOUT) });
try {
response = await oauth.discoveryRequest(new URL(p.issuer), options());
if (response.status !== 200 || !/^application\/json\b/i.test(response.headers.get('content-type') ?? '')) throw new Error();
text = await response.text();
JSON.parse(text);
} catch { throw new Unreachable(); }
let as;
try {
as = await oauth.processDiscoveryResponse(new URL(p.issuer), new Response(text, { status: 200, headers: { 'content-type': 'application/json' } }));
} catch { throw new Refused(); }
if (as.issuer !== p.issuer) throw new Refused();
if (!Array.isArray(as.id_token_signing_alg_values_supported) || !p.algs.some(a => as.id_token_signing_alg_values_supported.includes(a))) throw new Refused();
if (!Array.isArray(as.code_challenge_methods_supported) || !as.code_challenge_methods_supported.includes('S256')) throw new Refused();
if (![as.authorization_endpoint, as.token_endpoint, as.jwks_uri].every(https)) throw new Refused();
if (as.end_session_endpoint !== undefined && !https(as.end_session_endpoint)) throw new Refused();
let jwks;
try {
const fetcher = fetchOptions[oauth.customFetch] ?? fetch;
const res = await fetcher(as.jwks_uri, { method: 'GET', headers: { accept: 'application/json, application/jwk-set+json' }, redirect: 'manual', signal: AbortSignal.timeout(FETCH_TIMEOUT) });
if (res.status !== 200) throw new Error();
jwks = JSON.parse(await res.text());
} catch { throw new Unreachable(); }
if (jwks === null || typeof jwks !== 'object' || !Array.isArray(jwks.keys)) throw new Refused();
const usable = p.algs.filter(alg => jwks.keys.some(k => usableKey(k, alg)));
if (!usable.length) throw new Refused();
return as;
}
// `config` is console.sso, checked again here; `secrets` maps provider id to its client secret
// (readSlot). `fetch` is for tests: a fetch that trusts only the fake OP's certificate.
// `retryFirst` is for tests too; production waits 5 s, doubling, capped at 5 minutes.
export async function createSso(config, { secrets, fetch: customFetch = null, log = line => console.log(line), retryFirst = 5000 } = {}) {
checkConsole({ sso: config });
oauth ??= await import('oauth4webapi');
const publicOrigin = config.publicOrigin;
const publicHost = new URL(publicOrigin).host;
const redirectUri = `${publicOrigin}/auth/callback`;
const fetchOptions = customFetch ? { [oauth.customFetch]: customFetch } : {};
const sessions = new Map(), logins = new Map();
const timers = new Set();
let closed = false;
const refuse = (code, provider, extra = '') => {
if (!CODES.has(code)) throw new Error(`unknown sso log code ${code}`);
log(`${new Date().toISOString()} sso refused code=${code} provider=${provider}${extra}`);
};
const providers = new Map(config.providers.map(p => {
if (typeof secrets?.get(p.id) !== 'string') throw new Error(`console.sso provider ${p.id} has no client secret`);
return [p.id, {
config: p, state: 'unreachable', as: null, jwks: {},
client: { client_id: p.clientId, id_token_signed_response_alg: [...p.algs] },
auth: oauth.ClientSecretBasic(secrets.get(p.id)),
subjects: new Map(p.subjects.map(s => [s.sub, s.name])),
}];
}));
async function attempt(entry, atStart) {
try {
entry.as = await discover(entry.config, fetchOptions);
entry.jwks = {};
entry.state = 'ready';
} catch (err) {
if (err instanceof Refused) {
entry.state = 'refused';
refuse('discovery-failed', entry.config.id);
if (atStart) throw new Error(`console.sso provider ${entry.config.id} refused at discovery: the issuer, algorithms, S256 or JWKS check failed`);
return;
}
entry.state = 'unreachable';
refuse('discovery-failed', entry.config.id);
throw err;
}
}
function retry(entry, delay) {
if (closed) return;
const timer = setTimeout(async () => {
timers.delete(timer);
try { await attempt(entry, false); } catch { retry(entry, Math.min(delay * 2, RETRY_CAP)); }
}, delay);
timer.unref();
timers.add(timer);
}
for (const entry of providers.values()) {
try { await attempt(entry, true); } catch (err) {
if (!(err instanceof Unreachable)) throw err;
retry(entry, retryFirst);
}
}
const page = (res, status, title, html, headers = {}) => {
res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', ...headers });
res.end(`<!doctype html><meta charset="utf-8"><title>${escape(title)}</title><main style="font-family:sans-serif;max-width:32rem;margin:4rem auto"><h1>${escape(title)}</h1>${html}</main>\n`);
};
const redirect = (res, location, setCookie = []) => {
res.writeHead(303, { location, 'cache-control': 'no-store', ...(setCookie.length ? { 'set-cookie': setCookie } : {}) });
res.end();
};
const failed = (res, setCookie = []) => page(res, 403, 'Sign-in failed', '<p><a href="/auth/login">Sign in again</a></p>', setCookie.length ? { 'set-cookie': setCookie } : {});
const expire = name => `${name}=; ${ATTRS}; Max-Age=0`;
const remember = (map, key, value, cap) => {
while (map.size >= cap) map.delete(map.keys().next().value);
map.set(key, value);
};
// B2: only a URL on publicOrigin itself; anything else, or nothing, is publicOrigin's `/`. The
// Location is absolute and its origin checked again: dot segments can leave a pathname of `//host`,
// which as a relative Location a browser reads as another origin (webby-0019 B1, sage-1403).
function returnURL(ret) {
const home = `${publicOrigin}/`;
try {
if (typeof ret !== 'string' || !ret) return home;
const u = new URL(ret, publicOrigin);
if (u.origin !== publicOrigin) return home;
const out = new URL(`${publicOrigin}${u.pathname}${u.search}`);
return out.origin === publicOrigin ? out.href : home;
} catch { return home; }
}
async function start(res, entry, ret) {
const state = oauth.generateRandomState(), nonce = oauth.generateRandomNonce(), verifier = oauth.generateRandomCodeVerifier();
const challenge = await oauth.calculatePKCECodeChallenge(verifier);
const id = randomBytes(32).toString('base64url');
remember(logins, sha256(id), { provider: entry.config.id, state, nonce, verifier, ret: typeof ret === 'string' ? ret.slice(0, 2048) : '/', expires: Date.now() + LOGIN_MAX_AGE * 1000 }, MAX_LOGINS);
const url = new URL(entry.as.authorization_endpoint);
for (const [k, v] of Object.entries({ client_id: entry.config.clientId, response_type: 'code', scope: 'openid profile email', redirect_uri: redirectUri, state, nonce, code_challenge: challenge, code_challenge_method: 'S256' })) url.searchParams.set(k, v);
return redirect(res, url.href, [`${LOGIN_COOKIE}=${id}; ${ATTRS}; Max-Age=${LOGIN_MAX_AGE}`]);
}
function login(res, query) {
const asked = query.get('provider');
const ret = query.get('return');
if (asked === null && providers.size > 1) {
const items = [...providers.values()].map(e => `<li><a href="/auth/login?${escape(new URLSearchParams({ provider: e.config.id, ...(ret ? { return: ret } : {}) }))}">${escape(e.config.id)}</a></li>`).join('');
return page(res, 200, 'Sign in', `<ul>${items}</ul>`);
}
const entry = asked === null ? [...providers.values()][0] : providers.get(asked);
if (!entry) return page(res, 404, 'Unknown sign-in provider', '<p><a href="/auth/login">Sign in</a></p>');
if (entry.state !== 'ready') return page(res, 503, 'sign-in unavailable', '<p>The sign-in provider can\'t be reached. Try again shortly.</p>');
return start(res, entry, ret);
}
// Design section 1, steps 2-7. Each refusal ends here with one log line and the 403 page.
async function callback(res, query, req) {
const clear = [expire(LOGIN_COOKIE)];
const ids = cookies(req, LOGIN_COOKIE);
const key = ids.length === 1 ? sha256(ids[0]) : null;
const record = key === null ? undefined : logins.get(key);
if (key !== null) logins.delete(key);
// The provider comes from the record, never from the query.
const entry = record && providers.get(record.provider);
if (!record || record.expires <= Date.now() || !entry) {
refuse('login-record-missing', record?.provider ?? 'none');
return failed(res, clear);
}
const pid = entry.config.id;
if (entry.state !== 'ready') { refuse('discovery-failed', pid); return failed(res, clear); }
let params;
try { params = oauth.validateAuthResponse(entry.as, entry.client, query, record.state); } catch (err) {
refuse(err instanceof oauth.AuthorizationResponseError ? 'auth-error-response' : 'state-mismatch', pid);
return failed(res, clear);
}
const options = () => ({ ...fetchOptions, [oauth.jwksCache]: entry.jwks, signal: AbortSignal.timeout(FETCH_TIMEOUT) });
let response;
try { response = await oauth.authorizationCodeGrantRequest(entry.as, entry.client, entry.auth, params, redirectUri, record.verifier, options()); } catch {
refuse('grant-failed', pid); return failed(res, clear);
}
let result, claims;
try {
result = await oauth.processAuthorizationCodeResponse(entry.as, entry.client, response, { expectedNonce: record.nonce, requireIdToken: true });
claims = oauth.getValidatedIdTokenClaims(result);
} catch (err) {
refuse(err instanceof oauth.ResponseBodyError ? 'grant-failed' : 'id-token-invalid', pid);
return failed(res, clear);
}
// Step 5: the library skips this for a token-endpoint ID token; point 2 needs it, on the same
// Response object. Its throw ends the callback.
try { await oauth.validateApplicationLevelSignature(entry.as, response, options()); } catch (err) {
const fetching = !(err instanceof oauth.OperationProcessingError || err instanceof oauth.UnsupportedOperationError) || [oauth.RESPONSE_IS_NOT_CONFORM, oauth.RESPONSE_IS_NOT_JSON].includes(err.code);
refuse(fetching ? 'jwks-failed' : 'signature-invalid', pid);
return failed(res, clear);
}
// Step 6: our own checks on top of the library's.
let header;
try { header = JSON.parse(Buffer.from(result.id_token.split('.')[0], 'base64url').toString('utf8')); } catch { header = null; }
if (!header || !entry.config.algs.includes(header.alg)) { refuse('alg-refused', pid); return failed(res, clear); }
if (claims.iss !== entry.config.issuer) { refuse('issuer-mismatch', pid); return failed(res, clear); }
const name = typeof claims.sub === 'string' ? entry.subjects.get(claims.sub) : undefined;
if (name === undefined) {
refuse('subject-unknown', pid, ` iss=${JSON.stringify(String(claims.iss).slice(0, 255))} sub=${JSON.stringify(String(claims.sub).slice(0, 255))}`);
return failed(res, clear);
}
// Step 7: the session, then the return path.
const id = randomBytes(32).toString('base64url'), now = Date.now();
remember(sessions, sha256(id), { provider: pid, iss: claims.iss, sub: claims.sub, name, idToken: result.id_token, created: now, seen: now }, MAX_SESSIONS);
return redirect(res, returnURL(record.ret), [...clear, `${SESSION_COOKIE}=${id}; ${ATTRS}`]);
}
// The session for this request, or null. `touch` is true only for a page load or a write (Q-D).
function session(req, { touch = false } = {}) {
const ids = cookies(req, SESSION_COOKIE);
if (ids.length !== 1) return null;
const key = sha256(ids[0]);
const s = sessions.get(key);
if (!s) return null;
const now = Date.now();
if (now - s.created >= SESSION_ABSOLUTE || now - s.seen >= SESSION_IDLE) { sessions.delete(key); return null; }
if (touch) s.seen = now;
return s;
}
function logout(req, res) {
const one = name => (req.headersDistinct[name]?.length === 1 ? req.headersDistinct[name][0] : null);
if (one('origin') !== publicOrigin || one('sec-fetch-site') !== 'same-origin') {
return page(res, 403, 'Sign-out refused', '<p>Sign out from the Console page.</p>');
}
const ids = cookies(req, SESSION_COOKIE);
const key = ids.length === 1 ? sha256(ids[0]) : null;
const s = key === null ? undefined : sessions.get(key);
if (key !== null) sessions.delete(key);
const entry = s && providers.get(s.provider);
let location = `${publicOrigin}/auth/signed-out`;
if (entry?.as?.end_session_endpoint) {
const url = new URL(entry.as.end_session_endpoint);
url.searchParams.set('id_token_hint', s.idToken);
url.searchParams.set('post_logout_redirect_uri', `${publicOrigin}/auth/signed-out`);
url.searchParams.set('client_id', entry.config.clientId);
location = url.href;
}
return redirect(res, location, [expire(SESSION_COOKIE)]);
}
// `/auth/*` on the human listener; the caller has already checked the Host.
async function handle(req, res, path, search) {
const query = new URLSearchParams(search);
if (path === '/auth/logout') {
if (req.method !== 'POST') return page(res, 405, 'Method not allowed', '');
return logout(req, res);
}
if (req.method !== 'GET') return page(res, 405, 'Method not allowed', '');
if (path === '/auth/login') return login(res, query);
if (path === '/auth/callback') return callback(res, query, req);
if (path === '/auth/signed-out') return page(res, 200, 'Signed out', '<p><a href="/auth/login">Sign in</a></p>');
return page(res, 404, 'Not found', '');
}
return {
publicOrigin, publicHost, sessions, logins, handle, session, returnURL,
health: () => Object.fromEntries([...providers].map(([id, e]) => [id, e.state])),
formTargets: () => [...new Set([...providers.values()].filter(e => e.as?.end_session_endpoint).map(e => new URL(e.as.end_session_endpoint).origin))],
summary: () => config.providers.map(p => ({ id: p.id, issuer: p.issuer, subjects: p.subjects.length })),
close() { closed = true; for (const t of timers) clearTimeout(t); timers.clear(); },
};
}
-155
View File
@@ -1,155 +0,0 @@
// Q175 phase 1 (#1690, design section 9): a fake OpenID provider for the --sso tests. A loopback
// https server with a self-signed certificate made in a temp dir; `op.fetch` is a fetch that trusts
// only that certificate, so the code under test needs no insecure flag. It serves any number of
// issuers (`/<name>/`) at once, each with discovery, a JWKS, authorization, token and end-session
// endpoints. Keys come from node:crypto. Nothing here reaches any other host.
import { constants, createHash, createHmac, generateKeyPairSync, randomBytes, sign } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { createServer, request } from 'node:https';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
export const b64url = value => Buffer.from(typeof value === 'string' ? value : JSON.stringify(value)).toString('base64url');
export function rsaKey(kid = `rsa-${randomBytes(4).toString('hex')}`, bits = 2048) {
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: bits });
return { kid, alg: 'RS256', privateKey, jwk: { ...publicKey.export({ format: 'jwk' }), kid, use: 'sig', alg: 'RS256' } };
}
export function ecKey(kid = `ec-${randomBytes(4).toString('hex')}`) {
const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' });
return { kid, alg: 'ES256', privateKey, jwk: { ...publicKey.export({ format: 'jwk' }), kid, use: 'sig', alg: 'ES256' } };
}
export function psKey(kid = `ps-${randomBytes(4).toString('hex')}`) {
const k = rsaKey(kid);
return { ...k, alg: 'PS256', jwk: { ...k.jwk, alg: 'PS256' } };
}
// A compact JWS. `alg` HS256 uses `hmac` as the key, `none` has an empty signature.
export function jwt(header, payload, { key = null, hmac = null } = {}) {
const input = `${b64url(header)}.${b64url(payload)}`;
let sig = '';
if (header.alg === 'HS256') sig = createHmac('sha256', hmac).update(input).digest('base64url');
else if (header.alg === 'RS256') sig = sign('sha256', Buffer.from(input), key).toString('base64url');
else if (header.alg === 'PS256') sig = sign('sha256', Buffer.from(input), { key, padding: constants.RSA_PKCS1_PSS_PADDING, saltLength: 32 }).toString('base64url');
else if (header.alg === 'ES256') sig = sign('sha256', Buffer.from(input), { key, dsaEncoding: 'ieee-p1363' }).toString('base64url');
return `${input}.${sig}`;
}
function certificate() {
const dir = mkdtempSync(join(tmpdir(), 'fake-oidc-'));
try {
const r = spawnSync('openssl', ['req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256', '-nodes', '-days', '1',
'-subj', '/CN=127.0.0.1', '-addext', 'subjectAltName=IP:127.0.0.1', '-keyout', join(dir, 'key.pem'), '-out', join(dir, 'cert.pem')], { encoding: 'utf8' });
if (r.status !== 0) throw new Error(`openssl failed: ${r.stderr}`);
return { key: readFileSync(join(dir, 'key.pem')), cert: readFileSync(join(dir, 'cert.pem')) };
} finally { rmSync(dir, { recursive: true, force: true }); }
}
// One issuer's state. `discovery` is merged over the defaults; `mode` is 'ok', 'down' (the socket is
// destroyed) or 'html' (200 with an HTML page) for discovery.
function issuerState(name, base, opts) {
const keys = opts.keys ?? [rsaKey()];
return {
name, issuer: `${base}/${name}/`, keys, retired: [], clientId: opts.clientId ?? `client-${name}`, clientSecret: opts.clientSecret ?? `secret-${name}-${randomBytes(6).toString('hex')}`,
discovery: opts.discovery ?? {}, mode: 'ok', jwksOverride: null, codes: new Map(), acceptTwice: false, requirePkce: true,
tokenRequests: [], authorizeRequests: [], jwksFetches: 0, discoveryFetches: 0,
};
}
export async function fakeOidc() {
const { key, cert } = certificate();
const issuers = new Map();
const server = createServer({ key, cert }, (req, res) => {
const url = new URL(req.url, 'https://127.0.0.1');
const [, name, ...rest] = url.pathname.split('/');
const iss = issuers.get(name);
const route = rest.join('/');
const json = (status, body, type = 'application/json') => { res.writeHead(status, { 'content-type': type }); res.end(typeof body === 'string' ? body : JSON.stringify(body)); };
if (!iss) return json(404, { error: 'no issuer' });
if (route === '.well-known/openid-configuration') {
iss.discoveryFetches++;
if (iss.mode === 'down') return req.socket.destroy();
if (iss.mode === 'html') return json(200, '<!doctype html><title>Sign in</title>', 'text/html');
return json(200, discovery(iss));
}
if (route === 'jwks') {
iss.jwksFetches++;
return json(200, iss.jwksOverride ?? { keys: iss.keys.map(k => k.jwk) });
}
if (route === 'authorize') { iss.authorizeRequests.push(Object.fromEntries(url.searchParams)); return json(200, { ok: true }); }
if (route === 'end-session') return json(200, { ok: true });
if (route === 'token' && req.method === 'POST') {
let text = '';
req.on('data', c => (text += c));
req.on('end', () => {
const form = new URLSearchParams(text);
const basic = Buffer.from((req.headers.authorization ?? '').replace(/^Basic /, ''), 'base64').toString();
const [id, secret] = basic.split(':').map(decodeURIComponent);
iss.tokenRequests.push({ form: Object.fromEntries(form), clientId: id });
if (id !== iss.clientId || secret !== iss.clientSecret) return json(401, { error: 'invalid_client' });
const grant = iss.codes.get(form.get('code'));
if (!grant) return json(400, { error: 'invalid_grant' });
if (!iss.acceptTwice) iss.codes.delete(form.get('code'));
if (form.get('redirect_uri') !== grant.redirectUri) return json(400, { error: 'invalid_grant', error_description: 'redirect_uri' });
if (iss.requirePkce && createHash('sha256').update(form.get('code_verifier') ?? '').digest('base64url') !== grant.challenge) return json(400, { error: 'invalid_grant', error_description: 'pkce' });
return json(200, { access_token: randomBytes(16).toString('hex'), token_type: 'Bearer', expires_in: 300, id_token: grant.idToken });
});
return;
}
return json(404, { error: 'not found' });
});
await new Promise(r => server.listen(0, '127.0.0.1', r));
const base = `https://127.0.0.1:${server.address().port}`;
const discovery = iss => ({
issuer: iss.issuer, authorization_endpoint: `${iss.issuer}authorize`, token_endpoint: `${iss.issuer}token`, jwks_uri: `${iss.issuer}jwks`,
end_session_endpoint: `${iss.issuer}end-session`, response_types_supported: ['code'], subject_types_supported: ['public'],
id_token_signing_alg_values_supported: [...new Set(iss.keys.map(k => k.alg))], code_challenge_methods_supported: ['S256'],
...iss.discovery,
});
// A fetch over node:https that trusts only this certificate. oauth4webapi passes a Headers object
// and a string or URLSearchParams body.
const opFetch = (input, init = {}) => new Promise((resolve, reject) => {
const url = new URL(typeof input === 'string' ? input : input.href ?? input.url);
if (url.protocol !== 'https:' || url.hostname !== '127.0.0.1' || url.port !== String(server.address().port)) return reject(new TypeError(`fake OP fetch refuses ${url.origin}`));
const headers = Object.fromEntries(new Headers(init.headers ?? {}));
const body = init.body === undefined || init.body === null ? undefined : String(init.body);
if (body !== undefined) headers['content-length'] = Buffer.byteLength(body);
const req = request(url, { method: init.method ?? 'GET', headers, ca: cert, signal: init.signal }, res => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
const h = new Headers();
for (const [k, v] of Object.entries(res.headers)) h.set(k, Array.isArray(v) ? v.join(', ') : v);
const response = new Response(res.statusCode === 204 ? null : Buffer.concat(chunks), { status: res.statusCode, headers: h });
Object.defineProperty(response, 'url', { value: url.href });
resolve(response);
});
res.on('error', reject);
});
req.on('error', reject);
req.end(body);
});
return {
base, fetch: opFetch, cert,
issuer(name, opts = {}) { const iss = issuerState(name, base, opts); issuers.set(name, iss); return iss; },
// A code for `iss` whose ID token is built from the start redirect's nonce, the claims and header
// overrides. `challenge` defaults to the start redirect's; `idToken` replaces the token outright.
grant(iss, authorizeUrl, { sub = `user-${iss.name}`, claims = {}, header = {}, key = iss.keys[0], idToken = null, challenge = null, redirectUri = null } = {}) {
const q = new URL(authorizeUrl).searchParams;
const now = Math.floor(Date.now() / 1000);
const payload = { iss: iss.issuer, sub, aud: iss.clientId, iat: now, exp: now + 300, nonce: q.get('nonce'), ...claims };
for (const [k, v] of Object.entries(payload)) if (v === undefined) delete payload[k];
const token = idToken ?? jwt({ alg: key.alg, kid: key.kid, typ: 'JWT', ...header }, payload, { key: key.privateKey, hmac: iss.clientSecret });
const code = randomBytes(12).toString('base64url');
iss.codes.set(code, { idToken: token, challenge: challenge ?? q.get('code_challenge'), redirectUri: redirectUri ?? q.get('redirect_uri') });
return { code, idToken: token, payload };
},
rotate(iss, next = rsaKey()) { iss.retired.push(...iss.keys); iss.keys = [next]; return next; },
close: () => new Promise(r => { server.close(r); server.closeAllConnections(); }),
};
}
+1 -1
View File
@@ -30,7 +30,7 @@ function fakeCopy(dir) {
writeFileSync(join(copyRoot(dir), 'packages', 'webui', 'src', 'cli.mjs'), '');
return copyRoot(dir);
}
const SEND_REFUSAL = /^webui-service: --send needs the preview mode \(--public-host, --auth-header and --allow-user\) or --sso$/m;
const SEND_REFUSAL = /^webui-service: --send needs the preview mode \(--public-host, --auth-header and --allow-user\)$/m;
const TEMPLATE = readFileSync(join(REPO, 'packages', 'webui', 'systemd', 'mosaic-webui.service.in'), 'utf8');
// The unit as the base script renders it (row 75): the template with its four
-132
View File
@@ -1,132 +0,0 @@
// Q175 phase 1 (#1690, design test 24, sage-1340 F1, sage-1347 F7): the page under --sso. A read
// the Console answers 401 `sign-in required` signs the page out the way a proxy redirect does, and
// the line links to /auth/login with the current path and query; any other 401 stays an error. Both
// fetch paths in app.js and both in bus.js (fetchRead and fetchApi) do it. The Sign out form shows
// only when /api/config says sso. The board-held.test.mjs stub, with two lists of paths: those in
// window.__signIn answer 401 `sign-in required`, those in window.__other 401 `unauthenticated`. A
// page URL holding `sso` gets `sso: true` added to /api/config. Poll timers fire after 150 ms.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Store } from '../../bus/src/store.mjs';
import { Broker } from '../../bus/src/broker.mjs';
import { views } from '../../bus/src/views.mjs';
import { startServer } from '../src/serve.mjs';
import { browser } from './browser.mjs';
import { fixture, close } from './fixture.mjs';
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles: { pm: { authority: { withinRole: ['message.send'], crossRole: [] } }, cto: { authority: { withinRole: ['message.send'], crossRole: [] } } } } };
const stub = `(() => {
const real = window.fetch.bind(window);
window.__reqs = []; window.__signIn = []; window.__other = [];
const timeout = window.setTimeout.bind(window);
window.setTimeout = (f, ms, ...rest) => timeout(f, ms === 10000 ? 150 : ms, ...rest);
const answer = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
window.fetch = async (url, init = {}) => {
const path = String(url).split('?')[0];
window.__reqs.push(path);
if (window.__signIn.includes(path)) return answer(401, { error: 'sign-in required' });
if (window.__other.includes(path)) return answer(401, { error: 'unauthenticated' });
if (path === '/api/config' && /sso/.test(location.search)) return answer(200, { ...(await (await real(url, init)).json()), sso: true });
return real(url, init);
};
})();`;
async function setup() {
const root = mkdtempSync(join(tmpdir(), 'webui-sso-browser-'));
const store = new Store(root), b0 = new Broker({ store, businesses });
b0.request(b0.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-run', harness: 'pi', address: 'pm-run' }), { verb: 'role.claim' });
const reader = b0.bindReader({ business: 'demo' });
const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) });
const f = await fixture(), b = await browser({ machine: true });
const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`;
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub });
await b.viewport(1280);
const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`);
const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`);
const shown = sel => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(sel)});return !!e && !e.hidden && getComputedStyle(e).display !== 'none' && !!e.getClientRects().length})()`);
const reqs = () => b.evaluate('window.__reqs.length');
const settle = () => new Promise(r => setTimeout(r, 600));
const done = async () => { await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); };
return { b, base, wait, text, shown, reqs, settle, done };
}
const LOADED = 'document.querySelector("table.sessions [data-open=\'proj/agent1\']")';
const LINE = 'Signed out. Sign in again.';
test('a 401 sign-in required on each fetch path signs the page out with a sign-in link and stops the refresh; another 401 stays an error', { timeout: 120000 }, async () => {
const { b, base, wait, text, shown, reqs, settle, done } = await setup();
// The line, its link back to this page, the Sign out form gone, and no further fetch.
const out = async ret => {
await wait('!document.querySelector("#signed-out").hidden');
assert.equal(await text('#signed-out'), LINE);
assert.equal(await b.evaluate('document.querySelector("#signed-out a").getAttribute("href")'), `/auth/login?return=${encodeURIComponent(ret)}`);
assert.equal(await shown('#sign-out'), false);
assert.equal(await b.evaluate('document.querySelector("#refresh").disabled'), true);
const n = await reqs();
await b.evaluate('document.querySelector("#refresh").click(), true');
await settle();
assert.equal(await reqs(), n, 'the page fetched after signing out');
};
try {
// app.js api(): the Board read, with sso on from /api/config.
await b.navigate(`${base}?sso&x=1`);
await wait(LOADED);
await b.evaluate(`window.__signIn.push('/api/board'), document.querySelector('#refresh').click(), true`);
await out('/?sso&x=1');
// app.js getJSON: the conversation catalogue behind History.
await b.navigate(`${base}?sso&conv`);
await wait(LOADED);
await b.evaluate(`window.__signIn.push('/api/conversations'), document.querySelector('table.sessions [data-history="proj/agent1"]').click(), true`);
await out('/?sso&conv');
// app.js api(): the board POST behind Seen.
await b.navigate(`${base}?sso&seen`);
await wait(LOADED);
await b.evaluate(`document.querySelector('table.sessions [data-open="proj/agent1"]').click(), true`);
await wait('document.querySelector("#inspection [data-seen]")');
await b.evaluate(`window.__signIn.push('/api/seen'), document.querySelector('#inspection [data-seen]').click(), true`);
await out('/?sso&seen');
// bus.js fetchRead and fetchApi, on a page whose /api/config has no sso: the link comes from
// the 401 itself (F1).
await b.navigate(`${base}?read#/inbox`);
await wait(LOADED);
await b.evaluate(`window.__signIn.push('/api/bus/inbox'), document.querySelector('#refresh').click(), true`);
await out('/?read');
await b.navigate(`${base}?module`);
await wait(LOADED);
await b.evaluate(`window.__signIn.push('/api/queue'), location.hash = '#/queue', true`);
await out('/?module');
// Any other 401 is an error: no sign-out, and the polls go on.
await b.navigate(`${base}?sso&other`);
await wait(LOADED);
await b.evaluate(`window.__other.push('/api/board', '/api/bus/inbox', '/api/queue'), document.querySelector('#refresh').click(), true`);
await settle();
assert.equal(await b.evaluate('document.querySelector("#signed-out").hidden'), true);
assert.equal(await b.evaluate('document.body.classList.contains("signed-out")'), false);
const n = await reqs();
await settle();
assert.ok(await reqs() > n, 'the refresh stopped on another 401');
assert.equal(await shown('#sign-out'), true);
} finally { await done(); }
});
test('the Sign out form shows only when /api/config says sso, and posts to /auth/logout', { timeout: 120000 }, async () => {
const { b, base, wait, shown, done } = await setup();
try {
await b.navigate(base);
await wait(LOADED);
await new Promise(r => setTimeout(r, 300));
assert.equal(await shown('#sign-out'), false);
await b.navigate(`${base}?sso`);
await wait(`${LOADED} && !document.querySelector("#sign-out").hidden`);
assert.equal(await shown('#sign-out'), true);
assert.deepEqual(await b.evaluate('(() => { const f = document.querySelector("#sign-out"); return [f.method, f.getAttribute("action"), f.querySelector("button").textContent, f.querySelector("button").type]; })()'), ['post', '/auth/logout', 'Sign out', 'submit']);
} finally { await done(); }
});
-589
View File
@@ -1,589 +0,0 @@
// Q175 phase 1 (#1690, design agents/sage/work/q175-sso/DESIGN.md section 9): the Console as an
// OIDC relying party (--sso). Every provider is the fake OP in helpers/fake-oidc.mjs on loopback;
// no test reaches a real provider, the live Console or a real config or slot file. The public name
// is console.example.test, sent only as a Host header to the test's own loopback listener. Each
// refusal checks the status, that no session cookie was set and that the send stub never ran.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { request } from 'node:http';
import { spawn, spawnSync } from 'node:child_process';
import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { startServer } from '../src/serve.mjs';
import { createSso, readSlot, SESSION_COOKIE, LOGIN_COOKIE } from '../src/sso.mjs';
import { close } from './fixture.mjs';
import { b64url, ecKey, fakeOidc, jwt, psKey, rsaKey } from './helpers/fake-oidc.mjs';
const HOST = 'console.example.test', ORIGIN = `https://${HOST}`;
const CLI = fileURLToPath(new URL('../src/cli.mjs', import.meta.url));
const MSG = JSON.stringify({ to: 'coder', body: 'Please look at row 192.' });
// Test 19: every log line any test here captured, and every token, code, state, nonce, verifier,
// client secret and session id any test produced. The last test checks the one against the other.
const ALL_LINES = [], PRODUCED = new Set();
// node:http, not fetch: fetch can't set Host, Origin or Sec-Fetch-Site.
function call(port, { method = 'GET', path = '/', headers = {}, body, host = HOST } = {}) {
return new Promise((resolve, reject) => {
const length = body === undefined ? {} : { 'content-length': Buffer.byteLength(body) };
const req = request({ host: '127.0.0.1', port, method, path, headers: { host, ...length, ...headers } }, res => {
let text = '';
res.on('data', c => (text += c));
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: text && (res.headers['content-type'] ?? '').startsWith('application/json') ? JSON.parse(text) : text }));
});
req.on('error', reject);
req.end(body);
});
}
const setCookies = r => [r.headers['set-cookie'] ?? []].flat();
const sessionOf = r => setCookies(r).find(c => c.startsWith(`${SESSION_COOKIE}=`) && !c.includes('Max-Age=0'))?.split(';')[0] ?? null;
const writeHeaders = cookie => ({ ...(cookie ? { cookie } : {}), origin: ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' });
function providerFor(iss, extra = {}) {
return { id: iss.name, issuer: iss.issuer, clientId: iss.clientId, clientSecret: { slot: iss.name }, algs: [...new Set(iss.keys.map(k => k.alg))], subjects: [{ sub: `user-${iss.name}`, name: `person-${iss.name}` }], ...extra };
}
// A fake OP with `issuers`, createSso over them, and the human listener with a counting send.
async function setup(t, { issuers = [{ name: 'a' }], provider = {}, before = null, retryFirst = 5000, server = {} } = {}) {
const op = await fakeOidc();
t.after(() => op.close());
const iss = issuers.map(o => op.issuer(o.name, o));
for (const i of iss) PRODUCED.add(i.clientSecret);
before?.(op, iss);
const config = { publicOrigin: ORIGIN, providers: iss.map(i => providerFor(i, provider[i.name] ?? {})) };
const lines = [];
const log = line => { lines.push(line); ALL_LINES.push(line); };
const sso = await createSso(config, { secrets: new Map(iss.map(i => [i.name, i.clientSecret])), fetch: op.fetch, log, retryFirst });
t.after(() => sso.close());
const sends = [];
const bus = Object.fromEntries(['inbox', 'tasks', 'agents', 'trail', 'decisions'].map(v => [v, async () => [{ verb: v }]]));
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus, sso, send: async args => { sends.push(args); return { id: 'msg-0001' }; }, log, ...server });
t.after(() => close(web));
return { op, iss, sso, port: web.address().port, lines, sends, config, bus };
}
// GET /auth/login; returns the authorize URL and the login cookie (name=value).
async function begin(env, { provider = null, ret = null, query = null, host = HOST } = {}) {
const q = query ?? new URLSearchParams({ ...(provider ? { provider } : {}), ...(ret !== null ? { return: ret } : {}) }).toString();
const r = await call(env.port, { path: `/auth/login${q ? `?${q}` : ''}`, host });
assert.equal(r.status, 303, `login start: ${r.status} ${r.body}`);
const cookie = setCookies(r)[0].split(';')[0];
const location = r.headers.location;
const params = new URL(location).searchParams;
PRODUCED.add(cookie.split('=')[1]);
for (const k of ['state', 'nonce', 'code_challenge']) PRODUCED.add(params.get(k));
for (const record of env.sso.logins.values()) PRODUCED.add(record.verifier);
return { r, cookie, location, params };
}
function callback(env, begun, { code, state = begun.params.get('state'), extra = {}, cookie = begun.cookie } = {}) {
const q = new URLSearchParams({ ...(code !== undefined ? { code } : {}), state, ...extra });
return call(env.port, { path: `/auth/callback?${q}`, headers: cookie ? { cookie } : {} });
}
function grant(env, begun, iss, opts = {}) {
const g = env.op.grant(iss, begun.location, opts);
PRODUCED.add(g.code);
PRODUCED.add(g.idToken);
return g;
}
// A whole sign-in: start, a code at the OP, the callback. Returns the callback answer and the
// session cookie (name=value) when one was set.
async function signIn(env, { iss = env.iss[0], ret = null, opts = {} } = {}) {
const begun = await begin(env, { provider: env.iss.length > 1 ? iss.name : null, ret });
const g = grant(env, begun, iss, opts);
const r = await callback(env, begun, { code: g.code });
const session = sessionOf(r);
if (session) PRODUCED.add(session.split('=')[1]);
return { begun, g, r, session };
}
function refused(env, r, code) {
assert.equal(r.status, 403, `expected 403, got ${r.status}`);
assert.equal(sessionOf(r), null, 'a session cookie was set');
assert.equal(env.sends.length, 0, 'the send stub ran');
assert.ok(setCookies(r).includes(`${LOGIN_COOKIE}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0`), 'the login cookie was not expired');
if (code) assert.match(env.lines.at(-1), new RegExp(` sso refused code=${code} provider=`), env.lines.at(-1));
}
test('positive: an RS256 provider signs in and sends one message, and the session reads', async t => {
const env = await setup(t);
assert.notEqual(env.port, 7332);
const { r, session } = await signIn(env, { ret: '/board?x=1' });
assert.equal(r.status, 303);
assert.equal(r.headers.location, `${ORIGIN}/board?x=1`);
assert.ok(session);
const send = await call(env.port, { method: 'POST', path: '/api/bus/message', headers: writeHeaders(session), body: MSG });
assert.equal(send.status, 200, JSON.stringify(send.body));
assert.deepEqual(env.sends, [{ to: 'coder', body: 'Please look at row 192.' }]);
assert.match(env.lines.at(-1), / send user="person-a" to="coder" id=msg-0001$/);
const read = await call(env.port, { path: '/api/bus/inbox', headers: { cookie: session } });
assert.equal(read.status, 200);
const config = await call(env.port, { path: '/api/config', headers: { cookie: session } });
assert.equal(config.body.sso, true);
assert.equal(config.body.send, true);
});
test('positive: an ES256 provider signs in and sends', async t => {
const env = await setup(t, { issuers: [{ name: 'e', keys: [ecKey()] }] });
assert.deepEqual(env.config.providers[0].algs, ['ES256']);
const { session } = await signIn(env);
assert.ok(session);
assert.equal((await call(env.port, { method: 'POST', path: '/api/bus/message', headers: writeHeaders(session), body: MSG })).status, 200);
assert.equal(env.sends.length, 1);
});
test('positive: two providers each sign in their own subjects, and one provider\'s subject is unknown to the other', async t => {
const env = await setup(t, { issuers: [{ name: 'a' }, { name: 'b', keys: [ecKey()] }] });
const list = await call(env.port, { path: '/auth/login?return=%2Fboard' });
assert.equal(list.status, 200);
assert.match(list.body, /provider=a&#38;return=%2Fboard/);
assert.match(list.body, /provider=b&#38;return=%2Fboard/);
const [a, b] = env.iss;
assert.ok((await signIn(env, { iss: a })).session);
assert.ok((await signIn(env, { iss: b })).session);
const names = [...env.sso.sessions.values()].map(s => s.name);
assert.deepEqual(names, ['person-a', 'person-b']);
const cross = await signIn(env, { iss: b, opts: { sub: 'user-a' } });
refused({ ...env, sends: [] }, cross.r, 'subject-unknown');
});
test('1: a forged x-authentik header without a cookie is 401 on the API and a sign-in redirect on a page', async t => {
const env = await setup(t);
const forged = { 'x-authentik-username': 'jason.woltje', origin: ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
for (const [method, path, body] of [['POST', '/api/bus/message', MSG], ['POST', '/api/settings', '{}'], ['GET', '/api/config', undefined]]) {
const r = await call(env.port, { method, path, headers: forged, body });
assert.equal(r.status, 401, `${method} ${path}`);
assert.deepEqual(r.body, { error: 'sign-in required' });
}
const page = await call(env.port, { path: '/', headers: forged });
assert.equal(page.status, 303);
assert.equal(page.headers.location, '/auth/login?return=%2F');
const deep = await call(env.port, { path: '/board?x=1' });
assert.equal(deep.headers.location, '/auth/login?return=%2Fboard%3Fx%3D1');
assert.equal(env.sends.length, 0);
});
test('2: an HS256 ID token keyed with the client secret and a real RSA kid is refused', async t => {
const env = await setup(t);
const [iss] = env.iss;
const { r } = await signIn(env, { opts: { header: { alg: 'HS256', kid: iss.keys[0].kid } } });
refused(env, r, 'id-token-invalid');
});
test('3: a wrong aud, and an aud list holding another client with no azp, are refused', async t => {
const env = await setup(t);
refused(env, (await signIn(env, { opts: { claims: { aud: 'another-client' } } })).r, 'id-token-invalid');
refused(env, (await signIn(env, { opts: { claims: { aud: [env.iss[0].clientId, 'another-client'] } } })).r, 'id-token-invalid');
});
test('4: a token that expired 10 minutes ago is refused', async t => {
const env = await setup(t);
const now = Math.floor(Date.now() / 1000);
refused(env, (await signIn(env, { opts: { claims: { iat: now - 900, exp: now - 600 } } })).r, 'id-token-invalid');
});
test('5: another issuer, and the issuer without its trailing slash, are refused', async t => {
const env = await setup(t);
const [iss] = env.iss;
refused(env, (await signIn(env, { opts: { claims: { iss: `${env.op.base}/other/` } } })).r, 'id-token-invalid');
refused(env, (await signIn(env, { opts: { claims: { iss: iss.issuer.slice(0, -1) } } })).r, 'id-token-invalid');
});
test('6: a nonce mismatch and a missing nonce are refused', async t => {
const env = await setup(t);
refused(env, (await signIn(env, { opts: { claims: { nonce: 'not-the-nonce' } } })).r, 'id-token-invalid');
refused(env, (await signIn(env, { opts: { claims: { nonce: undefined } } })).r, 'id-token-invalid');
});
test('7: state mismatch, no login cookie, a replay, an expired record and two login cookies are refused', async t => {
const env = await setup(t);
const [iss] = env.iss;
let begun = await begin(env);
refused(env, await callback(env, begun, { code: grant(env, begun, iss).code, state: 'wrong-state' }), 'state-mismatch');
begun = await begin(env);
refused(env, await callback(env, begun, { code: grant(env, begun, iss).code, cookie: null }), 'login-record-missing');
// Replay: the OP takes the code twice, so only the take-and-delete rule refuses the second callback.
iss.acceptTwice = true;
begun = await begin(env);
const g = grant(env, begun, iss);
const first = await callback(env, begun, { code: g.code });
assert.equal(first.status, 303);
PRODUCED.add(sessionOf(first).split('=')[1]);
refused(env, await callback(env, begun, { code: g.code }), 'login-record-missing');
iss.acceptTwice = false;
begun = await begin(env);
for (const record of env.sso.logins.values()) record.expires = Date.now() - 1;
refused(env, await callback(env, begun, { code: grant(env, begun, iss).code }), 'login-record-missing');
begun = await begin(env);
const other = await begin(env);
refused(env, await callback(env, begun, { code: grant(env, begun, iss).code, cookie: `${begun.cookie}; ${other.cookie}` }), 'login-record-missing');
});
test('8: the token request carries the PKCE verifier, and the OP refuses a wrong one', async t => {
const env = await setup(t);
const [iss] = env.iss;
const { r, begun } = await signIn(env);
assert.equal(r.status, 303);
const { form } = iss.tokenRequests.at(-1);
assert.equal(begun.params.get('code_challenge_method'), 'S256');
const { createHash } = await import('node:crypto');
assert.equal(createHash('sha256').update(form.code_verifier).digest('base64url'), begun.params.get('code_challenge'));
refused(env, (await signIn(env, { opts: { challenge: 'not-the-challenge' } })).r, 'grant-failed');
});
test('9: alg none, a changed payload under a real kid, and an unknown kid even after the refetch are refused', async t => {
const env = await setup(t);
const [iss] = env.iss;
// (a) alg none: the alg rule, before any signature check.
refused(env, (await signIn(env, { opts: { header: { alg: 'none' } } })).r, 'id-token-invalid');
// (b) a real kid's signature over another payload: only the signature check catches it.
let begun = await begin(env);
const real = env.op.grant(iss, begun.location);
PRODUCED.add(real.idToken);
const [h, , s] = real.idToken.split('.');
const forged = `${h}.${b64url({ ...real.payload, name: 'changed' })}.${s}`;
refused(env, await callback(env, begun, { code: grant(env, begun, iss, { idToken: forged }).code }), 'signature-invalid');
// (c) a key the JWKS never held, again after the cache is old enough to refetch.
t.mock.timers.enable({ apis: ['Date'], now: Date.now() });
const stranger = rsaKey('not-in-jwks');
refused(env, (await signIn(env, { opts: { key: stranger } })).r, 'signature-invalid');
const fetches = iss.jwksFetches;
t.mock.timers.tick(61000);
refused(env, (await signIn(env, { opts: { key: stranger } })).r, 'signature-invalid');
assert.equal(iss.jwksFetches, fetches + 1, 'no refetch');
});
test('10: a valid token for a subject not configured is 403 with one subject-unknown line', async t => {
const env = await setup(t);
const before = env.lines.length;
const { r } = await signIn(env, { opts: { sub: 'stranger' } });
refused(env, r, 'subject-unknown');
const added = env.lines.slice(before);
assert.equal(added.length, 1);
assert.match(added[0], new RegExp(`^\\S+ sso refused code=subject-unknown provider=a iss=${JSON.stringify(env.iss[0].issuer).replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')} sub="stranger"$`));
});
test('11: start refusals: HS or none algs, reserved names, HS-only discovery, an empty JWKS, no S256, an issuer mismatch, a short RSA key', async t => {
const op = await fakeOidc();
t.after(() => op.close());
const start = (iss, extra = {}) => createSso({ publicOrigin: ORIGIN, providers: [providerFor(iss, extra)] }, { secrets: new Map([[iss.name, iss.clientSecret]]), fetch: op.fetch, log: l => ALL_LINES.push(l) });
const a = op.issuer('a');
PRODUCED.add(a.clientSecret);
for (const [extra, why] of [
[{ algs: ['HS256'] }, /algs\[0\] must be one of .*HS\* and none are refused/],
[{ algs: ['none'] }, /algs\[0\] must be one of/],
[{ subjects: [{ sub: 'u', name: 'tester' }] }, /subjects\[0\]\.name must not be tester or human/],
[{ subjects: [{ sub: 'u', name: 'Human' }] }, /subjects\[0\]\.name must not be tester or human/],
]) await assert.rejects(start(a, extra), why);
const refusedAtDiscovery = /console\.sso provider \w+ refused at discovery/;
// The mosaic-preview shape: HS256 only.
const hs = op.issuer('hs', { discovery: { id_token_signing_alg_values_supported: ['HS256'] } });
await assert.rejects(start(hs, { algs: ['RS256'] }), refusedAtDiscovery);
const empty = op.issuer('empty');
empty.jwksOverride = { keys: [] };
await assert.rejects(start(empty), refusedAtDiscovery);
const plain = op.issuer('plain', { discovery: { code_challenge_methods_supported: ['plain'] } });
await assert.rejects(start(plain), refusedAtDiscovery);
const moved = op.issuer('moved');
moved.discovery = { issuer: `${op.base}/elsewhere/` };
await assert.rejects(start(moved), refusedAtDiscovery);
const small = op.issuer('small', { keys: [rsaKey('small', 1024)] });
await assert.rejects(start(small), refusedAtDiscovery);
// The control: the same OP and provider start.
const sso = await start(a);
assert.deepEqual(sso.health(), { a: 'ready' });
sso.close();
await assert.rejects(startServer({ port: 0, sso, preview: { host: HOST, header: 'remote-user', user: 'jason' } }), /--sso cannot be combined/);
});
test('11: slot files: a symlink, mode 0644, mode 0640 and a file under the repository are refused, and no message holds the value', t => {
const root = mkdtempSync(join(tmpdir(), 'webui-sso-slot-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const dir = join(root, 'config'), slots = join(dir, 'secrets', 'mosaic-stack');
mkdirSync(slots, { recursive: true });
const provider = slot => ({ id: 'a', clientSecret: { slot } });
const value = 'slot-value-do-not-print';
const opts = { repo: join(root, 'repo'), copies: join(root, 'copies') };
writeFileSync(join(slots, 'good'), `${value}\n`, { mode: 0o600 });
assert.equal(readSlot(dir, provider('good'), opts), value);
writeFileSync(join(root, 'target'), value, { mode: 0o600 });
symlinkSync(join(root, 'target'), join(slots, 'link'));
for (const [slot, mode] of [['wide', 0o644], ['group', 0o640]]) { writeFileSync(join(slots, slot), value); chmodSync(join(slots, slot), mode); }
writeFileSync(join(slots, 'two'), `${value}\nmore\n`, { mode: 0o600 });
writeFileSync(join(slots, 'empty'), '\n', { mode: 0o600 });
const cases = [
['missing', /slot is missing$/, opts],
['link', /must not be a symbolic link$/, opts],
['wide', /must have no group or other permission bits$/, opts],
['group', /must have no group or other permission bits$/, opts],
['two', /must hold one non-empty line$/, opts],
['empty', /must hold one non-empty line$/, opts],
// The config dir itself under the repository, and under a --checkout.
['good', /must not be inside the repository or a stack copy$/, { ...opts, repo: root }],
['good', /must not be inside the repository or a stack copy$/, { ...opts, checkout: dir }],
['good', /must not be inside the repository or a stack copy$/, { ...opts, copies: dir }],
];
for (const [slot, why, o] of cases) {
let err = null;
try { readSlot(dir, provider(slot), o); } catch (e) { err = e; }
assert.ok(err, `${slot} was read`);
assert.match(err.message, /^console\.sso provider a client secret slot /);
assert.match(err.message, why, slot);
assert.equal(err.message.includes(value), false);
assert.equal(err.message.includes(root), false);
}
});
test('11: the CLI refuses --sso with the old flags and without console.sso, and starts with a good one', async t => {
const root = mkdtempSync(join(tmpdir(), 'wsso-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const env = { ...process.env, MOSAIC_CONFIG: join(root, 'cfg', 'config.json'), XDG_DATA_HOME: join(root, 'xdg') };
for (const flags of [['--auth-header', 'remote-user'], ['--public-host', HOST], ['--allow-user', 'jason'], ['--proof-header', 'x-proof']]) {
const r = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--sso', ...flags], { encoding: 'utf8', timeout: 10000, env });
assert.equal(r.status, 2, flags.join(' '));
assert.match(r.stderr, /^refused: --sso cannot be combined with --public-host, --auth-header, --allow-user or --proof-\*$/m);
}
const missing = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--sso'], { encoding: 'utf8', timeout: 10000, env });
assert.equal(missing.status, 2);
assert.match(missing.stderr, /^refused: --sso needs the system config \(/);
const op = await fakeOidc();
t.after(() => op.close());
const a = op.issuer('a');
PRODUCED.add(a.clientSecret);
mkdirSync(join(root, 'cfg', 'secrets', 'mosaic-stack'), { recursive: true });
writeFileSync(join(root, 'ca.pem'), op.cert);
const base = { configVersion: 1, environment: 'development', dataRoot: join(root, 'data'), execution: { backend: 'docker', provider: 'zai', model: 'm' } };
writeFileSync(env.MOSAIC_CONFIG, JSON.stringify(base));
const noKey = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--sso'], { encoding: 'utf8', timeout: 10000, env });
assert.equal(noKey.status, 2);
assert.match(noKey.stderr, /^refused: --sso needs console\.sso in the system config$/m);
writeFileSync(env.MOSAIC_CONFIG, JSON.stringify({ ...base, console: { sso: { publicOrigin: ORIGIN, providers: [providerFor(a)] } } }));
writeFileSync(join(root, 'cfg', 'secrets', 'mosaic-stack', 'a'), `${a.clientSecret}\n`, { mode: 0o644 });
const wide = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--sso'], { encoding: 'utf8', timeout: 10000, env });
assert.equal(wide.status, 2);
assert.match(wide.stderr, /^refused: console\.sso provider a client secret slot must have no group or other permission bits$/m);
chmodSync(join(root, 'cfg', 'secrets', 'mosaic-stack', 'a'), 0o600);
// The child trusts the fake OP's certificate through NODE_EXTRA_CA_CERTS; the code under test has no flag for it.
const child = spawn(process.execPath, [CLI, 'serve', '--port', '0', '--sso'], { env: { ...env, NODE_EXTRA_CA_CERTS: join(root, 'ca.pem') } });
t.after(() => child.kill('SIGKILL'));
let out = '', err = '';
child.stderr.on('data', c => (err += c));
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`no start line: ${out} ${err}`)), 15000);
child.stdout.on('data', c => { out += c; if (out.includes('Ctrl-C')) { clearTimeout(timer); resolve(); } });
child.on('exit', () => { clearTimeout(timer); reject(new Error(`exited: ${err}`)); });
});
assert.match(out, new RegExp(`^Sign-in: ${ORIGIN}/ \\(--sso\\), read-only$`, 'm'));
assert.match(out, new RegExp(`^Sign-in provider: a issuer ${a.issuer.replace(/[.]/g, '\\.')} subjects 1 \\(ready\\)$`, 'm'));
assert.equal(out.includes(a.clientSecret) || err.includes(a.clientSecret), false);
assert.equal(out.includes(a.clientId), false);
const port = Number(out.match(/^Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\//m)[1]);
const health = await call(port, { path: '/healthz', host: `127.0.0.1:${port}` });
assert.deepEqual(health.body, { ok: true, sso: { a: 'ready' } });
child.kill('SIGTERM');
});
test('12: under --sso the tester listener refuses each kind of sign-in, has no /auth, and startServer refuses tester with sso', async t => {
const env = await setup(t);
const tester = await startServer({ port: 0, board: 'http://127.0.0.1:9', bus: env.bus, tester: true, testerNoSignIn: true, log: () => {} });
t.after(() => close(tester));
const port = tester.address().port, host = `127.0.0.1:${port}`;
const { session, g, begun } = await signIn(env);
assert.ok(session);
const plain = await call(port, { path: '/api/config', host });
assert.equal(plain.status, 200, 'control: a request with no sign-in is served');
for (const headers of [
{ cookie: session },
{ cookie: `theme=dark; ${begun.cookie}` },
{ cookie: `${SESSION_COOKIE.toLowerCase()}=x` },
{ authorization: `Bearer ${g.idToken}` },
{ 'x-authentik-jwt': g.idToken },
{ 'X-Authentik-Username': 'jason.woltje' },
]) {
const r = await call(port, { path: '/api/config', host, headers });
assert.equal(r.status, 403, JSON.stringify(Object.keys(headers)));
assert.deepEqual(r.body, { error: 'tester listener takes no sign-in' });
}
assert.equal((await call(port, { path: '/auth/login', host })).status, 404);
await assert.rejects(startServer({ port: 0, tester: true, sso: env.sso }), /tester listener cannot use sso/);
});
test('13: idle and absolute expiry, logout then reuse, a cross-origin logout, and background reads that don\'t keep a session', async t => {
const env = await setup(t);
const read = cookie => call(env.port, { path: '/api/bus/inbox', headers: { cookie } });
const record = () => [...env.sso.sessions.values()].at(-1);
// Background reads don't move `seen`; a page load does.
let { session } = await signIn(env);
const old = Date.now() - 59 * 60 * 1000;
record().seen = old;
assert.equal((await read(session)).status, 200);
assert.equal(record().seen, old, 'a background read refreshed the session');
assert.equal((await call(env.port, { path: '/', headers: { cookie: session } })).status, 200);
assert.ok(record().seen > old, 'a page load did not refresh the session');
// Idle: 59 minutes of background reads, then one more past the hour.
record().seen = Date.now() - 61 * 60 * 1000;
assert.equal((await read(session)).status, 401);
// Absolute.
({ session } = await signIn(env));
record().created = Date.now() - 8 * 3600 * 1000;
assert.equal((await call(env.port, { path: '/', headers: { cookie: session } })).headers.location, '/auth/login?return=%2F');
// Logout needs same-origin; refused, the session stays.
({ session } = await signIn(env));
const cross = await call(env.port, { method: 'POST', path: '/auth/logout', headers: { cookie: session, origin: ORIGIN, 'sec-fetch-site': 'cross-site' } });
assert.equal(cross.status, 403);
assert.equal((await read(session)).status, 200);
const noOrigin = await call(env.port, { method: 'POST', path: '/auth/logout', headers: { cookie: session, 'sec-fetch-site': 'same-origin' } });
assert.equal(noOrigin.status, 403);
assert.equal((await read(session)).status, 200);
const out = await call(env.port, { method: 'POST', path: '/auth/logout', headers: { cookie: session, origin: ORIGIN, 'sec-fetch-site': 'same-origin' } });
assert.equal(out.status, 303);
const end = new URL(out.headers.location);
assert.equal(`${end.origin}${end.pathname}`, `${env.iss[0].issuer}end-session`);
assert.equal(end.searchParams.get('post_logout_redirect_uri'), `${ORIGIN}/auth/signed-out`);
assert.equal(end.searchParams.get('client_id'), env.iss[0].clientId);
assert.ok(end.searchParams.get('id_token_hint'));
assert.deepEqual(setCookies(out), [`${SESSION_COOKIE}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0`]);
assert.equal((await read(session)).status, 401);
assert.equal((await call(env.port, { method: 'GET', path: '/auth/logout', headers: { cookie: session } })).status, 405);
});
test('13: the CSP lets the Sign out form reach the provider\'s end-session origin, and only under --sso', async t => {
const env = await setup(t);
const r = await call(env.port, { path: '/healthz', host: `127.0.0.1:${env.port}` });
assert.match(r.headers['content-security-policy'], new RegExp(`; form-action 'self' ${env.op.base.replace(/[.]/g, '\\.')}$`));
const plain = await startServer({ port: 0, board: 'http://127.0.0.1:9' });
t.after(() => close(plain));
const p = await call(plain.address().port, { path: '/healthz', host: `127.0.0.1:${plain.address().port}` });
assert.match(p.headers['content-security-policy'], /; form-action 'self'$/);
});
test('14: open redirects land on /, and a path on the Console lands there', async t => {
const env = await setup(t);
for (const [query, where] of [
['return=%2F%2Fevil.example', `${ORIGIN}/`],
['return=https%3A%2F%2Fevil.example', `${ORIGIN}/`],
['return=%2F%5Cevil.example', `${ORIGIN}/`],
['return=/%09/evil.example', `${ORIGIN}/`],
// Dot segments that leave a pathname of //evil.example stay on the Console (webby-0019 B1).
['return=%2F.%2F%2Fevil.example', `${ORIGIN}//evil.example`],
['return=%2Fx%2F..%2F%2Fevil.example', `${ORIGIN}//evil.example`],
[`return=${encodeURIComponent(`${ORIGIN}//evil.example`)}`, `${ORIGIN}//evil.example`],
['return=%2Fboard%2F.%2Fx', `${ORIGIN}/board/x`],
['return=%2Fboard%3Fx%3D1', `${ORIGIN}/board?x=1`],
['', `${ORIGIN}/`],
]) {
const begun = await begin(env, { query });
const r = await callback(env, begun, { code: grant(env, begun, env.iss[0]).code });
assert.equal(r.status, 303);
assert.equal(r.headers.location, where, query);
assert.equal(new URL(r.headers.location).origin, ORIGIN, query);
PRODUCED.add(sessionOf(r).split('=')[1]);
}
});
test('15: a callback for A carrying B\'s code and iss, and a provider= query, is refused', async t => {
const env = await setup(t, { issuers: [{ name: 'a' }, { name: 'b' }] });
const [, b] = env.iss;
for (const extra of [{ iss: b.issuer, provider: 'b' }, { provider: 'b' }]) {
const begun = await begin(env, { provider: 'a' });
// B's code against A's PKCE challenge and nonce, for B's own subject: only the record rule
// (this callback belongs to A) stands in the way.
const g = grant(env, begun, b, { sub: 'user-b' });
const r = await callback(env, begun, { code: g.code, extra });
refused(env, r);
}
assert.equal(b.tokenRequests.length, 0, 'B\'s token endpoint was called');
});
test('16: a provider with RS256 and ES256 takes either, and refuses PS256 with a usable PS256 key in the JWKS', async t => {
const rsa = rsaKey(), ec = ecKey(), ps = psKey();
const env = await setup(t, { issuers: [{ name: 'a', keys: [rsa, ec, ps] }], provider: { a: { algs: ['RS256', 'ES256'] } } });
assert.ok((await signIn(env, { opts: { key: rsa } })).session);
assert.ok((await signIn(env, { opts: { key: ec } })).session);
refused(env, (await signIn(env, { opts: { key: ps } })).r, 'id-token-invalid');
});
test('17: 33 started logins leave 32 records, and the first one\'s callback finds none', async t => {
const env = await setup(t);
const first = await begin(env);
for (let i = 0; i < 32; i++) await begin(env);
assert.equal(env.sso.logins.size, 32);
refused(env, await callback(env, first, { code: grant(env, first, env.iss[0]).code }), 'login-record-missing');
});
test('18: after a key rotation a sign-in works through one refetch, and the removed kid is refused', async t => {
const env = await setup(t);
const [iss] = env.iss;
t.mock.timers.enable({ apis: ['Date'], now: Date.now() });
const old = iss.keys[0];
assert.ok((await signIn(env)).session);
const fetches = iss.jwksFetches;
env.op.rotate(iss);
t.mock.timers.tick(61000);
assert.ok((await signIn(env)).session);
assert.equal(iss.jwksFetches, fetches + 1);
refused(env, (await signIn(env, { opts: { key: old } })).r, 'signature-invalid');
});
test('20 and 21: the store holds no raw session id, and the cookie strings are exact', async t => {
const env = await setup(t);
const begun = await begin(env);
assert.match(setCookies(begun.r)[0], new RegExp(`^${LOGIN_COOKIE}=[A-Za-z0-9_-]{43}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=600$`));
for (const key of env.sso.logins.keys()) assert.match(key, /^[0-9a-f]{64}$/);
const r = await callback(env, begun, { code: grant(env, begun, env.iss[0]).code });
const set = setCookies(r);
assert.equal(set.length, 2);
assert.equal(set[0], `${LOGIN_COOKIE}=; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=0`);
assert.match(set[1], new RegExp(`^${SESSION_COOKIE}=[A-Za-z0-9_-]{43}; Path=/; Secure; HttpOnly; SameSite=Lax$`));
const id = set[1].split(';')[0].split('=')[1];
PRODUCED.add(id);
for (const [key, value] of env.sso.sessions) {
assert.match(key, /^[0-9a-f]{64}$/);
assert.notEqual(key, id);
assert.equal(JSON.stringify(value).includes(id), false);
}
});
test('22: a loopback Host is 403 on a page, a read and a write; /healthz on loopback is 200; a redirect_uri never comes from the Host', async t => {
const env = await setup(t);
const { session } = await signIn(env);
const loop = `127.0.0.1:${env.port}`;
for (const [method, path, body] of [['GET', '/', undefined], ['GET', '/api/board', undefined], ['GET', '/api/bus/inbox', undefined], ['POST', '/api/bus/message', MSG]]) {
// A loopback Host on this listener's port; any other Host is already the older non-local refusal.
for (const host of [loop, `localhost:${env.port}`]) {
const r = await call(env.port, { method, path, host, body, headers: { cookie: session, 'content-type': 'application/json' } });
assert.equal(r.status, 403, `${method} ${path} ${host}`);
assert.deepEqual(r.body, { error: 'public Host required' });
}
}
const health = await call(env.port, { path: '/healthz', host: loop });
assert.equal(health.status, 200);
assert.deepEqual(health.body, { ok: true, sso: { a: 'ready' } });
assert.equal(env.sends.length, 0);
const begun = await begin(env, { host: HOST.toUpperCase() });
assert.equal(begun.params.get('redirect_uri'), `${ORIGIN}/auth/callback`);
});
test('23: a provider down at start leaves the Console up and 503, and the retry makes sign-in work', async t => {
let env = await setup(t, { retryFirst: 50, before: (op, [a]) => { a.mode = 'down'; } });
assert.equal((await call(env.port, { path: '/auth/login' })).status, 503);
assert.deepEqual((await call(env.port, { path: '/healthz', host: `127.0.0.1:${env.port}` })).body.sso, { a: 'unreachable' });
assert.match(env.lines.at(-1), / sso refused code=discovery-failed provider=a$/);
env.iss[0].mode = 'ok';
for (let i = 0; i < 100 && env.sso.health().a !== 'ready'; i++) await new Promise(r => setTimeout(r, 20));
assert.deepEqual(env.sso.health(), { a: 'ready' });
assert.ok((await signIn(env)).session);
// A 200 HTML page at the discovery URL is unreachable too, and the Console starts.
env = await setup(t, { before: (op, [a]) => { a.mode = 'html'; } });
assert.deepEqual(env.sso.health(), { a: 'unreachable' });
// HS-only discovery at start refuses the start.
await assert.rejects(setup(t, { before: (op, [a]) => { a.discovery = { id_token_signing_alg_values_supported: ['HS256'] }; } }), /refused at discovery/);
});
test('19: no captured log line holds a token, code, state, nonce, verifier, client secret or session id', () => {
assert.ok(ALL_LINES.length > 20, `only ${ALL_LINES.length} lines`);
assert.ok(PRODUCED.size > 50, `only ${PRODUCED.size} values`);
for (const line of ALL_LINES) {
for (const value of PRODUCED) if (value) assert.equal(line.includes(value), false, `a log line holds a produced value: ${line.slice(0, 80)}`);
assert.match(line, /^\S+ (sso refused code=[a-z-]+ provider=[a-z0-9-]+( iss="[^"]*" sub="[^"]*")?|send user="[^"]+" to="[^"]*" (id=msg-0001|error=[a-z -]+))$/, line);
}
});
+9 -32
View File
@@ -26,7 +26,6 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { pathToFileURL } from "node:url";
const SUPPORTED_CONFIG_VERSION = 1;
const SUPPORTED_ENVIRONMENTS = new Set(["development", "production"]);
@@ -107,30 +106,13 @@ function isAncestorOf(ancestor, candidate) {
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
}
// Q175 phase 1 (#1690): the optional `console` key, checked for shape only by the Console's own
// checker. It is imported only when the key is there, so a config without it never needs the
// Console's code, and a copy of this file alone still reads one.
async function validateConsole(value) {
let checkConsole;
try {
({ checkConsole } = await import(pathToFileURL(path.join(import.meta.dirname, "..", "packages", "webui", "src", "sso-config.mjs")).href));
} catch {
fail(2, 'the "console" key needs packages/webui/src/sso-config.mjs beside this loader');
}
try {
return checkConsole(value);
} catch (error) {
fail(2, error.message);
}
}
async function validate(document, file) {
function validate(document, file) {
if (!isPlainObject(document)) {
fail(2, "configuration must be a JSON object");
}
rejectUnknownKeys(
document,
["configVersion", "environment", "dataRoot", "execution", "console"],
["configVersion", "environment", "dataRoot", "execution"],
"configuration",
);
@@ -164,10 +146,6 @@ async function validate(document, file) {
fail(2, `unsupported execution.adapter: ${JSON.stringify(adapter)} (supported: ${[...SUPPORTED_ADAPTERS].join(", ")})`);
}
// Last, so the older checks keep their order. The key is in the output only when the file has
// it, so a config without it prints what it always printed.
const consoleKey = Object.hasOwn(document, "console") ? await validateConsole(document.console) : undefined;
return {
configVersion: document.configVersion,
environment: document.environment,
@@ -178,11 +156,10 @@ async function validate(document, file) {
model: document.execution.model,
adapter,
},
...(consoleKey === undefined ? {} : { console: consoleKey }),
};
}
async function load(file) {
function load(file) {
const raw = readRaw(file);
if (raw === null) {
fail(3, `configuration not found: ${file} (run scripts/bootstrap.sh to create it)`);
@@ -193,7 +170,7 @@ async function load(file) {
} catch (error) {
fail(2, `configuration is not valid JSON (${file}): ${error.message}`);
}
return await validate(document, file);
return validate(document, file);
}
function shellQuote(value) {
@@ -217,7 +194,7 @@ const file = configPath();
switch (operation) {
case "bootstrap": {
if (fs.existsSync(file)) {
await load(file); // validate only; never rewrite
load(file); // validate only; never rewrite
process.stderr.write(`mosaic-config: configuration already present, validated without changes: ${file}\n`);
process.exit(0);
}
@@ -229,26 +206,26 @@ switch (operation) {
fs.writeFileSync(fd, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`);
} catch (error) {
if (error.code === "EEXIST") {
await load(file);
load(file);
process.exit(0);
}
fail(1, `unable to create configuration: ${error.message}`);
} finally {
if (fd !== undefined) fs.closeSync(fd);
}
await load(file);
load(file);
process.stderr.write(`mosaic-config: created default configuration: ${file}\n`);
process.exit(0);
}
case "validate": {
const resolved = await load(file);
const resolved = load(file);
process.stdout.write(`${JSON.stringify(resolved, null, 2)}\n`);
process.exit(0);
}
case "env": {
const resolved = await load(file);
const resolved = load(file);
process.stdout.write(
[
`MOSAIC_DATA_ROOT=${shellQuote(resolved.dataRoot)}`,
-46
View File
@@ -151,52 +151,6 @@ else
FAIL=$((FAIL+1)); echo "${C_FAIL}FAIL${C_RESET} env exports resolve correctly"
fi
# --- Q175 phase 1 (#1690), design test 25: the optional console key ---
# Shape only. The slot path is a FIFO, so a loader that opened the slot would block and the
# timeout would fail the case; no case reads a slot file.
mkdir -p "$SANDBOX/secrets/mosaic-stack"
mkfifo "$SANDBOX/secrets/mosaic-stack/console-sso-test"
sso_body() {
printf '{"configVersion":1,"environment":"development","dataRoot":"%s","execution":{"backend":"docker","provider":"zai","model":"m"},"console":%s}' "$DATA_ROOT" "$1"
}
# provider [ALGS] [CLIENT_SECRET]: one provider entry; an empty argument takes the valid value.
provider() {
local algs='["RS256"]' secret='{"slot":"console-sso-test"}'
[ -n "${1:-}" ] && algs="$1"
[ -n "${2:-}" ] && secret="$2"
printf '{"id":"fake","issuer":"https://op.test/app/","clientId":"client-1","clientSecret":%s,"algs":%s,"subjects":[{"sub":"user-1","name":"jason.woltje"}]}' "$secret" "$algs"
}
cfg sso-valid.json "$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider)"']}}')"
OUT="$(MOSAIC_CONFIG="$SANDBOX/sso-valid.json" timeout 20 $CONFIG_OP validate 2>&1)"
[ $? -eq 0 ] && printf '%s' "$OUT" | grep -q '"publicOrigin": "https://console.test"'
check "valid console.sso loads and is in the output" $?
# expect_refusal NAME PATH-IN-MESSAGE BODY: exit 2, and the message names the bad key path.
expect_refusal() {
cfg sso-bad.json "$2"
local err rc
err="$(MOSAIC_CONFIG="$SANDBOX/sso-bad.json" timeout 20 $CONFIG_OP validate 2>&1 >/dev/null)"
rc=$?
[ "$rc" -eq 2 ] && printf '%s' "$err" | grep -qF -- "$3"
check "$1 (exit $rc)" $?
}
expect_refusal "console.sso HS256 alg exits 2 naming the path" \
"$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider '["HS256"]')"']}}')" 'console.sso.providers[0].algs[0] must be one of'
expect_refusal "console.sso alg none exits 2 naming the path" \
"$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider '["RS256","none"]')"']}}')" 'console.sso.providers[0].algs[1] must be one of'
expect_refusal "console.sso missing slot field exits 2 naming the path" \
"$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider '' '{}')"']}}')" 'console.sso.providers[0].clientSecret.slot is required'
expect_refusal "console.sso slot name outside the pattern exits 2 naming the path" \
"$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider '' '{"slot":"../x"}')"']}}')" 'console.sso.providers[0].clientSecret.slot must match'
expect_refusal "unknown key under console exits 2 naming the path" \
"$(sso_body '{"sso":{"publicOrigin":"https://console.test","providers":['"$(provider)"']},"extra":1}')" 'console.extra is not a supported key'
# Without the key: the output is exactly what the loader printed before phase 1 (sage-1340, F2).
cfg no-console.json '{"configVersion":1,"environment":"development","dataRoot":"'$DATA_ROOT'","execution":{"backend":"docker","provider":"zai","model":"m"}}'
EXPECTED="$(printf '{\n "configVersion": 1,\n "environment": "development",\n "dataRoot": "%s",\n "execution": {\n "backend": "docker",\n "provider": "zai",\n "model": "m",\n "adapter": "pi"\n }\n}' "$DATA_ROOT")"
[ "$(MOSAIC_CONFIG="$SANDBOX/no-console.json" timeout 20 $CONFIG_OP validate)" = "$EXPECTED" ]
check "config without console loads with unchanged output" $?
# --- validation must not modify the file ---
SUM_INVALID_BEFORE=$(sha256sum "$SANDBOX/invalid.json" | cut -d' ' -f1)
MOSAIC_CONFIG="$SANDBOX/invalid.json" $CONFIG_OP validate >/dev/null 2>&1
+7 -20
View File
@@ -49,9 +49,6 @@
# character set and refused otherwise. --proof-header NAME --proof-file PATH
# go together under preview; only the path is carried here, never the secret.
# render checks ownership, mode and location without opening the proof file.
# --sso (Q175 phase 1, #1690): the Console signs people in itself from
# console.sso in config.json. It refuses the preview set and the proof pair,
# and --send takes it in place of the preview set. Nothing secret is carried.
#
# There is no start, stop, enable or restart verb. The Console reads the
# main listener's bus through the human transport; the optional loopback
@@ -71,7 +68,7 @@ REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
TEMPLATE="$REPO/packages/webui/systemd/mosaic-webui.service.in"
UNIT_NAME="mosaic-webui.service"
USAGE="usage: scripts/webui-service.sh copy COMMIT [--copies DIR] [--allow-worktree] | render [--copy ID [--copies DIR]] [FLAGS] | install --copy ID [--copies DIR] [--dir DIR] [--no-reload] [--allow-worktree] [FLAGS] | uninstall [--dir DIR] [--no-reload] | status [--dir DIR]
FLAGS: [--port N] [--business ID] [--board URL] [--sso [--send] | --public-host NAME --auth-header HEADER --allow-user ID [--send]] [--tester-port N] [--proof-header NAME --proof-file PATH]
FLAGS: [--port N] [--business ID] [--board URL] [--public-host NAME --auth-header HEADER --allow-user ID [--send]] [--tester-port N] [--proof-header NAME --proof-file PATH]
--tester-port: 1024..65535, different from --port and never 7332; loopback tester listener.
--allow-worktree is for the tests only: copy and install otherwise refuse outside the canonical checkout."
@@ -94,7 +91,6 @@ PUBLIC_HOST=""
AUTH_HEADER=""
ALLOW_USER=""
SEND=0
SSO=0
PROOF_HEADER=""
PROOF_FILE=""
PROOF_SET=0
@@ -118,7 +114,6 @@ parse_flags() {
--proof-header) [ $# -ge 2 ] || usage; PROOF_HEADER="$2"; PROOF_SET=1; SERVE_SET=1; shift 2 ;;
--proof-file) [ $# -ge 2 ] || usage; PROOF_FILE="$2"; PROOF_SET=1; SERVE_SET=1; shift 2 ;;
--send) SEND=1; SERVE_SET=1; shift ;;
--sso) SSO=1; SERVE_SET=1; shift ;;
*) usage ;;
esac
done
@@ -171,13 +166,9 @@ serve_flags() {
[ -z "$PUBLIC_HOST" ] || preview=$((preview + 1))
[ -z "$AUTH_HEADER" ] || preview=$((preview + 1))
[ -z "$ALLOW_USER" ] || preview=$((preview + 1))
# serve refuses --sso with the preview set or the proof pair, and --send
# without --sso or the preview set; refuse them here first, so no unit is
# written that would only exit 2 at start.
if [ "$SSO" = 1 ] && { [ "$preview" -ne 0 ] || [ "$PROOF_SET" = 1 ]; }; then
die "--sso cannot be combined with --public-host, --auth-header, --allow-user or --proof-*"
fi
[ "$SEND" = 0 ] || [ "$preview" -eq 3 ] || [ "$SSO" = 1 ] || die "--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso"
# serve refuses --send without the preview set; refuse it here first,
# so no unit is written that would only exit 2 at start.
[ "$SEND" = 0 ] || [ "$preview" -eq 3 ] || die "--send needs the preview mode (--public-host, --auth-header and --allow-user)"
if [ "$preview" -ne 0 ]; then
[ "$preview" -eq 3 ] || die "--public-host, --auth-header and --allow-user go together: all three or none"
case "$PUBLIC_HOST" in [A-Za-z0-9]*) ;; *) die "public host must be a hostname, no port or scheme: $PUBLIC_HOST" ;; esac
@@ -210,7 +201,6 @@ serve_flags() {
{ [ "$proof_mode" = 600 ] || [ "$proof_mode" = 400 ]; } && [ "$proof_uid" = "$(id -u)" ] || die "invalid proxy proof file metadata"
flags="$flags --proof-header $PROOF_HEADER --proof-file $PROOF_FILE"
fi
[ "$SSO" = 0 ] || flags="$flags --sso"
[ "$SEND" = 0 ] || flags="$flags --send"
[ "$TESTER_SET" = 0 ] || flags="$flags --tester-port $TESTER_PORT"
echo "$flags"
@@ -395,17 +385,14 @@ status_unit() {
else
echo "copy: none; the unit runs from $wd"
fi
# render puts --send after the allowed user and optional proof pair, or
# after --sso, before the tester port. The allowed user
# render puts --send after the allowed user and optional proof pair,
# before the tester port. The allowed user
# starts with a letter or digit (N5), so it is never the value --send.
if grep -Eq '^ExecStart=.*( --allow-user [^ ][^ ]*( --proof-header [^ ]+ --proof-file /[^ ]+)?| --sso) --send( --tester-port [0-9]+)?$' "$target"; then
if grep -Eq '^ExecStart=.* --allow-user [^ ][^ ]*( --proof-header [^ ]+ --proof-file /[^ ]+)? --send( --tester-port [0-9]+)?$' "$target"; then
echo "send: on (--send; human messages to roles)"
else
echo "send: off (read-only)"
fi
if grep -Eq '^ExecStart=.* --sso( --send)?( --tester-port [0-9]+)?$' "$target"; then
echo "sign-in: the Console's own (--sso)"
fi
local tester_port
tester_port="$(sed -n 's/^ExecStart=.* --tester-port \([0-9][0-9]*\)$/\1/p' "$target")"
if [ -n "$tester_port" ]; then