diff --git a/agents/launchpad/work/queue-192/BUILD.md b/agents/launchpad/work/queue-192/BUILD.md new file mode 100644 index 000000000..8a01b9a61 --- /dev/null +++ b/agents/launchpad/work/queue-192/BUILD.md @@ -0,0 +1,144 @@ +# 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 eba861f5d2bb). +Rulings sage-1340 (F1 to F6) and sage-1347 (F7, F8). Plan launchpad-0001. + +Base 53bb59462 (`base.txt`). Candidate: one commit on branch `launchpad/row192`. `candidate-manifest.sha256` +lists the 18 changed code, test and doc paths; the packet files are not in it. Drift check at commit time: +`git log 53bb59462..origin/refactor` (origin/refactor aa1bd209e) 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 [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 | + +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. + +## 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. + +## 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. diff --git a/agents/launchpad/work/queue-192/base.txt b/agents/launchpad/work/queue-192/base.txt new file mode 100644 index 000000000..6a539bdd6 --- /dev/null +++ b/agents/launchpad/work/queue-192/base.txt @@ -0,0 +1 @@ +53bb59462120a03a9f54b02881895e3f575e501c diff --git a/agents/launchpad/work/queue-192/candidate-manifest.sha256 b/agents/launchpad/work/queue-192/candidate-manifest.sha256 new file mode 100644 index 000000000..2d52a3e67 --- /dev/null +++ b/agents/launchpad/work/queue-192/candidate-manifest.sha256 @@ -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 +b506bb352ec71524799e276ead340437876cd9380ccceb266d9e4ad7b3ef400a packages/webui/src/serve.mjs +37b01ab0479d5f2a7ab9aac790931c6d88c790465392d9c0b65a105513f3e36b packages/webui/src/sso-config.mjs +285dea6be3d7895ab4f22c9a7cff88dcd0618004708d8f7489574cd1a8997334 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 +4a1645d9202690ad3e6911bb71ada379cbf5c46f67bd82bbc80c01c4a07330ed packages/webui/tests/sso.test.mjs +f8307d4bd998634467fbed8748157754132b76affac051f320196161ce385e4a scripts/mosaic-config.mjs +3301c1fabf616bb961d62f6decd0a46dadb0f399b8ba7f3c2ca60023d2b24c78 scripts/test-config.sh +7678bb6b45861c3dc6d775fc99269b1db7350ced60b89517d6ebeb4cc161781c scripts/webui-service.sh diff --git a/agents/launchpad/work/queue-192/mutants.py b/agents/launchpad/work/queue-192/mutants.py new file mode 100644 index 000000000..ba5fbc9fc --- /dev/null +++ b/agents/launchpad/work/queue-192/mutants.py @@ -0,0 +1,57 @@ +# Row 192 (#1690) mutants: `python3 mutants.py [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'), +] +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') diff --git a/package-lock.json b/package-lock.json index 244416347..0067b41a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 3c0e80966..124091dfc 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/packages/webui/README.md b/packages/webui/README.md index 634ceaead..429be8a1f 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -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 + `/secrets/mosaic-stack/`. 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: { : "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=`. 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 diff --git a/packages/webui/src/chat-http.mjs b/packages/webui/src/chat-http.mjs index f88f70b7d..c7770726d 100644 --- a/packages/webui/src/chat-http.mjs +++ b/packages/webui/src/chat-http.mjs @@ -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'); } diff --git a/packages/webui/src/cli.mjs b/packages/webui/src/cli.mjs index 774024bc6..080e3802b 100644 --- a/packages/webui/src/cli.mjs +++ b/packages/webui/src/cli.mjs @@ -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; diff --git a/packages/webui/src/public/app.js b/packages/webui/src/public/app.js index 89dbd749d..9739c9a8f 100644 --- a/packages/webui/src/public/app.js +++ b/packages/webui/src/public/app.js @@ -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 ? `${esc(labels[value] || value)}` : ''; @@ -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 = '

Signed out. Reload the page to sign in.

'; line.hidden = false; + line.innerHTML = sso || e.detail?.signIn + ? `

Signed out. Sign in again.

