Spec: Shared SSO Relay (@kaged/sso)

  • Status: Draft
  • Last amended: 2026-06-11 (relay extracted to a standalone repo; token contract via @kaged/sso-fixtures)
  • Constrained by: ADR-0007, ADR-0036 (incl. its 2026-06-11 amendment)
  • Implements: github.com/kaged-dev/kaged-sso (the relay service + @kaged/sso-fixtures), packages/daemon/ (the verification path)

Purpose

The relay 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 any kaged daemon configured to trust the issuer can verify locally. It is self-hostable (one container, no database). The kaged project runs the default public instance at https://sso.kaged.dev; operators may run their own and point the daemon at it.

Where it lives (ADR-0036 amendment, 2026-06-11): the relay is not in this monorepo. It is a standalone repository github.com/kaged-dev/kaged-sso (npm @kaged/sso, image ghcr.io/kaged-dev/kaged-sso) with its own CI and release cadence. The §3.4 token contract is enforced by @kaged/sso-fixtures — a package published in lockstep with the relay (same version number, same release) that the daemon consumes as a pinned exact-version devDependency. A signing/claim change in the relay cannot reach the daemon without a fixtures bump that fails the daemon's verifier suite visibly. Naming convention (binding): repo kaged-<thing> ⇔ npm @kaged/<thing> ⇔ image ghcr.io/kaged-dev/kaged-<thing> (see docs/conventions.md).

This spec is normative for:

  • The relay's HTTP endpoints and their auth/CORS posture.
  • The signed state parameter and return-URL validation.
  • Fragment-based token delivery.
  • The token format (claims, algorithm, kid) — the compatibility contract between independently-deployed relays and daemons.
  • The daemon-side verification order (fail-closed at every step).
  • JWKS/key rotation and the zero-contact (pinned-key) option.
  • The trust statement and log-retention policy (verbatim, user-facing).

It is not normative for:

  • The users table, sessions, TOFU lifecycle — that's users.md.
  • The daemon's [auth.sharedsso] config block — that's daemon.md/local-config.md.
  • The UI callback handling — that's ui/README.md.

The trust statement (user-facing, 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.

This statement ships in the relay README and on the relay's landing page.

Endpoints

Method Path Auth Purpose
GET /providers public, CORS * (no credentials) JSON array: [{ "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.
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 (ADR-0036 Alternative E). CORS * is granted only on /providers and the JWKS endpoint, and never with credentials.

Flow and token delivery

  1. UI sends the user to {issuer}/{provider}/login?return={return_url}.
  2. Relay validates return_url shape: 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, 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 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). Never readable cross-origin; never required for the protocol.
  8. The UI callback route reads location.hash, immediately calls history.replaceState to scrub the fragment, and POSTs the token to the daemon's POST /api/v1/auth/sso.

Token format (the contract)

  • Algorithm: ES256 (P-256). kid header REQUIRED. No other algorithm is accepted by the daemon — none (obviously) and RS256 (explicitly, to keep the verify path single-shaped) are rejected.
  • 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: a token is valid at any daemon trusting this issuer where the subject is approved. Mitigated by short exp and per-daemon approval.
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.
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)

  1. parse header
  2. require alg == "ES256" and kid present
  3. resolve key (pinned key if configured, else JWKS cache, else JWKS fetch)
  4. verify signature
  5. iss exact-match against configured issuer
  6. aud == "kaged"
  7. iat/exp valid with ±60 s clock skew
  8. email_verified === true
  9. required claims present and non-empty

Any failure → 401 unauthenticated, details.reason: "invalid_token". The response never says which step failed.

JWKS, key rotation, and the zero-contact option

  • The relay serves current + previous key in JWKS for rotation overlap; every key has a kid (key thumbprint).
  • Daemon fetches JWKS only during an auth attempt, only when no pinned key exists and the cache is stale (TTL 15 min), kid-indexed. The daemon NEVER fetches in the background. Network failure with a cold cache → 401 invalid_token (login fails closed; nothing retries in the background).
  • Zero-contact option: an operator who pins public_key (P-256 SPKI PEM) in daemon config eliminates all daemon→issuer traffic. The browser still visits the issuer to log in (inherent); the daemon never does. Rotation under pinning is manual by design.

Statelessness, privacy, and logs

  • The relay has no database. State lives in the signed state param and the optional relay-host cookie.
  • Stateless is not logless. The default public instance observes login events (subject, timestamp, return host); JWKS fetches reveal daemon IPs. Published policy: access logs retained ≤ 7 days, no analytics, no third-party log shipping. This ships in the relay README and on the landing page.

Provider modules

Providers are config-shaped (endpoints/scopes/client credentials), the same philosophy as the ADR-0028 driver catalog. v0 ships Google and GitHub. Adding a provider is one config entry plus a static icon.

  • Keys: the relay's ES256 keypair is provided by key file or env. kid is the key thumbprint.
  • Client credentials: per-provider client_id/client_secret from env/config.

Container and deployment

  • A container image and an examples/deployment/sso-relay/ compose example ship with the package.
  • Docs cover self-hosting and pinning the public key in a daemon.

Testing notes

  • state tamper → reject; stale state (> 10 min) → reject.
  • unverified email → no token minted.
  • token claims match the table above exactly — enforced by the @kaged/sso-fixtures package (golden token fixtures + test keypair) published in lockstep with the relay. The relay's own suite consumes the fixtures it publishes (one source of truth inside the kaged-sso repo; the npm package is its export); the daemon consumes the same published, version-pinned package as a devDependency. The contract cannot drift silently across the two repos.
  • return-URL validation: absolute https (or http+localhost) accepted; relative, fragment-bearing, or non-localhost http rejected.
  • JWKS serves current + previous key, each with a distinct kid.

Open questions

  1. Silent re-auth cookie lifetime — the relay-host cookie TTL; tune against UX.
  2. Provider catalog format — whether to share the ADR-0028 driver-catalog schema verbatim or fork a smaller one.

References