Compare commits

...
Author SHA1 Message Date
jason.woltje 490430e9f2 launchpad: row 192 packet, mutant 23 equivalent and the guard kept (sage-1407) 2026-10-20 15:07:24 -05:00
jason.woltje 37582e8205 webui: Q175 phase 1 round 2, absolute post-sign-in Location checked on publicOrigin (row 192, #1690)
webby-0019 B1, sage-1403: returnURL builds the Location from publicOrigin plus the
parsed pathname and search, checks the origin again and returns the href, so a
dot-segment `//host` path stays on the Console. Test 14 covers Webby's three cases
and asserts each Location's origin; mutant 22 restores the r1 returnPath (killed).
N1 design blob cite, N2 listed under Deviations, N3 SIGN_IN_COOKIE comment.
2026-10-20 14:54:43 -05:00
jason.woltje 83b32d8d86 webui: Q175 phase 1, the Console verifies sign-in itself under --sso (row 192, #1690)
The Console is an OIDC relying party on oauth4webapi 3.8.8 (pinned, root lock).
console.sso in config.json, client secrets from slot files, session cookie,
public Host only on the human listener, tester listener refuses every sign-in
header. Rulings sage-1340 F1/F2/F4 and sage-1347 F7/F8. Tests use a fake OP on
loopback only. Packet: agents/launchpad/work/queue-192/BUILD.md.
2026-10-20 14:30:24 -05:00
22 changed files with 1812 additions and 59 deletions
+178
View File
@@ -0,0 +1,178 @@
# 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
@@ -0,0 +1 @@
53bb59462120a03a9f54b02881895e3f575e501c
@@ -0,0 +1,18 @@
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
@@ -0,0 +1,59 @@
# 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')
+11 -1
View File
@@ -9,7 +9,8 @@
"version": "0.0.3",
"license": "UNLICENSED",
"dependencies": {
"@earendil-works/pi-coding-agent": "0.85.1"
"@earendil-works/pi-coding-agent": "0.85.1",
"oauth4webapi": "3.8.8"
}
},
"node_modules/@anthropic-ai/sdk": {
@@ -1933,6 +1934,15 @@
"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",
+2 -1
View File
@@ -5,6 +5,7 @@
"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"
"@earendil-works/pi-coding-agent": "0.85.1",
"oauth4webapi": "3.8.8"
}
}
+39
View File
@@ -129,6 +129,45 @@ 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
+3 -2
View File
@@ -58,8 +58,9 @@ 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');
if (one(req, preview.header) !== preview.user) return refuse(401, 'unauthenticated');
if (one(req, 'origin') !== `https://${preview.host}`) return refuse(403, 'cross-origin');
// 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, 'sec-fetch-site') !== 'same-origin') return refuse(403, 'cross-site');
}
+27 -8
View File
@@ -7,20 +7,23 @@ 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] [--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] [--sso [--send]] [--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, testerPort = null;
let sendOn = false, ssoOn = 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') {
@@ -51,9 +54,10 @@ 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) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user)');
if (sendOn && !options.preview && !ssoOn) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user) or --sso');
// 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
@@ -77,31 +81,46 @@ 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 });
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 });
// 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; }
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, testerProofHeader: proof?.header ?? null, testerAuthHeader: options.preview?.header ?? null, bus: testerBus, reads,
tester: true, testerNoSignIn: ssoOn, 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) { await new Promise(resolve => server.close(resolve)); throw err; }
} catch (err) { sso?.close(); 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}${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(); });
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(); });
} catch (err) {
console.error(`refused: ${err.message}`);
process.exitCode = 2;
+14 -3
View File
@@ -16,6 +16,11 @@
// 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>` : '';
@@ -110,6 +115,7 @@
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) {
@@ -304,6 +310,7 @@
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;
}
@@ -452,7 +459,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', () => {
document.addEventListener('mosaic-signed-out', e => {
if (out()) return;
document.body.classList.add('signed-out');
clearTimeout(timer); convGen++;
@@ -461,9 +468,13 @@
for (const id of ['projects', 'waiting', 'seen', 'sessions']) $(id).replaceChildren();
$('refresh').disabled = $('pause').disabled = true;
const line = $('signed-out');
line.innerHTML = '<p><b>Signed out.</b> Reload the page to sign in.</p>'; line.hidden = false;
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;
// 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(); });
});
api('/api/config').then(config => { board = config.board; $('board-url').textContent = `Board: ${board}`; }).catch(err => { if (machine) error(err); }).finally(refresh);
// 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);
})();
+6
View File
@@ -30,6 +30,10 @@
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();
@@ -38,6 +42,7 @@
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 };
}
@@ -50,6 +55,7 @@
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></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><form id="sign-out" method="post" action="/auth/logout" hidden><button class="btn" type="submit">Sign out</button></form></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>
+56 -26
View File
@@ -61,6 +61,8 @@ 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) {
@@ -160,14 +162,18 @@ 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 (one(req, preview.header) !== preview.user) return json(res, 401, { error: 'sign-in required' });
if (!signedIn(req, preview)) 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') !== `https://${preview.host}`) return refuse(403, 'cross-origin request refused', 'a send needs the Console\'s own Origin');
if (one(req, 'origin') !== ownOrigin(preview)) 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); }
@@ -241,10 +247,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 (one(req, preview.header) !== preview.user) {
if (!signedIn(req, preview)) {
return refuse(401, 'sign-in required', 'sign in again');
}
if (one(req, 'origin') !== `https://${preview.host}`) {
if (one(req, 'origin') !== ownOrigin(preview)) {
return refuse(403, 'cross-origin request refused', 'settings need the Console origin');
}
if (one(req, 'sec-fetch-site') !== 'same-origin') {
@@ -292,8 +298,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 && 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 (!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 (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); }
@@ -335,8 +341,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 && 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 (!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 (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); }
@@ -382,8 +388,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 && 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 (!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 (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); }
@@ -420,8 +426,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 (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 (!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, '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); }
@@ -460,13 +466,17 @@ 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. `log`
// `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`
// 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, 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 } = {}) {
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 } = {}) {
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');
@@ -476,21 +486,26 @@ 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 || typeof send !== 'function')) throw new Error('--send needs the preview mode (--public-host, --auth-header and --allow-user)');
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');
const server = createServer(async (req, res) => {
res.setHeader('x-content-type-options', 'nosniff');
res.setHeader('referrer-policy', 'no-referrer');
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'");
// 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('') : ''}`);
// 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;
const named = preview !== null ? (req.headers.host ?? '').toLowerCase() === preview.host : sso !== null && (req.headers.host ?? '').toLowerCase() === sso.publicHost;
// The proof precedes every auth-header read, including the wrong-Host fence.
if (proof !== null) {
const values = req.headersDistinct[proof.header];
@@ -512,19 +527,34 @@ 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 ? `https://${preview.host}` : `http://${req.headers.host}`)) return json(res, 403, { error: 'cross-origin request refused' });
if (named) {
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) {
// 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}` } : preview;
const writer = tester ? { tester: true, user: 'tester', origin: `http://127.0.0.1:${server.address().port}` } : signer;
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,log});
return settingsSend(req,res,{settingsWrite,preview:signer,log});
}
if (Object.hasOwn(SEAT_ROUTES, path)) {
if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' });
@@ -544,18 +574,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, log });
return projectFileSend(req, res, { projectSave, preview: signer, 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, log });
return busSend(req, res, { send, preview: signer, 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 && req.method !== 'GET' && req.method !== 'HEAD') return json(res, 403, { error: 'read-only', message: 'the Console preview is read-only' });
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' });
// 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;
@@ -607,8 +637,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 } : {}), send: !tester && Boolean(send) && named, edit: !tester && Boolean(send) && Boolean(projectSave) && named });
if (path === '/healthz') return json(res, 200, { ok: true });
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 === '/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
@@ -0,0 +1,75 @@
// 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
@@ -0,0 +1,347 @@
// 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
@@ -0,0 +1,155 @@
// 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\)$/m;
const SEND_REFUSAL = /^webui-service: --send needs the preview mode \(--public-host, --auth-header and --allow-user\) or --sso$/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
@@ -0,0 +1,132 @@
// 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
@@ -0,0 +1,589 @@
// 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);
}
});
+32 -9
View File
@@ -26,6 +26,7 @@ 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"]);
@@ -106,13 +107,30 @@ function isAncestorOf(ancestor, candidate) {
return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
}
function validate(document, file) {
// 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) {
if (!isPlainObject(document)) {
fail(2, "configuration must be a JSON object");
}
rejectUnknownKeys(
document,
["configVersion", "environment", "dataRoot", "execution"],
["configVersion", "environment", "dataRoot", "execution", "console"],
"configuration",
);
@@ -146,6 +164,10 @@ 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,
@@ -156,10 +178,11 @@ function validate(document, file) {
model: document.execution.model,
adapter,
},
...(consoleKey === undefined ? {} : { console: consoleKey }),
};
}
function load(file) {
async function load(file) {
const raw = readRaw(file);
if (raw === null) {
fail(3, `configuration not found: ${file} (run scripts/bootstrap.sh to create it)`);
@@ -170,7 +193,7 @@ function load(file) {
} catch (error) {
fail(2, `configuration is not valid JSON (${file}): ${error.message}`);
}
return validate(document, file);
return await validate(document, file);
}
function shellQuote(value) {
@@ -194,7 +217,7 @@ const file = configPath();
switch (operation) {
case "bootstrap": {
if (fs.existsSync(file)) {
load(file); // validate only; never rewrite
await load(file); // validate only; never rewrite
process.stderr.write(`mosaic-config: configuration already present, validated without changes: ${file}\n`);
process.exit(0);
}
@@ -206,26 +229,26 @@ switch (operation) {
fs.writeFileSync(fd, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`);
} catch (error) {
if (error.code === "EEXIST") {
load(file);
await load(file);
process.exit(0);
}
fail(1, `unable to create configuration: ${error.message}`);
} finally {
if (fd !== undefined) fs.closeSync(fd);
}
load(file);
await load(file);
process.stderr.write(`mosaic-config: created default configuration: ${file}\n`);
process.exit(0);
}
case "validate": {
const resolved = load(file);
const resolved = await load(file);
process.stdout.write(`${JSON.stringify(resolved, null, 2)}\n`);
process.exit(0);
}
case "env": {
const resolved = load(file);
const resolved = await load(file);
process.stdout.write(
[
`MOSAIC_DATA_ROOT=${shellQuote(resolved.dataRoot)}`,
+46
View File
@@ -151,6 +151,52 @@ 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
+20 -7
View File
@@ -49,6 +49,9 @@
# 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
@@ -68,7 +71,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] [--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] [--sso [--send] | --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."
@@ -91,6 +94,7 @@ PUBLIC_HOST=""
AUTH_HEADER=""
ALLOW_USER=""
SEND=0
SSO=0
PROOF_HEADER=""
PROOF_FILE=""
PROOF_SET=0
@@ -114,6 +118,7 @@ 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
@@ -166,9 +171,13 @@ serve_flags() {
[ -z "$PUBLIC_HOST" ] || preview=$((preview + 1))
[ -z "$AUTH_HEADER" ] || preview=$((preview + 1))
[ -z "$ALLOW_USER" ] || preview=$((preview + 1))
# 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)"
# 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"
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
@@ -201,6 +210,7 @@ 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"
@@ -385,14 +395,17 @@ status_unit() {
else
echo "copy: none; the unit runs from $wd"
fi
# render puts --send after the allowed user and optional proof pair,
# before the tester port. The allowed user
# render puts --send after the allowed user and optional proof pair, or
# after --sso, 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 /[^ ]+)? --send( --tester-port [0-9]+)?$' "$target"; then
if grep -Eq '^ExecStart=.*( --allow-user [^ ][^ ]*( --proof-header [^ ]+ --proof-file /[^ ]+)?| --sso) --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