ADR-0036: Unified user identity — shared SSO, three-tier roles, and route-scope authorization
- Status: Accepted
- Date: 2026-06-11
- Deciders: @karasu
- Supersedes: —
- Amends: ADR-0007 (daemon now verifies JWT signatures for the sharedsso bootstrap path — see §9),
docs/specs/guests.md,docs/specs/http-api.md,docs/specs/daemon.md(local config),docs/specs/ui/README.md, ADR-0035 shell specs (system tab visibility by role) - Superseded by: —
- Note on numbering: bump to the next free sequential ADR number on merge if 0036 is taken.
Like ADR-0035, this ADR is intentionally longer than usual and carries an Implementation section (§10) with a phased, checklist-driven plan. Per ADR-0003 the fine-grained mechanics still cascade into the specs listed in §10 Phase 0; this document fixes the decisions, the honest tradeoffs, and the load-bearing contracts (token format, schema, route-scope semantics, migration procedure) that must not drift during build.
1. Context and problem statement
kaged authentication today (ADR-0007 + amendments) has three modes — sidecar, loopback, insecure — all of which resolve to a single ambient operator identity, plus a fully separate guest subsystem (guests table, kaged_guest_session cookie, /api/v1/g/* surface, per-project grants).
Four pressures have converged:
Multiple operators on one instance. A single kaged instance (Kubernetes deployment) is being shared by two people doing operator-level work. Today they share one login and one identity.
created_bycolumns exist on every relevant table, but every row says the same name. The need is attribution, not isolation — isolation is a later ACL concern and explicitly out of scope here.A mid-tier principal. One of those people should see the operator interface but only assigned projects — no system settings, no provider credentials/LLM keys, no project creation. Guests (issue-filing via
/g/) are too little; operator is too much.Login UX. Loopback launch-URLs and sidecar deployments are operator-grade ergonomics. Guests must remember
/g/. Nobody has a name or an avatar. A hosted (but self-hostable, opt-in) shared SSO relay can give every principal a one-click "Sign in with Google/GitHub" path without kaged owning an OAuth implementation per deployment.The guest/operator table fork is about to calcify. The proposed
operatorstable was column-for-column thegueststable. Duplicating sessions, invites, reaping, and status lifecycles across two identical schemas — and stamping two id namespaces into permanentcreated_byaudit history — is the expensive path.
The manifesto constraints that shaped every call below:
- kaged does not own auth flows (ADR-0007). It may verify a signature; it may not implement OIDC.
- No phone-home. Any runtime dependency on
kaged.devinfrastructure must be optional and eliminable (key pinning). - Operator is the principal. Trust statements are explicit, opt-in, and written down — including the trust statement about who runs the SSO relay.
- Opt-in everything. Shared SSO disabled by default. Open registration disabled by default.
2. Decision
kaged unifies operators, members, and guests into a single
userstable with a three-tierrole(operator>member>guest), a single storage-backed user-session mechanism, and route-scope authorization annotations with default-deny. A new opt-insharedssoauthentication bootstrap verifies short-lived ES256 JWTs minted by a stateless, self-hostable SSO relay (default instance:sso.kaged.dev); the issuer is operator-configurable and the verification key is pinnable so the daemon never needs to contact kaged.dev. Unknown subjects are provisioned TOFU-style aspendingrows (whenuser_creation = "enabled") and must be activated — and optionally elevated — by an existing operator through already-trusted auth. The first authorization primitive lands: every API route is classifiedsystem/project/guest-realm/account/public, unannotated routes fail closed assystem.
The decision decomposes into six load-bearing parts:
- SSO relay contract (§3) — stateless token relay; what it signs, how tokens are delivered, what the daemon verifies.
- Unified
userstable (§4) — schema, credentials model, TOFU provisioning, activation lifecycle. - Single user-session mechanism (§5) — one session table, one cookie, role resolved at the gate.
- Role model (§6) — operator / member / guest semantics; what
membermay and may not do. - Route-scope authorization (§7) — annotation taxonomy, default-deny, project resolution, list filtering.
- ADR-0007 amendment (§9) — the daemon now verifies token signatures; the boundary of that reversal.
3. The shared SSO relay (@kaged/sso)
3.1 What it is
A stateless HTTP service that performs the OAuth dance with upstream identity providers (Google, GitHub, …) and converts the result into a short-lived signed JWT that any kaged daemon configured to trust the issuer can verify locally. It is published as a self-hostable single-purpose service in the kaged monorepo (packages/sso, @kaged/sso). Anthropic-of-this-metaphor — i.e. the kaged project — runs the default public instance at https://sso.kaged.dev; operators may run their own and point the daemon at it.
The trust statement, stated plainly (this goes in user-facing docs verbatim):
Whoever holds the relay's private signing key can mint a valid identity assertion for any email/subject, for every daemon configured to trust that key, until that daemon's operator removes the trust. Publishing the relay's source code does not prove what runs at any given URL. If that trust is unacceptable, run your own relay (one container, no database) or pin a key you control — or don't enable sharedsso at all. It is disabled by default.
3.2 Endpoints
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /providers |
public, CORS * (no credentials) |
JSON array of available providers: [{ "provider": "google", "label": "Google", "icon": "/icons/google.svg", "path": "/google/login" }] |
| GET | /.well-known/jwks.json |
public, CORS * |
JWKS document; every key carries a kid. Replaces any bespoke single-key endpoint. |
| GET | /{provider}/login?return={url} |
public | Begins the OAuth flow with the upstream provider. |
| GET | /{provider}/callback |
provider redirect | Completes the flow; mints the JWT; redirects to the return URL. |
There is no /jwt endpoint and no cross-origin-readable token cookie. (Rejected — see §11 Alternative E.)
3.3 Flow and token delivery
- UI sends the user to
{issuer}/{provider}/login?return={return_url}. - Relay validates
return_urlshape: must be absolute, schemehttps(orhttponly when host islocalhost/127.0.0.1), no fragment. The relay cannot allowlist return hosts (self-hosted daemons are unknowable to a stateless service); this is an accepted open redirect, mitigated by never placing the token in a query parameter and by the daemon-side allowlist being the real gate. - Relay encodes
{return_url, provider, nonce, iat}into a signedstateparameter (HMAC over the relay's signing key material) and redirects to the upstream provider with PKCE where supported. - On callback, relay verifies
statesignature and freshness (≤ 10 min), exchanges the code, and reads{sub, name, email, email_verified, picture}from the provider. - If
email_verifiedis nottrue, the relay refuses to mint a token. Hard rule, no config. - Relay mints the JWT (§3.4) and redirects to
{return_url}#kaged_token={jwt}— token in the URL fragment, never a query parameter. Fragments do not reach server logs, proxies, orRefererheaders. - Relay MAY set its own
HttpOnlysession cookie scoped to its own host only, used solely to skip the upstream provider hop on the next visit (silent re-auth). This cookie is never readable cross-origin and is never required for the protocol to work. - The UI callback route reads
location.hash, immediately callshistory.replaceStateto scrub the fragment, and POSTs the token to the daemon (§5.3).
3.4 Token format (the contract)
- Algorithm: ES256 (P-256).
kidheader REQUIRED. No other algorithms accepted — daemon rejects anything else includingnone(obviously) and RS256 (explicitly, to keep the verify path single-shaped). - Claims:
| Claim | Required | Value |
|---|---|---|
iss |
yes | The relay's canonical base URL, e.g. https://sso.kaged.dev. |
aud |
yes | The fixed string "kaged". A stateless relay cannot know daemon origins; the audience scopes the token to kaged daemons as a class, not an instance. Consequence (recorded tradeoff): a token is valid at any daemon trusting this issuer where the subject is approved. Mitigated by short exp and by approval being per-daemon. |
sub |
yes | Provider-prefixed stable subject: google_113942…, github_8842…. The relay composes the prefix; the daemon treats sub as opaque. |
iat |
yes | Epoch seconds. |
exp |
yes | iat + 600 maximum (10 minutes). The token's only job is to bootstrap a daemon session; the daemon owns longevity from there. |
name |
yes | Display name from provider. |
email |
yes | Verified email from provider. |
email_verified |
yes | Must be literal true. |
picture |
no | Avatar URL from provider, if any. |
- Daemon verification order (exact, fail-closed at every step): parse header → require
alg == "ES256"andkidpresent → resolve key (pinned key if configured, else JWKS cache, else JWKS fetch) → verify signature →issexact-match against configured issuer →aud == "kaged"→iat/expvalid with ±60 s clock skew →email_verified === true→ required claims present and non-empty. Any failure → 401unauthenticated,details.reason: "invalid_token". The response never says which step failed.
3.5 Statelessness, privacy, and the zero-contact option
- The relay has no database. State lives in the signed
stateparam and (optionally) the relay-host cookie. - Stateless is not logless. The default public instance observes login events (subject, timestamp, return host) and JWKS fetches reveal daemon IPs. The public instance's published policy: access logs retained ≤ 7 days, no analytics, no third-party log shipping. This policy ships in the relay's README and on the relay's landing page.
- Zero-contact option: an operator who pins
public_keyin daemon config (§5.1) eliminates all daemon→issuer traffic. The browser still visits the issuer to log in (inherent), but the daemon never does. Rotation under pinning is manual by design. - The daemon NEVER fetches JWKS in the background — only during an auth attempt, and only when no pinned key exists and the cache is stale (TTL 15 min).
4. The unified users table
4.1 Why one table
The proposed operators table was the guests table plus an avatar column. Two identical schemas means duplicated sessions, invites, status lifecycles, reaping, and every future column — and two permanent id namespaces in created_by. One table with a role column also dissolves the TOFU classification problem: an unknown subject can be provisioned without knowing what they will become; the activating operator assigns role at activation.
guest stops being a species and becomes the floor of a role hierarchy.
4.2 Schema (final DDL — this is the contract)
CREATE TABLE IF NOT EXISTS users (
user_id TEXT PRIMARY KEY, -- ULID, daemon-generated. NEVER the sub.
handle TEXT UNIQUE NOT NULL, -- url-safe slug; see 4.4
display_name TEXT, -- precedence: user-edited > token name > handle
email TEXT, -- from token or operator entry; display only
avatar_path TEXT, -- relative path under {data_dir}/avatars/; see 4.5
provider_sub TEXT, -- provider-prefixed sub; presence ⇒ SSO credential
password_hash TEXT, -- presence ⇒ password credential
role TEXT NOT NULL DEFAULT 'guest'
CHECK (role IN ('operator', 'member', 'guest')),
status TEXT NOT NULL
CHECK (status IN ('pending', 'active', 'disabled')),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_provider_sub
ON users(provider_sub) WHERE provider_sub IS NOT NULL;
CREATE TABLE IF NOT EXISTS user_invites (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS user_sessions (
session_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
last_active_at INTEGER NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS project_user_grants (
project_id TEXT NOT NULL,
user_id TEXT NOT NULL,
permission_set TEXT NOT NULL,
notes TEXT,
granted_at INTEGER NOT NULL,
granted_by TEXT NOT NULL,
last_modified_at INTEGER NOT NULL,
PRIMARY KEY (project_id, user_id),
FOREIGN KEY (user_id) REFERENCES users(user_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_user_invites_user ON user_invites(user_id);
CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_project_user_grants_user ON project_user_grants(user_id);
CREATE INDEX IF NOT EXISTS idx_users_status ON users(status, created_at);
4.3 Credentials are columns, not types
A user's available login methods are readable from the row:
provider_sub IS NOT NULL⇒ SSO login works for this user.password_hash IS NOT NULL⇒ password login works (existing guest invite flow).- Both set is legal (a member with SSO plus a password fallback) — no migration needed later.
- Both NULL is legal only while
status = 'pending'with an outstanding invite.
The primary key is an opaque ULID, never the sub. The sub is a credential, not an identity: welding it into the PK — which is permanently stamped into created_by across every table — breaks the moment a second credential, second issuer, or provider sub-weirdness appears.
4.4 Handle derivation
- Default: slugified local-part of the email (
bob.smith+kaged@gmail.com→bob-smith-kaged): lowercase,[a-z0-9-]only, runs of other chars collapse to single-, trim leading/trailing-, max 32 chars. - Collision: append
-2,-3, … (first free integer). - No email available (operator-created password user): operator supplies the handle, as the guest flow does today.
- User-editable afterwards via the account surface (§8.3), uniqueness enforced; handles are display/mention keys — no login path uses them except the existing guest password login, which continues to accept handle as the username.
4.5 Avatars: fetch, never hotlink
If the token carries picture, the daemon fetches it once at row creation (timeout 5 s, max 1 MiB, content-type must be image/png|jpeg|webp|gif), stores it at {data_dir}/avatars/{user_id}.{ext}, and records avatar_path. The UI loads avatars only from GET /api/v1/users/:uid/avatar (scope: any active user). Rendering the provider's CDN URL directly would ping Google/GitHub from every viewer's browser on every page load — telemetry-shaped exhaust, forbidden. Fetch failure is non-fatal: the row is created with avatar_path = NULL and the UI falls back to initials. Users may upload a replacement via the account surface.
4.6 TOFU provisioning and activation lifecycle
unknown sub arrives via POST /api/v1/auth/sso
├─ user_creation = "disabled" → 403 forbidden, details.reason: "user_creation_disabled".
│ NO row is created. No pending-row side channel.
└─ user_creation = "enabled" → create row:
role = 'guest' (the floor — entry point is irrelevant to the row),
status = 'pending',
handle/display_name/email/avatar from token (per 4.4/4.5),
provider_sub = token sub.
→ 403 forbidden, details.reason: "user_pending".
→ UI shows "awaiting activation" page in whichever realm they entered from.
known sub, status = 'pending' → 403 "user_pending" (no row changes).
known sub, status = 'disabled' → 403 "user_disabled".
known sub, status = 'active' → mint user session (§5.2).
- Activation happens through already-trusted auth only: an active operator session, or the ambient loopback/sidecar identity.
PATCH /api/v1/users/:uidsetsstatus = 'active'and optionallyrole. There is no auto-trust of the first SSO user. The bootstrap chain is: ambient operator (launch URL / sidecar) activates and elevates the first SSO operator; everything after can be done by that operator from the UI. - Pending reaping: rows with
status = 'pending'ANDprovider_sub IS NOT NULLAND no invite token, older thanauth.sharedsso.pending_ttl_days(default 7), are deleted by the existing daily reaper that already handles invite tokens. (Pending rows created via the invite flow are governed by their invite TTL, unchanged.) user_creationis a toggle precisely so the operator can open registration, add the people they want, and close it again. Default:"disabled".
4.7 Display-name precedence
display_name set by the user (or operator) wins over the token's name claim, always. The token's name populates the field at creation only; subsequent logins never overwrite it. The operator owns labels; the provider supplies defaults.
5. Single user-session mechanism
5.1 Daemon configuration (local config — auth is a deployment concern per ADR-0011)
# local.toml
[auth.sharedsso]
enabled = true # default: false
issuer = "https://sso.kaged.dev" # no default; required when enabled
public_key = """-----BEGIN PUBLIC KEY-----
...PEM (P-256)...
-----END PUBLIC KEY-----""" # optional; presence ⇒ daemon NEVER fetches JWKS
user_creation = "disabled" # "enabled" | "disabled"; default "disabled"
pending_ttl_days = 7 # reap window for TOFU pending rows
Env-var equivalents follow the existing convention: KAGED_AUTH_SHAREDSSO_ENABLED, KAGED_AUTH_SHAREDSSO_ISSUER, KAGED_AUTH_SHAREDSSO_USER_CREATION, etc.
sharedsso is a session bootstrap, orthogonal to auth.mode. It can be enabled alongside loopback or sidecar. (insecure interaction: §5.5.)
5.2 One session table, one cookie
user_sessionsreplacesguest_sessions; SSO-bootstrapped sessions and password-bootstrapped (guest invite) sessions are rows in the same table.- Cookie:
kaged_user_session—HttpOnly,SameSite=Lax,Path=/,Securewhen the daemon believes it is served over TLS (existing heuristic). Replaceskaged_guest_session. - TTL: same as current guest sessions; sliding
last_active_at, unchanged semantics. - The ambient operator mechanisms are untouched:
kaged_session(loopback nonce) and the sidecar header contract continue to exist and continue to resolve to the ambient operator identity. The ambient identities live outside theuserstable; they are bootstrap/break-glass authorities. - A documented property is deliberately removed: "a browser holding both cookies is two distinct principals" (guests.md). With unified identity, an operator viewing the guest surface is legitimately themselves (§6). A view-as/impersonation affordance is future work, noted, not built.
- All sessions issued under the old
kaged_guest_sessioncookie are invalidated at migration (the table is rebuilt; §10 Phase 1). Guests re-login once. Acceptable: sessions are ephemeral by design.
5.3 Auth endpoints (new/changed)
| Method | Path | Scope | Behavior |
|---|---|---|---|
| GET | /api/v1/auth/methods |
public |
{ "methods": ["loopback"|"sidecar", "password", "sharedsso"?], "sharedsso": { "issuer": "...", "providers_url": "{issuer}/providers" } | null }. The UI's 401 handler calls this to render login options — this replaces the old idea of embedding methods in the 401 body. |
| POST | /api/v1/auth/sso |
public |
Body { "token": "<jwt>" }. Runs §3.4 verification, then §4.6 lifecycle. On success: creates user_sessions row, sets kaged_user_session + CSRF cookies, returns { "ok": true, "user": { user_id, handle, display_name, role }, "csrf_token": "..." }. CSRF-exempt (pre-auth; no ambient authority is exercised) but REQUIRES Content-Type: application/json — form posts cannot send that cross-origin without a CORS preflight, which closes the login-CSRF hole. Returns 404 when auth.sharedsso.enabled = false (details.reason: "sso_disabled"). |
| POST | /api/v1/auth/logout |
account |
Deletes the caller's user_sessions row, clears cookie. (Generalizes /api/v1/g/logout; the /g/ alias remains for one release.) |
/api/v1/g/login (password), /api/v1/g/setup, /api/v1/g/setup/validate are unchanged in behavior; they now read/write users/user_invites/user_sessions and set the new cookie name.
5.4 Gate resolution order (exact)
For each request the auth gate resolves, in order, first match wins:
- Sidecar headers present and valid (mode
sidecar) → ambient operator. kaged_sessionloopback nonce valid (modeloopback) → ambient operator.kaged_user_sessionvalid → load user row → requirestatus = 'active'→ principal is{user_id, handle, display_name, role}.- Mode
insecure→ synthesized ambient operator (§5.5). - Otherwise → 401.
Past the gate, every code path sees one identity shape (the synthesized X-Kaged-User-* header model, now extended with role). Mode- and credential-awareness dies at the gate — the ADR-0007 invariant is preserved.
5.5 --insecure interaction (one careful rule)
insecure waives the ambient operator check only: a request with no user session resolves to the synthesized operator identity. A request carrying a valid kaged_user_session still resolves as that user with that user's role, and user credentials (passwords, SSO tokens) are still verified at login. Insecure means "I vouch for whoever reaches this port as me," not "everyone is everyone." This generalizes the existing guests.md rule ("guest auth is still strictly enforced under --insecure") and replaces it.
6. The role model
operator |
member |
guest |
|
|---|---|---|---|
| System settings, providers, LLM keys, plugins, daemon config | ✅ | ❌ | ❌ |
| Add/load/delete projects | ✅ | ❌ | ❌ |
| User administration, grants, activation | ✅ | ❌ | ❌ |
| Global logs, global audit, spend ledger | ✅ | ❌ | ❌ |
| Operator UI shell | ✅ all projects | ✅ granted projects only | ❌ |
| Sessions/runs/checkpoints/issues/workflows in granted projects | ✅ (all projects, grant bypass) | ✅ | per guest permission_set, /g/ surface |
/g/ realm |
✅ | ✅ | ✅ (granted projects) |
| Account surface (own profile) | ✅ | ✅ | ✅ |
- Roles are strictly ordered:
operator ⊇ member ⊇ guest. Higher roles can do everything lower roles can; an operator visiting/g/is themselves, not a second principal. memberexists for exactly one reason in v0: operator-interface access without system access. What the member tier explicitly does NOT provide: isolation between operators (two operators can still read/resume/delete each other's sessions —created_byis attribution, not a security boundary; ACL is a later ADR), fine-grained per-project permissions (one coarse set, below), or any change to subagent/sandbox semantics.permission_setpunt, stated explicitly: for members, exactly one value exists —"member"— meaning full project access on the operator surface. Guest permission sets are unchanged. The fine-grained matrix (can-edit-DSL, can-resume, can-close-issues, …) is deferred until usage data exists; it will slot into the grant check without touching the gate architecture.users.rolerow for ambient identities: none. Ambient loopback/sidecar identities remain outside the table.created_byhistory therefore permanently contains two namespaces — ambient usernames (e.g.ashley) and table ULIDs (01HX…). This is accepted and documented so future readers don't think it's a bug. The UI's name-resolution helper (§8.4) renders both.
7. Route-scope authorization (the first authz primitive)
Until now authorization was implicit in which cookie you held — the guest cookie physically couldn't reach /api/v1/*. With one principal type, authorization becomes explicit. This is the seed the per-project ACL grows from.
7.1 The annotation
RouteDef in packages/daemon/src/api/routes.ts gains a REQUIRED field:
access:
| "system" // role === 'operator'. Full stop.
| "project" // operator bypasses; member requires project_user_grants row
// (permission_set = 'member') on the RESOLVED project. Guests: 403.
| "guest-realm" // any active user; project-scoped entries within the /g/ surface
// additionally require a grant on the resolved project
// (operator bypasses, member's 'member' grant qualifies,
// guest's grant per its permission_set).
| "account" // any active user (or ambient operator); operates on SELF only.
| "public"; // no auth (healthz, launch, auth/methods, auth/sso, g/login, g/setup*).
7.2 Default-deny — the load-bearing convention
A route without an access annotation is treated as system. Enforced two ways:
- The TypeScript type makes
accessrequired (compile-time). - The gate, on receiving a route object lacking
accessat runtime (drift, codegen, future dynamic routes), appliessystem. A startup self-check logs every route and its resolved scope; any route that fell through to the default logs atwarn.
A forgotten annotation can only ever over-restrict. New endpoints fail closed.
7.3 Project resolution registry
project-scoped routes whose path lacks :id as a project (sessions, runs, checkpoints, issues by id, workflow invocations, task instances, todos) must resolve the owning project in the gate, before the handler runs. A single registry maps path shape → resolver:
// gate-side, exhaustive; adding a project-scoped route without a resolver entry
// is a startup error, not a silent pass
const PROJECT_RESOLVERS: Record<string, (params, storage) => Promise<string | null>> = {
"/api/v1/sessions/:id/*": (p, s) => s.getSession(p.id)?.projectId,
"/api/v1/projects/:slug/*": (p) => p.id,
"/api/v1/issues/:id/*": (p, s) => s.getIssue(p.id)?.projectId,
"/api/v1/workflow-invocations/:id/*": (p, s) => s.getInvocation(p.id)?.projectId,
"/api/v1/tasks/:id/*": (p, s) => s.getTaskInstance(p.id)?.projectId,
// ...exhaustive list lives in the spec; this is the mechanism
};
Resolver returns null (resource not found) → 404 before any grant logic, preserving the existing "404 whether it doesn't exist or you can't see it" property.
Streams are routes. SSE and WebSocket endpoints (session streams, project log streams) carry access annotations and pass the same gate at connection time. A member may attach to a session stream in a granted project; may not attach to a project-log stream of an ungranted project; may never attach to global log streams (system).
7.4 Filtering vs gating
Endpoints that list across projects are filtered, not gated:
GET /api/v1/projects→ operators: all; members: only projects with a grant; (guests use/api/v1/g/projects, unchanged).- Any future cross-project listing follows the same rule. A member must never learn that an ungranted project exists — not its id, not its label, not its count.
7.5 Classification of the existing surface (summary — the exhaustive per-route table lands in http-api.md, §10 Phase 0)
system: everything under/api/v1/local/*(aliases, providers, provider OAuth login/status/logout — this is where LLM keys live, preferences, model overrides, spend limits and the spend ledger — the cost ledger is provider-global and leaks cross-project activity),/api/v1/projects/load,DELETE /api/v1/projects/:slug, all/api/v1/users*admin, all grant management (/api/v1/projects/:slug/users*— grant admin is operator-only in v0), global/api/v1/logs*, global/api/v1/audit*, plugin management, daemon settings.project:GET /api/v1/projects/:slugand all project sub-resources (sessions, messages, runs, checkpoints, prompts, files, status, reload, dsl, issues, todos, links, workflows, tasks, terminals, compactions, project-scoped log/audit streams).guest-realm: everything under/api/v1/g/*except the public setup/login endpoints.account:/api/v1/me,/api/v1/account*(§8.3),/api/v1/auth/logout,GET /api/v1/users/:uid/avatar(any active user may render any avatar — needed to show names everywhere).public:/healthz,/readyz,/api/versions,/api/v1/launch,/api/v1/auth/methods,/api/v1/auth/sso,/api/v1/g/login,/api/v1/g/setup,/api/v1/g/setup/validate.
GET /api/v1/me gains role and the user's profile fields in its response; for ambient identities role is "operator" and user_id is the ambient username (unchanged shape otherwise).
8. UI consequences
8.1 Post-auth routing by role
- After any successful auth, the UI calls
/api/v1/meand routes:operator→ shell at/;member→ shell at/(project list = granted projects; empty-state home when no grants);guest→/g. - A
guestnavigating to any non-/groute is redirected to/g(their home), not shown a 403 wall. Amembernavigating to/system/*is redirected to/likewise. Fail toward the realm they belong to, never toward a wall. - The SSO callback route (
/auth/sso/callback) reads the fragment, scrubs it, POSTs to/api/v1/auth/sso, then applies the same role routing. A stale/cross-realmreturndeep link is overridden by role routing after auth resolves. - On 401, the UI calls
GET /api/v1/auth/methodsand renders the available options: launch-URL hint (loopback), password form (/g/loginrealm), and/or provider buttons fetched from{issuer}/providers. The/g/login page gains the same provider buttons. Guests never need to know/g/exists to enter — only their bookmarks live there.
8.2 Shell chrome by role (amends ADR-0035 specs)
- The 影 system tab is the system scope — for
memberit is replaced by an account tab (same slot, person glyph or the user's avatar) containing only the account surface (§8.3). No system routes are reachable; the router guards mirror the API gate (defense in depth — the API gate is the real boundary). - Operators get the account surface too, as a section within the existing system scope (
/system/account). - Pending-user administration lives at
/system/users(renames/system/guests): list with name/avatar/email/provider badge/status; activate + role-assign in one action on pending rows; disable/reinvite/unlock as today.
8.3 Account surface (all roles)
GET/PATCH /api/v1/account — display_name, handle (uniqueness errors surfaced), avatar upload (same constraints as §4.5 fetch); POST /api/v1/account/password generalizes the guest change-password endpoint (current-password verification when one exists; setting a first password on an SSO-only account requires none but requires an active session). All account-scoped.
8.4 Names and avatars everywhere
A single UI helper resolves a created_by value to {display_name, handle, avatar_url}: ULIDs resolve via a batched GET /api/v1/users/lookup?ids=… (account scope; returns only {user_id, handle, display_name, has_avatar} — no emails, no roles, no subs); non-ULID values (ambient identities) render as-is with an initials avatar. Sessions list, session header, issue timeline, audit rows, checkpoint rows adopt it.
9. Amendment to ADR-0007 (recorded here, applied there in Phase 7)
ADR-0007 states the daemon validates zero JWTs. This ADR amends that decision, narrowly: when auth.sharedsso.enabled = true, the daemon verifies ES256 token signatures (one JWKS fetch + one signature check via jose, ~50 LOC) at the session-bootstrap endpoint only. The daemon still implements zero OAuth/OIDC flows — no redirects, no token exchange, no refresh, no provider knowledge. ADR-0007's actual fear was implementing OIDC; verifying a signature against a configured issuer is not that. The sidecar mode is untouched and remains the recommendation for tunneled single-operator deployments that don't need in-daemon identity.
Why the sidecar can't absorb this instead: a sidecar authenticates but cannot mint kaged's user sessions, distinguish principal classes, or drive the TOFU lifecycle — the guest/member requirement forces identity into the daemon regardless. (See §11 Alternative B.)
10. Implementation plan
Doc-first per ADR-0003: each phase begins with its spec amendments, then tests, then code. Phases land independently and in order — each leaves the system shippable. Every checklist item is a discrete commit-sized unit. Items marked ⛔ invariant are properties that must hold at the end of the phase and must have a test asserting them.
Phase 0 — Spec amendments (no code)
-
docs/specs/guests.md→ renamed/rewritten asdocs/specs/users.md: unified table, roles, credentials-as-columns, TOFU lifecycle, activation, reaping,--insecurerule (§5.5), removal of the dual-cookie two-principals property (with the view-as note). -
docs/specs/http-api.md: auth section rewritten — gate resolution order (§5.4), new cookie name,accesstaxonomy with default-deny, the exhaustive per-route classification table (every existing route gets an explicitaccessvalue; this table is the implementation contract for Phase 3), new endpoints (§5.3, §8.3, §8.4 lookup),meresponse change, errordetails.reasonvalues:sso_disabled,user_creation_disabled,user_pending,user_disabled,invalid_token. -
docs/specs/local-config.md(ordaemon.mdconfig section):[auth.sharedsso]block (§5.1) with env-var names. - New
docs/specs/sso-relay.md: endpoints, state-param signing, return-URL validation, fragment delivery, token claims table (§3.4), JWKS/kid/rotation, the trust statement and log-retention policy verbatim. -
docs/specs/ui/README.md+ ADR-0035 shell specs: role routing (§8.1), 影/account tab swap (§8.2),/system/guests→/system/users, account surface, login surfaces, name-resolution helper. - ADR index
docs/adr/README.md: add this ADR. - Gate: specs reviewed and merged before Phase 1 begins. Claude Code consumes specs, not this ADR, for implementation detail.
Phase 1 — Schema migration (v14 → v15)
SQLite cannot ALTER TABLE … ADD CHECK; tables with changed constraints are rebuilt. Exact procedure, single transaction, PRAGMA foreign_keys = OFF for its duration:
- 1.
CREATE TABLE users (…)per §4.2 DDL exactly. - 2.
INSERT INTO users (user_id, handle, display_name, email, avatar_path, provider_sub, password_hash, role, status, created_at, updated_at) SELECT user_id, handle, display_name, NULL, NULL, NULL, password_hash, 'guest', status, created_at, updated_at FROM guests; - 3.
CREATE TABLE user_invites (…);INSERT INTO user_invites SELECT token, user_id, expires_at, created_at FROM guest_invites; - 4.
CREATE TABLE user_sessions (…). Do not copyguest_sessions— all live guest sessions are invalidated; guests re-login once. - 5.
CREATE TABLE project_user_grants (…);INSERT INTO project_user_grants SELECT project_id, user_id, permission_set, notes, granted_at, granted_by, last_modified_at FROM project_guest_grants; - 6.
DROP TABLE guest_sessions; DROP TABLE guest_invites; DROP TABLE project_guest_grants; DROP TABLE guests;(children before parent). - 7. Create all indexes per §4.2. Bump
SCHEMA_VERSION = 15.PRAGMA foreign_keys = ON;PRAGMA foreign_key_checkmust return empty. - Postgres path: same logical steps via standard
ALTER/RENAME(Postgres supports adding CHECKs); migration code branches on adapter as existing migrations do. - Storage adapter: rename guest methods → user methods (
getGuest→getUser, etc.); addgetUserByProviderSub,lookupUsers(ids),listPendingUsers,reapPendingSsoUsers(ttlMs). - Tests: migration applies cleanly on a v14 fixture DB seeded with guests/invites/grants/sessions; row counts and field values verified;
foreign_key_checkempty; sessions table empty post-migration. - ⛔ invariant: every migrated row has
role = 'guest',statuspreserved,user_idpreserved (so historicalcreated_bystamps keep resolving).
Phase 2 — Identity & session layer (daemon)
-
resolveUserSession(generalizesresolveGuestSession): new cookie name, returns{user_id, handle, display_name, role}; slidinglast_active_atunchanged. - Gate resolution order implemented exactly per §5.4, including the
--insecurerule (§5.5). - Account endpoints (§8.3) +
GET /api/v1/users/:uid/avatar+ batched lookup (§8.4); avatar storage at{data_dir}/avatars/with the §4.5 fetch constraints (also used by Phase 4 at provisioning). -
/api/v1/g/*handlers rewired to user-storage methods and new cookie; behavior otherwise byte-identical (existing guest tests must pass unmodified except cookie name). - User admin endpoints:
/api/v1/guests*→/api/v1/users*(list gainsrole,provider_subpresence badge, pending filter;PATCHactivates/elevates/disables). - Tests: session resolution per role; insecure-with-user-session resolves as the user, not the ambient operator; password login/invite/reinvite/unlock regression suite green against
userstables. - ⛔ invariant: no handler below the gate branches on how the principal authenticated.
Phase 3 — Route-scope gate (the security phase — land before any SSO code)
- Add required
accessfield toRouteDef; annotate every route per the Phase-0 classification table in one commit (compiler enforces completeness). - Gate logic:
system/project/guest-realm/account/publicsemantics per §7.1; runtime default-deny fallback + startup scope self-check log (§7.2). -
PROJECT_RESOLVERSregistry (§7.3), exhaustive; startup error if aproject-scoped route lacks a resolver; resolver-null → 404 before grant logic. - SSE/WS endpoints annotated and gated at connect time (§7.3).
- List filtering:
GET /api/v1/projectsby grants for members (§7.4). - Tests — the matrix is the deliverable: for each
accessclass × each role (operator, member-with-grant, member-without-grant, guest-with-grant, guest-without-grant, unauthenticated), assert allow/deny/404/filter. Explicit named cases for the leak vectors: spend ledger (member → 403), provider OAuth status (member → 403), project list (member sees only granted), project log stream of ungranted project (member → 403/404), session stream of granted project (member → allowed), global log stream (member → 403). - ⛔ invariant: zero unannotated routes at startup (self-check asserts).
- ⛔ invariant: a member can never observe the existence of an ungranted project via any endpoint, list, or stream.
Phase 4 — sharedsso verification path (daemon)
- Config parsing + validation (§5.1):
enabledwithoutissueris a startup error;public_keymust parse as a P-256 SPKI PEM. - JWKS client: fetch only on auth attempt, only when no pinned key, cache TTL 15 min,
kid-indexed; network failure with cold cache → 401invalid_token(login fails closed; nothing retries in the background). - Token verification exactly per §3.4 order, via
jose; table-driven tests with one forged/expired/wrong-alg/wrong-iss/wrong-aud/unverified-email/missing-kid fixture per step. -
GET /api/v1/auth/methods,POST /api/v1/auth/sso(incl. theContent-Type: application/jsonrequirement and CSRF exemption rationale in a code comment),POST /api/v1/auth/logoutper §5.3. - TOFU lifecycle per §4.6 state table, including the no-row-on-disabled rule, avatar fetch (non-fatal), handle derivation per §4.4 with collision suffixing.
- Pending reaper extension (
pending_ttl_days). - Tests: full §4.6 state table as cases;
user_creationtoggle flips behavior without restart (config hot-reload path); replay of a still-valid token after logout creates a new session (accepted, documented tradeoff per §3.4audrow); pinned-key mode performs zero outbound requests (assert with a network-recording test double). - ⛔ invariant: with
public_keypinned, the daemon makes no request to the issuer, ever. - ⛔ invariant:
user_creation = "disabled"+ unknown sub writes nothing to storage.
Phase 5 — The relay (packages/sso, @kaged/sso)
- Bun HTTP service; providers: Google + GitHub in v0; provider modules are config-shaped (endpoints/scopes/client creds), same philosophy as the ADR-0028 driver catalog.
- Signed
state(HMAC, 10-min freshness), PKCE where supported, return-URL validation per §3.3 step 2,email_verifiedhard rule, fragment delivery#kaged_token=. - ES256 keypair management: key file or env;
kid= key thumbprint; JWKS endpoint serves current + previous key (rotation overlap). - Optional relay-host re-auth cookie (
HttpOnly,SameSite=Lax,Secure, host-scoped — not domain-wide). -
/providers+ static icons; CORS*without credentials on/providersand JWKS only. - No database, no analytics; access-log retention note in README; landing page states the policy.
- Container image +
examples/deployment/sso-relay/compose example; docs page for self-hosting and for pinning the public key. - Tests: state tamper → reject; stale state → reject; unverified email → no token; token claims match §3.4 exactly (golden-file test shared with the daemon's verification fixtures — same fixture set on both sides so the contract cannot drift silently).
Phase 6 — UI
-
/auth/sso/callbackroute: read hash →history.replaceStatescrub → POST → role routing (§8.1). Test: token never appears in any subsequentlocationor fetch URL. - 401 handler →
auth/methods→ login surface with provider buttons (operator realm +/g/login); pending/disabled states render the §4.6 reason messages. - Role routing + redirect-toward-realm rules (§8.1); router guards mirroring API scopes (§8.2).
- 影 ↔ account tab swap for members;
/system/usersadmin screen (activate + role-assign single action);/system/accountfor operators. - Account surface (§8.3); avatar upload.
- Name-resolution helper + adoption in sessions list, session header, issue timeline, audit rows, checkpoints (§8.4).
- Tests: role-routing unit tests (extend
auth.tshelpers); member shell renders no system affordances; guest deep-link into operator route lands on/g.
Phase 7 — Docs, examples, ADR housekeeping
- ADR-0007: add amendment section per §9.
-
docs/02-architecture.md: auth diagram gains the sharedsso bootstrap path. - Operator walkthroughs: "two operators + a member on one instance", "self-hosting the relay", "pinning the key".
-
STATUS.mdentry; ADR index row flips to Accepted on merge.
Explicitly out of scope (later ADRs)
- Operator↔operator isolation / fine-grained per-project permission matrix.
- View-as/impersonation.
- Multiple SSO issuers per daemon; refresh-token-style long-lived SSO sessions at the relay.
- Mapping ambient identities into
usersrows.
11. Alternatives considered
Alternative A — Config-file user allowlist ([[auth.sharedsso.users]] in local.toml)
The previous iteration of this design. Why tempting: zero schema work, operator-owned file, no TOFU machinery. Why rejected: no editable profiles/avatars, no status lifecycle, no reaping, awkward sub discovery, and it still required a sessions table — at which point the table should own the identity too. The DB row with operator-gated activation keeps the same control with better ergonomics.
Alternative B — oauth2-proxy with a multi-email allowlist (pure ADR-0007 sidecar)
Why tempting: zero daemon changes; the sidecar already supports email allowlists and would distinguish the two humans. Why rejected: the sidecar authenticates but cannot mint kaged guest/member sessions, distinguish principal classes, drive TOFU activation, or scope members to granted projects. The guest and member requirements force identity into the daemon regardless; once it's there, the sidecar-only path covers strictly less. Sidecar mode remains fully supported and remains the recommendation for tunneled single-operator deployments.
Alternative C — Separate operators table mirroring guests
Why tempting: guests ship today and stay untouched. Why rejected: column-identical schemas duplicate sessions/invites/lifecycles/reaping forever, fork the created_by namespace permanently, and leave the TOFU "which table does an unknown sub land in" question unanswerable. The migration is cheap now and prohibitive later.
Alternative D — sub as the users primary key
Why tempting: one less column, "the identity is the sub." Why rejected: welds identity to credential. Password fallback, second issuer, or provider sub churn would invalidate a key that is permanently stamped into audit history. Opaque ULID + provider_sub UNIQUE costs one column.
Alternative E — Relay /jwt endpoint readable via CORS-with-credentials
The original sketch. Why tempting: simple fetch from any UI. Why rejected: self-hosted daemons make origin allowlisting impossible, so the endpoint degenerates to reflecting arbitrary origins with credentials — any website the user visits could silently exfiltrate their signed identity token. Fragment delivery removes the readable-token surface entirely.
Alternative F — Long-lived SSO JWTs (skip daemon sessions)
Why tempting: truly stateless end-to-end. Why rejected: a long-lived bearer credential in a cookie, no revocation, no per-daemon session control, no last_active_at. Daemons own sessions; the JWT is a 10-minute bootstrap. This is also why the relay stays stateless — all state it would need lives in the daemon instead.
Alternative G — Full OIDC relying-party support in the daemon
Why tempting: standards-pure; point at any IdP (Keycloak, Authelia, Dex) directly. Why rejected for v0: discovery, nonce/state handling, token exchange, refresh — exactly the OIDC surface ADR-0007 refused. The relay contract is deliberately a subset (verify one token shape from one configured issuer). Nothing forecloses a future "any OIDC issuer" amendment; the verification path built here is the hard 80% of it.
12. Consequences
What this commits us to
- Maintaining the relay as a public service with the stated log-retention policy, and as a supported self-hostable package.
- The token contract (§3.4) as a compatibility surface between independently-deployed relays and daemons — golden-file fixtures shared across both packages.
- The
accessannotation on every route, forever, including streams. - The
userstable as the single principal store;created_byULIDs resolving through it. - One release of
/api/v1/g/logoutaliasing before removal.
What this forecloses
- Nothing architecturally; ACL, view-as, multi-issuer, and full OIDC all layer on without gate rework.
What becomes easier
- Adding principals: TOFU + one click. Adding providers: one relay config entry. Attribution: names/avatars everywhere. The future ACL: hangs off grants + scopes that now exist.
What becomes harder
- Every new endpoint requires a scope decision (mitigated: fail-closed default, compile-time requirement).
- The route gate is now genuinely security-critical code with a test matrix that must be maintained.
- Guest sessions invalidate once at migration.
Amendments
2026-06-11 — Relay is a standalone repository; token contract enforced via @kaged/sso-fixtures
Three changes to the accepted text, decided after acceptance:
1. The relay leaves the monorepo. @kaged/sso is NOT built at packages/sso. It lives in its own repository, github.com/kaged-dev/kaged-sso, with its own CI (test, container build, npm publish) and its own release cadence. Rationale: the relay is deliberately tiny, stateless, and trust-sensitive — a siloed repo keeps its audit surface small ("read this one repo before you run it"), decouples its releases from daemon releases, and avoids monorepo CI weight for a component that changes rarely.
2. Repo naming convention (binding for all future monorepo extractions). Repo kaged-<thing> ⇔ npm @kaged/<thing> ⇔ container image ghcr.io/kaged-dev/kaged-<thing> — one functional name traversing all three namespaces with zero translation. Bare names (kaged-dev/sso) are rejected: the org prefix evaporates in clones, forks, search results, and docker ps. Codenames are rejected as the base scheme: the relay in particular is named at the moment a stranger decides whether to run it, and the name must self-describe. Brand/kanji naming remains reserved for in-product identity (影 tab, Karasu), not repository navigation. Recorded in docs/conventions.md (Phase 7).
3. The §3.4 token contract is enforced by a published fixtures package. The accepted text's "golden-file test shared with the daemon's verification fixtures — same fixture set on both sides" assumed a monorepo and is replaced by:
- The
kaged-ssorepo publishes@kaged/sso-fixturesto npm: the golden token fixtures (one valid token per provider, plus one fixture per §3.4 verification-failure step: forged signature, expired, wrongalg, wrongiss, wrongaud,email_verifiedfalse, missingkid) and the test keypair that signs them. - Lockstep versioning:
@kaged/sso-fixturescarries the same version number as the relay and is published from the same release. "Which relay does fixturesx.y.zdescribe" must always answer "x.y.z". The package is never versioned or published independently. - The daemon repo consumes
@kaged/sso-fixturesas a pinned devDependency; the daemon's token-verification suite (Phase 4) runs against it. - The contract property (load-bearing): a claim or signing change in the relay cannot reach the daemon without a fixtures version bump that fails the daemon's verifier suite visibly. Drift between the two repos is structurally impossible to merge silently.
- Local development loop across the two repos:
bun link(local override only per ADR-0042; the committed lockfile reflects registry versions).
Everything else in the accepted ADR — token format, verification order, schema, roles, route scopes, phase ordering — is unchanged.
§B — Implementation deltas (apply after appending §A)
These replace the corresponding items in the accepted ADR's §10. Where an original checklist line is shown struck, implement the replacement line instead.
B.1 — Phase 4 (daemon, sharedsso verification path)
Token verification … table-driven tests with one forged/expired/wrong-alg/wrong-iss/wrong-aud/unverified-email/missing-kid fixture per step.- → Token verification exactly per §3.4 order, via
jose; table-driven tests consuming@kaged/sso-fixtures(pinned devDependency) — one fixture per verification step as enumerated in §A.3. Do not hand-write local token fixtures in the daemon repo; the daemon must fail if the published fixtures change shape. - → Add
@kaged/sso-fixturesto the daemon's devDependencies pinned to an exact version (no^/~). Version bumps are deliberate commits that run the verifier suite.
B.2 — Phase 5 (the relay) — header and scope change
- Phase title becomes: Phase 5 — The relay (
kaged-dev/kaged-sso, standalone repo). - New first item: repository scaffold — own repo, own CI (test + container build to
ghcr.io/kaged-dev/kaged-sso+ npm publish of@kaged/ssoand@kaged/sso-fixturesin lockstep), Bun service skeleton,docs/conventions.md-conformant naming throughout. Tests: … golden-file test shared with the daemon's verification fixtures — same fixture set on both sides …- → Tests: state tamper → reject; stale state → reject; unverified email → no token; token claims match §3.4 exactly via the golden fixtures.
- → New item:
@kaged/sso-fixturespackage per §A.3 — fixtures + test keypair, lockstep version, published from the relay's release workflow. The relay's own test suite consumes the same fixtures it publishes (one source of truth inside the repo; the npm package is its export). - →
examples/deployment/sso-relay/compose example in the daemon repo references the published container image by tag, not a monorepo build context. - Everything else in Phase 5 (providers, signed state, PKCE, return-URL validation, fragment delivery, JWKS with
kid+ rotation overlap, host-scoped re-auth cookie, CORS rules, no-database/no-analytics, log-retention statement, self-hosting + key-pinning docs) is unchanged — it just lives in the new repo.
B.3 — Phase 7 (docs/housekeeping)
- → New item: create
docs/conventions.md(or add to the existing conventions doc if one exists) recording the §A.2 naming rule, so future extractions don't relitigate it.
B.4 — §3.1 and Consequences (textual)
- §3.1's sentence "published as a self-hostable single-purpose service in the kaged monorepo (
packages/sso,@kaged/sso)" is superseded by §A.1. Do not edit §3.1 in place (append-only); §A is the authoritative correction. - The Consequences commitment "golden-file fixtures shared across both packages" is superseded by §A.3's pinned-devDependency mechanism.
B.5 — Out-of-scope guard (unchanged but restated for the agent)
- Do not extract anything else from the monorepo in this work. The relay is the first and only extraction under this ADR. The naming convention is recorded for the future; it does not authorize further splits.
B.6 — Done when
- §A appended to ADR-0036 under
## Amendments; ADR index untouched (status stays Accepted). -
kaged-dev/kaged-ssorepo exists with CI publishing@kaged/sso+@kaged/sso-fixturesat identical versions from one release workflow. - Daemon devDependencies pin
@kaged/sso-fixturesexactly; Phase 4 verifier suite is fixture-driven with zero locally-authored token fixtures. - A deliberate fixture change in the relay repo (test this once, manually: alter one claim in a fixture, publish a pre-release, bump the daemon pin) turns the daemon's verifier suite red. Revert after confirming.
-
docs/conventions.mdrecords the naming rule. -
packages/ssodoes not exist in the monorepo (if scaffolding had begun there, it is removed in the same PR that lands the daemon-side pin).
References
- ADR-0003 — doc-first, then TDD
- ADR-0005 — storage conventions (ULID ids, epoch-ms, CHECK-in-CREATE)
- ADR-0007 — auth via OAuth proxy sidecar (amended by §9)
- ADR-0010 — deployment modes
- ADR-0011 — operator-local concerns live in local config
- ADR-0028 — provider OAuth (a different problem: LLM credentials, not principal identity)
- ADR-0035 — shell structure this amends; precedent for ADR-with-implementation-section
docs/specs/users.md,docs/specs/http-api.md,docs/specs/sso-relay.md— amended/added in Phase 0- RFC 7517 (JWK), RFC 7519 (JWT), RFC 7636 (PKCE)
- Design discussion: SSO relay + unified identity conversation, 2026-06-11