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:

  1. 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_by columns 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.

  2. 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.

  3. 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.

  4. The guest/operator table fork is about to calcify. The proposed operators table was column-for-column the guests table. Duplicating sessions, invites, reaping, and status lifecycles across two identical schemas — and stamping two id namespaces into permanent created_by audit 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.dev infrastructure 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 users table with a three-tier role (operator > member > guest), a single storage-backed user-session mechanism, and route-scope authorization annotations with default-deny. A new opt-in sharedsso authentication 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 as pending rows (when user_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 classified system / project / guest-realm / account / public, unannotated routes fail closed as system.

The decision decomposes into six load-bearing parts:

  1. SSO relay contract (§3) — stateless token relay; what it signs, how tokens are delivered, what the daemon verifies.
  2. Unified users table (§4) — schema, credentials model, TOFU provisioning, activation lifecycle.
  3. Single user-session mechanism (§5) — one session table, one cookie, role resolved at the gate.
  4. Role model (§6) — operator / member / guest semantics; what member may and may not do.
  5. Route-scope authorization (§7) — annotation taxonomy, default-deny, project resolution, list filtering.
  6. 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

  1. UI sends the user to {issuer}/{provider}/login?return={return_url}.
  2. Relay validates return_url shape: must be absolute, scheme https (or http only when host is localhost/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.
  3. Relay encodes {return_url, provider, nonce, iat} into a signed state parameter (HMAC over the relay's signing key material) and redirects to the upstream provider with PKCE where supported.
  4. On callback, relay verifies state signature and freshness (≤ 10 min), exchanges the code, and reads {sub, name, email, email_verified, picture} from the provider.
  5. If email_verified is not true, the relay refuses to mint a token. Hard rule, no config.
  6. 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, or Referer headers.
  7. Relay MAY set its own HttpOnly session 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.
  8. The UI callback route reads location.hash, immediately calls history.replaceState to scrub the fragment, and POSTs the token to the daemon (§5.3).

3.4 Token format (the contract)

  • Algorithm: ES256 (P-256). kid header REQUIRED. No other algorithms accepted — daemon rejects anything else including none (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" and kid present → resolve key (pinned key if configured, else JWKS cache, else JWKS fetch) → verify signature → iss exact-match against configured issuer → aud == "kaged"iat/exp valid with ±60 s clock skew → email_verified === true → required claims present and non-empty. Any failure → 401 unauthenticated, 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 state param 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_key in 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.combob-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/:uid sets status = 'active' and optionally role. 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' AND provider_sub IS NOT NULL AND no invite token, older than auth.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_creation is 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_sessions replaces guest_sessions; SSO-bootstrapped sessions and password-bootstrapped (guest invite) sessions are rows in the same table.
  • Cookie: kaged_user_sessionHttpOnly, SameSite=Lax, Path=/, Secure when the daemon believes it is served over TLS (existing heuristic). Replaces kaged_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 the users table; 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_session cookie 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:

  1. Sidecar headers present and valid (mode sidecar) → ambient operator.
  2. kaged_session loopback nonce valid (mode loopback) → ambient operator.
  3. kaged_user_session valid → load user row → require status = 'active' → principal is {user_id, handle, display_name, role}.
  4. Mode insecure → synthesized ambient operator (§5.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.
  • member exists 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_by is 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_set punt, 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.role row for ambient identities: none. Ambient loopback/sidecar identities remain outside the table. created_by history 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:

  1. The TypeScript type makes access required (compile-time).
  2. The gate, on receiving a route object lacking access at runtime (drift, codegen, future dynamic routes), applies system. A startup self-check logs every route and its resolved scope; any route that fell through to the default logs at warn.

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/:slug and 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/me and routes: operator → shell at /; member → shell at / (project list = granted projects; empty-state home when no grants); guest/g.
  • A guest navigating to any non-/g route is redirected to /g (their home), not shown a 403 wall. A member navigating 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-realm return deep link is overridden by role routing after auth resolves.
  • On 401, the UI calls GET /api/v1/auth/methods and renders the available options: launch-URL hint (loopback), password form (/g/login realm), 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 member it 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/accountdisplay_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 as docs/specs/users.md: unified table, roles, credentials-as-columns, TOFU lifecycle, activation, reaping, --insecure rule (§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, access taxonomy with default-deny, the exhaustive per-route classification table (every existing route gets an explicit access value; this table is the implementation contract for Phase 3), new endpoints (§5.3, §8.3, §8.4 lookup), me response change, error details.reason values: sso_disabled, user_creation_disabled, user_pending, user_disabled, invalid_token.
  • docs/specs/local-config.md (or daemon.md config 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 copy guest_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_check must 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 (getGuestgetUser, etc.); add getUserByProviderSub, 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_check empty; sessions table empty post-migration.
  • ⛔ invariant: every migrated row has role = 'guest', status preserved, user_id preserved (so historical created_by stamps keep resolving).

Phase 2 — Identity & session layer (daemon)

  • resolveUserSession (generalizes resolveGuestSession): new cookie name, returns {user_id, handle, display_name, role}; sliding last_active_at unchanged.
  • Gate resolution order implemented exactly per §5.4, including the --insecure rule (§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 gains role, provider_sub presence badge, pending filter; PATCH activates/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 users tables.
  • ⛔ 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 access field to RouteDef; annotate every route per the Phase-0 classification table in one commit (compiler enforces completeness).
  • Gate logic: system / project / guest-realm / account / public semantics per §7.1; runtime default-deny fallback + startup scope self-check log (§7.2).
  • PROJECT_RESOLVERS registry (§7.3), exhaustive; startup error if a project-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/projects by grants for members (§7.4).
  • Tests — the matrix is the deliverable: for each access class × 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): enabled without issuer is a startup error; public_key must 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 → 401 invalid_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. the Content-Type: application/json requirement and CSRF exemption rationale in a code comment), POST /api/v1/auth/logout per §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_creation toggle 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.4 aud row); pinned-key mode performs zero outbound requests (assert with a network-recording test double).
  • ⛔ invariant: with public_key pinned, 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_verified hard 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 /providers and 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/callback route: read hash → history.replaceState scrub → POST → role routing (§8.1). Test: token never appears in any subsequent location or 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/users admin screen (activate + role-assign single action); /system/account for 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.ts helpers); 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.md entry; 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 users rows.

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 access annotation on every route, forever, including streams.
  • The users table as the single principal store; created_by ULIDs resolving through it.
  • One release of /api/v1/g/logout aliasing 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-sso repo publishes @kaged/sso-fixtures to npm: the golden token fixtures (one valid token per provider, plus one fixture per §3.4 verification-failure step: forged signature, expired, wrong alg, wrong iss, wrong aud, email_verified false, missing kid) and the test keypair that signs them.
  • Lockstep versioning: @kaged/sso-fixtures carries the same version number as the relay and is published from the same release. "Which relay does fixtures x.y.z describe" must always answer "x.y.z". The package is never versioned or published independently.
  • The daemon repo consumes @kaged/sso-fixtures as 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-fixtures to 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/sso and @kaged/sso-fixtures in 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-fixtures package 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-sso repo exists with CI publishing @kaged/sso + @kaged/sso-fixtures at identical versions from one release workflow.
  • Daemon devDependencies pin @kaged/sso-fixtures exactly; 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.md records the naming rule.
  • packages/sso does 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