|
|
|
@@ -0,0 +1,402 @@
|
|
|
|
|
# Row 255 design: a bundled Pocket ID for users with no OIDC provider (Q203, Gitea #1760, Vikunja 458)
|
|
|
|
|
|
|
|
|
|
Launchpad, draft 1, 2026-10-25, on `launchpad/row255` off refactor 78b69e1e6. Brief
|
|
|
|
|
`docs/plans/2026-10-21_bundled-pocket-id.md` (blob 26f34400). Reviewer Webby. Sage checks it against Q203, O6,
|
|
|
|
|
REQ-WEB-2 and REQ-INST-1. REQ-INST-2 (PRD 1.10) is post-v1 (mos-1204), and so is everything here.
|
|
|
|
|
|
|
|
|
|
This is a design only. Nothing here installs, downloads or starts Pocket ID. It changes nothing on .53, .54 or
|
|
|
|
|
dragon-lin, which keeps Authentik.
|
|
|
|
|
|
|
|
|
|
## Sources
|
|
|
|
|
|
|
|
|
|
- Q203 and O6 as the brief quotes them. PRD `docs/prd/mosaic-stack.md` REQ-WEB-2 (:902), REQ-INST-1 (:1509),
|
|
|
|
|
REQ-INST-2 (:1513) and REQ-INST-3 (:1528). Lead decision 44 items 5 and 6
|
|
|
|
|
(`docs/plans/2026-09-26_lead-decisions.md:655-663`).
|
|
|
|
|
- The Q175 design `agents/sage/work/q175-sso/DESIGN.md` sections 4 and 10, and the code row 192 landed:
|
|
|
|
|
`packages/webui/src/sso-config.mjs` (the `console.sso` shape), `packages/webui/src/sso.mjs` (the slot read,
|
|
|
|
|
the discovery checks at :105-118, the client at :147-148, the callback at :133, the post-logout URI at :332).
|
|
|
|
|
- Researcher's note `agents/researcher/work/2026-10-04_vikunja-pocketid.md` part 2 (v2.17.0).
|
|
|
|
|
- A fresh read of upstream on 2026-10-25, from release metadata and source text only: no binary was fetched or
|
|
|
|
|
run. The latest stable release is **v2.19.1** (2026-10-22). Source paths below are under
|
|
|
|
|
`backend/internal/` at tag v2.19.1 in `github.com/pocket-id/pocket-id`, marked "P". Releases v2.18.0,
|
|
|
|
|
v2.19.0 and v2.19.1 are not marked breaking.
|
|
|
|
|
|
|
|
|
|
## What the Console already needs from any provider
|
|
|
|
|
|
|
|
|
|
This design keeps the Console's sign-in and start checks exactly as row 192 built them. Pocket ID becomes one
|
|
|
|
|
`console.sso.providers` entry, the same shape any compliant OP uses (Q175 section 4, "Several providers"). The
|
|
|
|
|
Console gets no new sign-in path, no password and no proxy-header identity (O6, REQ-WEB-2, REQ-INST-2). The
|
|
|
|
|
facts that drive the rest of the design:
|
|
|
|
|
|
|
|
|
|
- `publicOrigin` and every `issuer` must be https (`sso-config.mjs` `httpsURL`). A plain-http Pocket ID can't
|
|
|
|
|
be a provider at all.
|
|
|
|
|
- Each provider needs 1 to 64 `subjects`. A `console.sso` with no subject refuses to load. So the first
|
|
|
|
|
person's subject has to exist before the Console starts with `--sso` (section 5).
|
|
|
|
|
- The client authenticates with `client_secret_basic` (`sso.mjs:148`), so the client is confidential.
|
|
|
|
|
- The slot is `<configDir>/secrets/mosaic-stack/<slot>`: a regular file, owned by the Console's uid, mode
|
|
|
|
|
with no group or other bits, and outside the repo and every stack copy (`readSlot`).
|
|
|
|
|
- `config.json` is user-authored. bootstrap.sh creates it and nothing overwrites it (canon invariant 2, Q175
|
|
|
|
|
section 4). This matters for sections 1 and 5.
|
|
|
|
|
|
|
|
|
|
## 1. The choice
|
|
|
|
|
|
|
|
|
|
The installer is REQ-INST-3's install command (Vikunja #432; today `stack-upgrade.sh` and the per-case
|
|
|
|
|
install scripts). It takes one sign-in option per instance:
|
|
|
|
|
|
|
|
|
|
- `--oidc existing`. The user points the Console at one or more OIDC providers they already have. Nothing
|
|
|
|
|
is installed. The user writes `console.sso` themselves, as Q175 step 4 does today. The installer writes
|
|
|
|
|
nothing into `console.sso`; it prints the entry shape and the two URIs to register
|
|
|
|
|
(`<publicOrigin>/auth/callback`, `<publicOrigin>/auth/signed-out`).
|
|
|
|
|
- `--oidc bundled`. The stack installs Pocket ID as an install component (section 2), and
|
|
|
|
|
`console.sso.providers` holds exactly one entry, `id: "pocket-id"`, whose `issuer` is the component's
|
|
|
|
|
origin.
|
|
|
|
|
- No option keeps today's behaviour: no `console.sso`, and the Console runs without `--sso`.
|
|
|
|
|
|
|
|
|
|
How the bundled choice is recorded. REQ-INST-1 lets a component be switched on "in the organization file
|
|
|
|
|
or system config". It has to be the system config. The organization file can be written by agents through
|
|
|
|
|
`organizationWriter`, which is the reason Q175 B1 put `console.sso` in config.json, and the same reason
|
|
|
|
|
holds for a component that issues the Console's identities. So config.json gets one new optional key:
|
|
|
|
|
|
|
|
|
|
```json
|
|
|
|
|
"components": {
|
|
|
|
|
"pocket-id": {
|
|
|
|
|
"release": "v2.19.1",
|
|
|
|
|
"origin": "https://id.example.internal",
|
|
|
|
|
"port": 1411
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
The loader (`scripts/mosaic-config.mjs`) checks the shape: `release` is a `vX.Y.Z` tag, and the component script refuses one that
|
|
|
|
|
differs from the pin file (section 2), `origin` is an https origin, `port` is 1024 to 65535. It also cross-checks: when
|
|
|
|
|
`components["pocket-id"]` is present, `console.sso` must have exactly one provider, `id` `pocket-id`,
|
|
|
|
|
`issuer` equal to `origin`. A config with `console.sso` providers and no `components` is the existing
|
|
|
|
|
choice and loads as today.
|
|
|
|
|
|
|
|
|
|
**M1, approved by Mos with three conditions (mos-1666 item 3).** `components` is a new top-level config key.
|
|
|
|
|
The loader refuses unknown keys today, so this is a config schema change. The conditions:
|
|
|
|
|
|
|
|
|
|
1. **Rollback trap.** A copy older than P1 refuses a config that has the key, the same trap Q175 section 4
|
|
|
|
|
names for `console.sso`. P1 documents the order. On a rollback past P1, first take `components` out of
|
|
|
|
|
config.json (restore the backup made before the key went in), then roll back the pointer. The STATE check
|
|
|
|
|
after the rollback reads config.json and confirms the key is absent.
|
|
|
|
|
2. **Strict inside the key.** The loader refuses any name under `components` other than `pocket-id`, and any
|
|
|
|
|
field under `pocket-id` other than `release`, `origin` and `port`. Each refusal names the key path and
|
|
|
|
|
quotes no value, as `checkConsole` does.
|
|
|
|
|
3. **test-config.sh cases:**
|
|
|
|
|
- a valid `components` loads;
|
|
|
|
|
- a malformed one fails, naming the bad key path;
|
|
|
|
|
- a config without `components` loads;
|
|
|
|
|
- the cross-check refuses a `console.sso` whose provider doesn't match `origin`.
|
|
|
|
|
|
|
|
|
|
What each choice writes into `console.sso` is in section 5. For the bundled choice, Pocket ID is the only
|
|
|
|
|
providers entry. The design keeps it that way because REQ-INST-2 says "the Console's one providers entry".
|
|
|
|
|
A user who later adds an existing provider moves to `--oidc existing` and removes the component (section 2).
|
|
|
|
|
|
|
|
|
|
## 2. The component, against REQ-INST-1
|
|
|
|
|
|
|
|
|
|
**Pinned.** A Pocket ID release is a set of raw binaries plus `checksums.txt` (sha256). From v2.16.0 there is
|
|
|
|
|
an SPDX SBOM per binary, and the release workflow attests `checksums.txt` through Sigstore (`actions/attest`
|
|
|
|
|
in `.github/workflows/release.yml`). The repo holds one pin file,
|
|
|
|
|
`scripts/pocket-id-pins.sh`, sourced the way `scripts/case1-pins.sh` is: the tag, and for each supported platform (linux amd64 and arm64 to
|
|
|
|
|
start) the asset name and its sha256. The build row copies the sha256 from `checksums.txt` at the tag and
|
|
|
|
|
records the `gh attestation verify` output for each asset as its evidence. The pin is checked by hand at
|
|
|
|
|
build time, not trusted at install time. No Pocket ID source and no binary goes in the repo. The binary is
|
|
|
|
|
not in any image either, since the container install comes after v1 (REQ-INST-3).
|
|
|
|
|
|
|
|
|
|
Recommended pin: v2.19.1, the current release. The build row checks it again, because Pocket ID ships a
|
|
|
|
|
minor every one to four weeks. Upgrading is a pin file change in a reviewed commit.
|
|
|
|
|
|
|
|
|
|
**Fetched and checked.** `scripts/component-pocket-id.sh fetch` downloads the pinned asset for the host's
|
|
|
|
|
platform over https into a new 0700 staging directory, computes its sha256, and compares it with the pin.
|
|
|
|
|
On a mismatch it deletes the file and refuses with `pocket-id-digest-mismatch`, naming the expected value
|
|
|
|
|
and the computed one. On a match it installs the binary read-only (0555) at
|
|
|
|
|
`<dataRoot>/components/pocket-id/bin/pocket-id-<tag>`. The fetch is the only network step, and the user
|
|
|
|
|
runs it at install. Tests never run it against the network (section 9).
|
|
|
|
|
|
|
|
|
|
**Runs.** A systemd user unit, `mosaic-pocket-id.service` (`mosaic-pocket-id-NAME.service` for a named
|
|
|
|
|
instance, as row 190 names the bus and webui units), as the instance's user:
|
|
|
|
|
|
|
|
|
|
- `ExecStart=<dataRoot>/components/pocket-id/bin/pocket-id-<tag>`.
|
|
|
|
|
- `WorkingDirectory=<dataRoot>/components/pocket-id/run`, a directory the unit owns, so Pocket ID's habit of
|
|
|
|
|
reading `.env` from the working directory reads nothing we didn't write. The build row checks there is no
|
|
|
|
|
`.env` there at start.
|
|
|
|
|
- Environment, all from the rendered unit:
|
|
|
|
|
- `APP_URL=<origin>` and `HOST=127.0.0.1` (P `common/env_config.go`: HOST defaults to `0.0.0.0`).
|
|
|
|
|
- `PORT=<port>`.
|
|
|
|
|
- `DB_CONNECTION_STRING` and `UPLOAD_PATH` as absolute paths under `<dataRoot>/components/pocket-id/data/`
|
|
|
|
|
(P defaults are `data/pocket-id.db` and `data/uploads`, relative to the working directory).
|
|
|
|
|
- `ALLOW_USER_SIGNUPS=disabled` and `ALLOW_INSECURE_CALLBACK_URLS=false`.
|
|
|
|
|
- `EMAIL_ONE_TIME_ACCESS_AS_UNAUTHENTICATED_ENABLED=false` and `..._AS_ADMIN_ENABLED=false`.
|
|
|
|
|
- The `OUTBOUND_ALLOWED_HOSTS_*` allowlists set empty (added in 2.19.0).
|
|
|
|
|
- No `TRUST_PROXY` unless section 6's proxy step sets it to the proxy's address.
|
|
|
|
|
- `ENCRYPTION_KEY` (mandatory since v2.0, at least 16 bytes) never goes in the unit text. It lives in slot
|
|
|
|
|
`<configDir>/secrets/mosaic-stack/pocket-id-encryption-key`, 0600. The component script generates it from
|
|
|
|
|
`/dev/urandom` straight into the file and never prints it. The unit passes it with
|
|
|
|
|
`LoadCredential=` and `ENCRYPTION_KEY_FILE=%d/...`. The build row confirms `ENCRYPTION_KEY_FILE` at the pinned
|
|
|
|
|
tag; if it isn't there, it refuses rather than putting the key in `Environment=`.
|
|
|
|
|
- The unit never carries `STATIC_API_KEY`. That key exists only during the enroll run (section 4).
|
|
|
|
|
|
|
|
|
|
The data directory holds the SQLite database, Pocket ID's signing key (stored encrypted with
|
|
|
|
|
`ENCRYPTION_KEY`), and the passkey registrations. It is 0700. Agent sandboxes bind only an allowlist
|
|
|
|
|
(`packages/seat/src/sandbox.mjs`), and `<dataRoot>/components/` is not on it. The build row adds a sandbox
|
|
|
|
|
test that pins that.
|
|
|
|
|
|
|
|
|
|
**Health-checked.** `GET http://127.0.0.1:<port>/healthz` returns 204 (P `controller/healthz_controller.go`).
|
|
|
|
|
`component-pocket-id.sh status` reports the unit state, the binary tag against the pin, the binary's sha256
|
|
|
|
|
against the pin, and the health result. The Console's own `/healthz` already shows `sso: { "pocket-id": ready
|
|
|
|
|
| unreachable | refused }` from its discovery retries, so an operator sees the provider from either side.
|
|
|
|
|
|
|
|
|
|
**Removable.** `component-pocket-id.sh remove` refuses while `console.sso` still names `pocket-id`, so the
|
|
|
|
|
Console can't be left pointing at a provider that's gone. Once the user has taken the entry out (or moved to
|
|
|
|
|
`--oidc existing`), the script stops and removes the unit, then moves the data directory to
|
|
|
|
|
`<dataRoot>/components/pocket-id.removed-<utc>` with a receipt line in `<dataRoot>/state/components.log`
|
|
|
|
|
(append-only). It never deletes the passkey database outright. Deleting the moved directory and the
|
|
|
|
|
encryption key slot is a separate `purge` verb that names both paths and needs `--yes`. Removing the
|
|
|
|
|
`components` key from config.json is the user's edit.
|
|
|
|
|
|
|
|
|
|
## 3. The Q175 start checks against the bundled instance
|
|
|
|
|
|
|
|
|
|
The Console checks are unchanged. Here is each one against Pocket ID, and the setting it needs:
|
|
|
|
|
|
|
|
|
|
| Check (row 192) | Pocket ID v2.19.1 | Default that fails | Fix |
|
|
|
|
|
|---|---|---|---|
|
|
|
|
|
| `issuer` is https (loader) | issuer = `APP_URL` exactly (P `controller/well_known_controller.go`) | `APP_URL` defaults to `http://localhost:1411` | `APP_URL=<https origin>`, section 6 |
|
|
|
|
|
| discovery `issuer` equals config `issuer` | equal when `APP_URL` has no trailing slash; Pocket ID refuses a path | none | config `issuer` = `origin`, no trailing slash; the loader cross-check compares them |
|
|
|
|
|
| `id_token_signing_alg_values_supported` overlaps `algs` | advertises only the current key's alg. RS256 (RSA-2048) by default (P `service/jwt_service.go`) | none | `algs: ["RS256"]` |
|
|
|
|
|
| `code_challenge_methods_supported` has `S256` | advertises `plain` and `S256` | none | none. The Console always sends S256 (`sso.mjs:221`) |
|
|
|
|
|
| JWKS has a usable key for `algs` | one key at `/.well-known/jwks.json` | none | none. The build row's fake OP pins `use`/`alg`/`kid` as Pocket ID emits them |
|
|
|
|
|
| `end_session_endpoint` is https | `<APP_URL>/api/oidc/end-session` | http, if `APP_URL` is http | same fix as row 1 |
|
|
|
|
|
| `redirect_uri` is registered | exact match first, then wildcard patterns; loopback callbacks match any port (P `utils/callback_url_util.go`) | `ALLOW_INSECURE_CALLBACK_URLS` defaults to true | register exactly `<publicOrigin>/auth/callback`, with no `*`; set `ALLOW_INSECURE_CALLBACK_URLS=false` |
|
|
|
|
|
| post-logout URI | per-client `logoutCallbackURLs`, same matching | none | register exactly `<publicOrigin>/auth/signed-out` |
|
|
|
|
|
| ID token alg in `algs`, `iss` match | as above | none | none |
|
|
|
|
|
|
|
|
|
|
Two points the Console doesn't check, set on the client anyway:
|
|
|
|
|
|
|
|
|
|
- `pkceEnabled: true`. Then Pocket ID refuses an authorize request that has no `code_challenge`. Pocket ID has
|
|
|
|
|
no per-client S256-only setting (P `oidc/provider.go` turns on `plain` for the server). That doesn't relax
|
|
|
|
|
anything: the Console sends S256 every time, and `plain` stays available only to other clients.
|
|
|
|
|
- `isPublic: false`, which matches `client_secret_basic`.
|
|
|
|
|
|
|
|
|
|
Key rotation. `pocket-id key-rotate --alg ...` replaces the one signing key (P `cmds/key_rotate.go`). After a
|
|
|
|
|
rotation to another alg, the Console's discovery check refuses on the next restart (no overlap with
|
|
|
|
|
`algs`), and that is the intended fail-closed result. A rotation is a user step that updates `algs` in the
|
|
|
|
|
same edit. The component script never rotates.
|
|
|
|
|
|
|
|
|
|
## 4. The client, and where its secret goes
|
|
|
|
|
|
|
|
|
|
The person running the install creates the client through `component-pocket-id.sh enroll`. No agent runs
|
|
|
|
|
this verb. It refuses when any agent marker is in its environment: the `MARKERS` list in
|
|
|
|
|
`packages/bus/src/human.mjs` (`MOSAIC_RUN_ID`, `MOSAIC_BUS_CAP`, `CLAUDECODE` and the rest) that the human
|
|
|
|
|
boundary uses. A test pins the refusal.
|
|
|
|
|
|
|
|
|
|
Enroll needs admin rights before any person has a passkey. Pocket ID offers two ways to get them, and one of
|
|
|
|
|
them is a hazard:
|
|
|
|
|
|
|
|
|
|
- `/setup` and its backing `POST /api/signup/setup` are unauthenticated: whoever calls first becomes admin
|
|
|
|
|
(P `usersignup/service.go`). Sandboxed agents share the host's network namespace (`sandbox.mjs` unshares
|
|
|
|
|
user, pid and ipc, not net), so any agent could reach a loopback Pocket ID and take that first admin.
|
|
|
|
|
- `STATIC_API_KEY` is a fixed admin key with no expiry (P `apikey/service.go`). It doesn't count as
|
|
|
|
|
completing setup, so `/setup` stays open while it's set.
|
|
|
|
|
|
|
|
|
|
So enroll never lets the real unit serve before an admin person exists. It runs the pinned binary once in a
|
|
|
|
|
**private network namespace** (`unshare --user --net`, or `bwrap --unshare-net` with the binary and data
|
|
|
|
|
directory bound), where only enroll's own loopback can reach it:
|
|
|
|
|
|
|
|
|
|
1. Generate a one-run static key into a 0600 temp file in a new 0700 directory, and start Pocket ID inside
|
|
|
|
|
the namespace with `STATIC_API_KEY_FILE` pointing at it, plus the unit's environment.
|
|
|
|
|
2. Wait for `/healthz` 204.
|
|
|
|
|
3. Create the admin user, `POST /api/users` with `isAdmin: true` and the username and email given with
|
|
|
|
|
`--admin-user` and `--admin-email`. Keep the returned `id`. That is the OIDC `sub` (P
|
|
|
|
|
`oidc/claims_service.go` uses `user.ID`, a UUIDv4 set at creation, so it's stable).
|
|
|
|
|
4. Create the Console client: client id `mosaic-console-<instance>`, callback and logout URLs exactly as in
|
|
|
|
|
section 3, `pkceEnabled: true`, `isPublic: false`. Supply the secret instead of letting Pocket ID
|
|
|
|
|
generate it. Enroll generates 48 random bytes, base64url, and writes them straight to slot
|
|
|
|
|
`<configDir>/secrets/mosaic-stack/console-sso-pocket-id` (0600, `O_CREAT|O_EXCL`, not a symlink). It sends
|
|
|
|
|
the same bytes in the create body over the namespace's loopback (`dto/oidc_dto.go` accepts a supplied
|
|
|
|
|
secret of 16 or more characters). The value is never printed, logged or put in an argv, and a test pins
|
|
|
|
|
that it appears in no output.
|
|
|
|
|
5. Mint a one-time access token for the admin, `POST /api/users/<id>/one-time-access-token`.
|
|
|
|
|
6. Stop the namespaced process, then shred and remove the static key file. The real unit has never had a
|
|
|
|
|
static key, so no admin key survives enroll.
|
|
|
|
|
7. Start the real unit and wait for `/healthz` 204.
|
|
|
|
|
8. Print the sign-in link `<origin>/lc/<token>` to the user's terminal (section 5), and the `console.sso`
|
|
|
|
|
entry (section 5).
|
|
|
|
|
|
|
|
|
|
The build row confirms each route and body against the pinned tag's source before it writes the client (the
|
|
|
|
|
upstream read for this design didn't trace the create-client route). If any of steps 1 to 7 fails, enroll
|
|
|
|
|
stops the process, removes the static key, leaves no slot file it created, and refuses with the step name.
|
|
|
|
|
|
|
|
|
|
The secret never enters the repo or an image. It sits in two places only: the slot file and Pocket ID's
|
|
|
|
|
database (stored as a hash). The Console's `readSlot` reads it as it does any provider's.
|
|
|
|
|
|
|
|
|
|
## 5. The first person, and their subject in `console.sso`
|
|
|
|
|
|
|
|
|
|
Pocket ID is passkey-only for people (REQ-INST-2). The installing user enrolls like this:
|
|
|
|
|
|
|
|
|
|
1. Enroll prints `<origin>/lc/<token>`. The token is valid for one hour and is a credential, so it goes only
|
|
|
|
|
to the terminal of the person who ran enroll. It goes in no log and no file.
|
|
|
|
|
2. The user opens it in a browser that can reach `origin` (section 6). Pocket ID signs them in and asks them
|
|
|
|
|
to add a passkey. Without one they can't sign in again. If the hour passes, `pocket-id
|
|
|
|
|
one-time-access-token <username>` (P `cmds/one_time_access_token.go`), run by the user against the data
|
|
|
|
|
directory, prints a new link. `component-pocket-id.sh relink` wraps that call.
|
|
|
|
|
3. Enroll already has the subject, the `id` from step 3 of section 4. It doesn't wait for a sign-in or read a
|
|
|
|
|
`subject-unknown` log line.
|
|
|
|
|
|
|
|
|
|
How the subject gets into `console.sso`. config.json is user-authored and never auto-written. Two ways
|
|
|
|
|
to respect that; I recommend the first:
|
|
|
|
|
|
|
|
|
|
- **R1 (recommended).** Enroll prints the full entry and writes nothing:
|
|
|
|
|
|
|
|
|
|
```json
|
|
|
|
|
"console": { "sso": { "publicOrigin": "<publicOrigin>", "providers": [ {
|
|
|
|
|
"id": "pocket-id", "issuer": "<origin>", "clientId": "mosaic-console-<instance>",
|
|
|
|
|
"clientSecret": { "slot": "console-sso-pocket-id" }, "algs": ["RS256"],
|
|
|
|
|
"subjects": [ { "sub": "<id>", "name": "<admin-user>" } ] } ] } },
|
|
|
|
|
"components": { "pocket-id": { "release": "<tag>", "origin": "<origin>", "port": <port> } }
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
The user pastes it into config.json, as with Q175 step 4. Rerunning `scripts/bootstrap.sh` (or
|
|
|
|
|
`--instance NAME`) then validates it through the loader, including the section 1 cross-check, and writes
|
|
|
|
|
nothing. Nothing changes in invariant 2.
|
|
|
|
|
- **R2 (rejected, Sage sage-4401: R1 chosen).** `enroll --write-config` would write those two keys itself, with a backup of config.json first, and
|
|
|
|
|
refuse if `console` or `components` were already present. That would be the first tool to modify an
|
|
|
|
|
existing config.json, against canon invariant 2's "never overwritten" practice. Not built.
|
|
|
|
|
|
|
|
|
|
Whose subject. Enroll creates one person, the one running it, and only that subject goes in the entry. On
|
|
|
|
|
Jason's instances, only his subject goes into `console.sso` (mos-1197). Further people are added with
|
|
|
|
|
Pocket ID's admin UI (a user plus a one-time link), and their subjects go into `console.sso` by the user's
|
|
|
|
|
edit, never by an agent. Pocket ID users who aren't in `subjects` can sign in to Pocket ID, but the Console
|
|
|
|
|
refuses them `subject-unknown`, so REQ-WEB-2's "must map to an authorized human" holds.
|
|
|
|
|
|
|
|
|
|
## 6. Reach: origin, TLS and proxy
|
|
|
|
|
|
|
|
|
|
WebAuthn takes its RP id from `APP_URL`'s host name, and its allowed origin is `APP_URL`
|
|
|
|
|
(P `webauthn/module.go`). Browsers allow passkeys only on a secure origin. The Console also needs https for
|
|
|
|
|
both `publicOrigin` and `issuer`. Pocket ID serves plain http, and I found no TLS listener setting in its
|
|
|
|
|
environment config. So:
|
|
|
|
|
|
|
|
|
|
- Pocket ID always binds `127.0.0.1:<port>`. That is the default, and the component never binds anything
|
|
|
|
|
else (`HOST=127.0.0.1`; the status verb refuses a unit whose `HOST` differs).
|
|
|
|
|
- `origin` is an https origin served by a TLS front that the user already has for the Console. The Console
|
|
|
|
|
needs one anyway, since `publicOrigin` is https. Pocket ID goes behind the same front under **its own host
|
|
|
|
|
name**, for example `id.<console host>`. Pocket ID doesn't support a path prefix (`APP_URL` must have no
|
|
|
|
|
path). A separate host name also keeps Pocket ID's cookies apart from the Console's
|
|
|
|
|
`__Host-mosaic_*` cookies; the two share cookies if they sit on one host name on different ports.
|
|
|
|
|
- The front reaches Pocket ID on loopback, so the front has to run on the same host. If it doesn't, Pocket
|
|
|
|
|
ID would need a non-loopback bind, and that is a `network.expose` hard wait (REQ-WAIT-1), not a component
|
|
|
|
|
option.
|
|
|
|
|
- `TRUST_PROXY` is set to the front's address only, never `true`.
|
|
|
|
|
- The Console fetches discovery and JWKS from `issuer` over https from the server side, so the Console's
|
|
|
|
|
node has to trust the front's certificate. A public CA does that. A private CA needs
|
|
|
|
|
`NODE_EXTRA_CA_CERTS` in the Console unit, which the build row adds as a render flag.
|
|
|
|
|
|
|
|
|
|
These steps are the user's, and on Jason's hosts they are Jason's, because each one changes who can reach
|
|
|
|
|
the Console or Pocket ID, or who the host trusts:
|
|
|
|
|
- J1: the DNS name for `origin`;
|
|
|
|
|
- J2: the certificate for it, or a private CA and its trust in the browser and in `NODE_EXTRA_CA_CERTS`;
|
|
|
|
|
- J3: the TLS front's route from `origin` to `127.0.0.1:<port>` (a Traefik change on Jason's hosts);
|
|
|
|
|
- J4: any listener off loopback.
|
|
|
|
|
|
|
|
|
|
The component checks and refuses; it never does them itself. With none of them done, Pocket ID runs on
|
|
|
|
|
loopback with an https `APP_URL` that nothing serves yet. Enroll's step 2 health probe goes straight to
|
|
|
|
|
`127.0.0.1`, so enroll still works. The person can't add a passkey until J1 to J3 are done.
|
|
|
|
|
|
|
|
|
|
## 7. Agents
|
|
|
|
|
|
|
|
|
|
Unchanged. Agents keep their per-role service tokens (lead decision 44 item 6, REQ-INST-2). Pocket ID issues
|
|
|
|
|
nothing an agent uses. The Console client is the only client enroll creates. No API, no client-credentials
|
|
|
|
|
grant and no admin API key exist after enroll. Residuals, stated plainly:
|
|
|
|
|
|
|
|
|
|
- Agents can reach `127.0.0.1:<port>`, because the sandbox shares the host network. What they can reach there
|
|
|
|
|
is the public sign-in pages, discovery and JWKS. Signup is disabled, email codes are off, `/setup` is
|
|
|
|
|
closed because an admin exists, and there is no static key. A test of the rendered unit pins all four
|
|
|
|
|
settings.
|
|
|
|
|
- The data directory and the two slots are outside every sandbox bind (section 2).
|
|
|
|
|
- A seat that ran as the same OS user outside a sandbox could read the data directory. The Console's
|
|
|
|
|
existing slot has the same exposure; this design adds no new class of it.
|
|
|
|
|
|
|
|
|
|
## 8. Instances
|
|
|
|
|
|
|
|
|
|
One Pocket ID per named instance, never shared:
|
|
|
|
|
|
|
|
|
|
- Each instance has one system config (row 190), and `components` and `console.sso` live in it. A shared
|
|
|
|
|
Pocket ID would make one instance's config depend on a unit, a data directory and an encryption key that
|
|
|
|
|
belong to another instance's config and data root.
|
|
|
|
|
- Unit names, ports, data directory, slots and removal are all per instance (`mosaic-pocket-id-NAME`,
|
|
|
|
|
`<dataRoot of NAME>/components/pocket-id`), so removing one instance's component can't break another's
|
|
|
|
|
sign-in.
|
|
|
|
|
- Each instance needs its own `origin` (section 6), so passkeys are per instance too. A person enrolls once
|
|
|
|
|
per instance.
|
|
|
|
|
- An instance that wants to use another instance's Pocket ID can still do it with `--oidc existing`. Then it
|
|
|
|
|
is just an OIDC provider, with its own client registered by that instance's owner. Nothing special-cases it.
|
|
|
|
|
|
|
|
|
|
Two instances on one host each need their own `port`. The loader can't see other instances' configs, so
|
|
|
|
|
`component-pocket-id.sh` refuses at fetch and enroll when the port is already bound on loopback.
|
|
|
|
|
|
|
|
|
|
## 9. Tests, stand-ins only
|
|
|
|
|
|
|
|
|
|
No test downloads or starts a real Pocket ID, and no test touches auth.diversecanvas.com,
|
|
|
|
|
mosaic.woltje.com or 127.0.0.1:7332. The stand-ins:
|
|
|
|
|
|
|
|
|
|
- **Fake Pocket ID OP**, a node server like row 192's fake OP, shaped from v2.19.1:
|
|
|
|
|
- the discovery fields `well_known_controller.go` emits (issuer = origin, `plain` and `S256`,
|
|
|
|
|
`end_session_endpoint` at `/api/oidc/end-session`, the three auth methods);
|
|
|
|
|
- one RS256 JWK at `/.well-known/jwks.json`;
|
|
|
|
|
- a token endpoint that takes only `client_secret_basic`;
|
|
|
|
|
- `/healthz` 204.
|
|
|
|
|
|
|
|
|
|
The Console starts against it under `--sso` with the section 5 entry, with no check changed. Negative
|
|
|
|
|
cases:
|
|
|
|
|
- an http issuer refuses at load;
|
|
|
|
|
- an ES256-only JWKS against `algs: ["RS256"]` refuses at discovery;
|
|
|
|
|
- an issuer with a trailing slash refuses on the cross-check.
|
|
|
|
|
- **Fake admin API** on the same stand-in: `POST /api/users`, the client create, the one-time token, and a
|
|
|
|
|
static-key check. Enroll runs against it with the binary replaced by a stub script that serves the stand-in.
|
|
|
|
|
The tests pin:
|
|
|
|
|
- the namespace argv;
|
|
|
|
|
- the slot file's mode and `O_EXCL`;
|
|
|
|
|
- that the secret appears in no stdout, stderr or argv;
|
|
|
|
|
- the static key file gone after success and after a failure at each step;
|
|
|
|
|
- the refusal under an agent marker in the environment.
|
|
|
|
|
- **Fetch**: a local file served by a test https server (self-signed, trusted only by the test) with a
|
|
|
|
|
matching pin, a mismatched pin and a truncated file.
|
|
|
|
|
- **Unit render**: the environment table in section 2. `HOST=127.0.0.1`, no `STATIC_API_KEY`, no
|
|
|
|
|
`ENCRYPTION_KEY` value in the text, and `ALLOW_USER_SIGNUPS=disabled`.
|
|
|
|
|
- **Loader**: test-config.sh cases for the `components` key and the cross-check, and the copy-rollback note.
|
|
|
|
|
- **Sandbox**: `<dataRoot>/components/` is in no bind list.
|
|
|
|
|
|
|
|
|
|
## 10. Build rows, in landing order
|
|
|
|
|
|
|
|
|
|
All are post-v1 (REQ-INST-2). Each row has Webby or Sage's named reviewer and its own gate. None of them
|
|
|
|
|
runs on .53, .54 or dragon-lin, and none installs Pocket ID on any host.
|
|
|
|
|
|
|
|
|
|
1. **P1, loader key.** `components["pocket-id"]` in `mosaic-config.mjs`, strict inside the key, and the
|
|
|
|
|
cross-check against `console.sso` (section 1, M1 conditions from mos-1666 item 3). P1 also writes the
|
|
|
|
|
rollback order into `docs/TOOLS.md` beside Q175's: remove `components` (restore the backup) before the
|
|
|
|
|
pointer goes back, and the post-rollback STATE check confirms the key is absent from config.json. Gate:
|
|
|
|
|
test-config.sh green with the four M1 cases.
|
|
|
|
|
2. **P2, pin and fetch.** `scripts/pocket-id-pins.sh` with the build-time sha256 and the
|
|
|
|
|
attestation evidence, and `component-pocket-id.sh fetch|verify`. Gate: the fetch tests against the local
|
|
|
|
|
stand-in; the evidence file shows `gh attestation verify` output for each pinned asset.
|
|
|
|
|
3. **P3, unit.** The unit template, `render|install|status|remove|purge`, and the encryption key slot. Gate:
|
|
|
|
|
the render and remove tests, and the sandbox bind test.
|
|
|
|
|
4. **P4, enroll.** The namespaced bootstrap, the client, the subject, `relink`, and printing the entry (R1),
|
|
|
|
|
(R2 rejected). Gate: the enroll tests in section 9, and a secret-leak grep over every test log.
|
|
|
|
|
5. **P5, Console against the fake Pocket ID.** The section 9 fake-OP start tests, and the
|
|
|
|
|
`NODE_EXTRA_CA_CERTS` render flag for the Console unit. Gate: the webui suite, with no change to
|
|
|
|
|
`sso.mjs` checks (a diff of `sso.mjs` and `sso-config.mjs` against the base shows none).
|
|
|
|
|
6. **P6, installer option.** `--oidc existing|bundled` on REQ-INST-3's install command, calling P2 to P4 in
|
|
|
|
|
order, and the docs: `docs/TOOLS.md`, the host run sheet's J1 to J4 steps. Gate: the installer's own
|
|
|
|
|
suite against the stubs; the docs list J1 to J4 as the user's steps.
|
|
|
|
|
|
|
|
|
|
## Open items for review
|
|
|
|
|
|
|
|
|
|
- M1 approved by Mos with three conditions (mos-1666 item 3), written into section 1 and P1.
|
|
|
|
|
- M2 dropped: Sage chose R1 and rejected R2 (sage-4401).
|
|
|
|
|
- **Q1 (Webby):** the private network namespace for enroll (section 4). The other option is to start the
|
|
|
|
|
real unit and race `/setup`, which I think is unsafe while agents share the host network.
|
|
|
|
|
- **Q2 (Webby):** one-providers-entry for the bundled choice (section 1). REQ-INST-2's wording reads that
|
|
|
|
|
way. Mixing a bundled Pocket ID with existing providers would need Sage's ruling.
|
|
|
|
|
- Not covered: Pocket ID for Vikunja or Gitea people sign-in (the Researcher note's part 3), and
|
|
|
|
|
client-credentials between Mosaic services. Neither is in Q203.
|