` + : '

Signed out. Reload the page to sign in.

'; + 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); })(); diff --git a/packages/webui/src/public/bus.js b/packages/webui/src/public/bus.js index 391c405d4..6a3a73b5a 100644 --- a/packages/webui/src/public/bus.js +++ b/packages/webui/src/public/bus.js @@ -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; } diff --git a/packages/webui/src/public/index.html b/packages/webui/src/public/index.html index 5063f33ca..5eb3e07ad 100644 --- a/packages/webui/src/public/index.html +++ b/packages/webui/src/public/index.html @@ -9,7 +9,7 @@
MosaicConsole

-
+

Control board

Loading board…

diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs index 47883b5c9..fa0d07a38 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -62,6 +62,7 @@ async function body(req, max = 4096) { const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'usage', 'limits', 'names', 'instances', 'routes']; const DECISION_LISTS = ['roles', 'recent']; // Row 177 (#1675): the chat writes the tester listener lets through, exactly the four chatHTTP routes. +const SIGN_IN_COOKIE = /(?:^|;)\s*__host-mosaic_(?:session|login)\s*=/i; 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) { let arg; @@ -160,14 +161,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 +246,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 +297,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 +340,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 +387,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 +425,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 +465,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 +485,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 +526,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 +573,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 +636,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' }); diff --git a/packages/webui/src/sso-config.mjs b/packages/webui/src/sso-config.mjs new file mode 100644 index 000000000..1238d2842 --- /dev/null +++ b/packages/webui/src/sso-config.mjs @@ -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; +} diff --git a/packages/webui/src/sso.mjs b/packages/webui/src/sso.mjs new file mode 100644 index 000000000..d8b70e743 --- /dev/null +++ b/packages/webui/src/sso.mjs @@ -0,0 +1,342 @@ +// 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(`${escape(title)}

${escape(title)}

${html}
\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', '

Sign in again

', 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 path on publicOrigin itself; anything else, or nothing, is `/`. + function returnPath(ret) { + try { + if (typeof ret !== 'string' || !ret) return '/'; + const u = new URL(ret, publicOrigin); + return u.origin === publicOrigin ? u.pathname + u.search : '/'; + } catch { return '/'; } + } + + 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 => `
  • ${escape(e.config.id)}
  • `).join(''); + return page(res, 200, 'Sign in', `
      ${items}
    `); + } + const entry = asked === null ? [...providers.values()][0] : providers.get(asked); + if (!entry) return page(res, 404, 'Unknown sign-in provider', '

    Sign in

    '); + if (entry.state !== 'ready') return page(res, 503, 'sign-in unavailable', '

    The sign-in provider can\'t be reached. Try again shortly.

    '); + 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, returnPath(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', '

    Sign out from the Console page.

    '); + } + 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', '

    Sign in

    '); + return page(res, 404, 'Not found', ''); + } + + return { + publicOrigin, publicHost, sessions, logins, handle, session, returnPath, + 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(); }, + }; +} diff --git a/packages/webui/tests/helpers/fake-oidc.mjs b/packages/webui/tests/helpers/fake-oidc.mjs new file mode 100644 index 000000000..5f611d5a0 --- /dev/null +++ b/packages/webui/tests/helpers/fake-oidc.mjs @@ -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 (`//`) 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, 'Sign in', '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(); }), + }; +} + diff --git a/packages/webui/tests/service.test.mjs b/packages/webui/tests/service.test.mjs index d8ff70edb..d64928cb4 100644 --- a/packages/webui/tests/service.test.mjs +++ b/packages/webui/tests/service.test.mjs @@ -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 diff --git a/packages/webui/tests/sso-browser.test.mjs b/packages/webui/tests/sso-browser.test.mjs new file mode 100644 index 000000000..7e621ba95 --- /dev/null +++ b/packages/webui/tests/sso-browser.test.mjs @@ -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(); } +}); diff --git a/packages/webui/tests/sso.test.mjs b/packages/webui/tests/sso.test.mjs new file mode 100644 index 000000000..8c43a04cf --- /dev/null +++ b/packages/webui/tests/sso.test.mjs @@ -0,0 +1,583 @@ +// 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, '/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&return=%2Fboard/); + assert.match(list.body, /provider=b&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', '/'], + ['return=https%3A%2F%2Fevil.example', '/'], + ['return=%2F%5Cevil.example', '/'], + ['return=/%09/evil.example', '/'], + ['return=%2Fboard%3Fx%3D1', '/board?x=1'], + ['', '/'], + ]) { + 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); + 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); + } +}); diff --git a/scripts/mosaic-config.mjs b/scripts/mosaic-config.mjs index cd66560d0..e18fda675 100755 --- a/scripts/mosaic-config.mjs +++ b/scripts/mosaic-config.mjs @@ -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)}`, diff --git a/scripts/test-config.sh b/scripts/test-config.sh index 2d409a271..c53fc582a 100755 --- a/scripts/test-config.sh +++ b/scripts/test-config.sh @@ -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 diff --git a/scripts/webui-service.sh b/scripts/webui-service.sh index d34af09dc..0a32547c5 100755 --- a/scripts/webui-service.sh +++ b/scripts/webui-service.sh @@ -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