ADR 0007. One login: the IdP everywhere, an explicit no-auth mode, GitHub OAuth login and the dev token retired
- Status: accepted (2026-09-12); implemented 2026-09-13 in the three stacked PRs of the Rollout (#175, #176, #177); the exit criterion is met by
apps/live-host/test/keycloak-e2e.sh(16/16 against the shipped realm) - Context: an inventory of every credential the Live Host accepts (verified against
mainat c232f7c, 2026-09-12), the product decision to keep ONE login, and two months of operating ADR 0005's two-entrance design - Related: 0001 (zero stored user tokens, which reaches its target state here), 0005 (OIDC resource server; its consequence "the web app's GitHub-OAuth login is untouched" is superseded), 0002 (cells already run OIDC-only; unchanged), 0004 (one artifact: the on-prem quickstart changes)
Context
What the Live Host accepts today
One funnel (sessionOf in http/api.ts, onAuthenticate in application/collab.ts) resolves a principal from seven credentials:
| Credential | Presented by | Why it exists |
|---|---|---|
session id: cookie bpm_live_sid, Authorization: Bearer <id>, ws token | browser, VS Code, ws rooms | the credential every login ends in; one value, three transports |
| OIDC access token (JWT bearer) | MCP clients, CI | ADR 0005: GitHub cannot be the authorization server for our API |
dev token (LIVE_DEV_TOKEN, default demo when nothing is configured) | tests, guest-test, the VS Code default | bootstrap shortcut; bypasses per-repo authorization |
| ws ticket (single-use, 60 s, one room) | the MCP-App widget iframe | the iframe holds no cookie and no JWT (ADR 0005 amendment 2026-08-04) |
login code (single-use, 60 s, POST /auth/exchange) | editor sign-in | the browser callback cannot set a cookie in the editor (#170) |
| pasted session token | VS Code against cells on an older image | deploy workaround (#173) |
CELL_SECRET as health bearer, webhook HMAC, token-mint credential | control plane, GitHub | machine-to-machine; out of scope here |
And two ways a session comes into existence, both OAuth 2 authorization-code flows: GitHub OAuth (/auth/github, GitHub App user-to-server tokens) and OIDC (/auth/oidc, code + PKCE against the configured IdP). The multiplicity is not in the logins. It comes from three sources:
- Two identity sources → two session shapes → two authorization paths. A GitHub-OAuth session stores the user's token (encrypted,
sessions.provider_token) and is authorized throughGitProvider.checkRepoAccess(AccessCachepath 2); an OIDC session is identity-only and can only be authorized app-side (installation token, path 1). ADR 0001 named the token-less session as the target state two months ago; the GitHub login is why it was never reached:release.tsstill falls back tosession.providerToken,access.tsstill refreshes user grants. - Boundaries a cookie cannot cross (iframe, editor URI scheme) produced two one-time-code stores of the same shape (
ws-tickets.ts,login-codes.ts). These are transport, not authentication. They stay. - Bootstrap shortcuts that stuck. The dev token was the M0 spike's identity. It is still the VS Code extension's default (
bpmLive.token: "demo"), the guest script's default, and the credential of every integration test. A token the server never verifies is not authentication; it is the absence of it, dressed as a bearer.
The bare spike is a no-auth mode with a hole
With nothing configured the server already runs without authentication: the dev token defaults to demo and every headless client gets all-repos access. The web app, however, is locked out: /api/me answers 401 without a cookie and the login page has no button ("This instance is not connected to GitHub yet"). The mode exists for machines only, and it is entered implicitly: the guard against "GitHub App configured, login pending, private repos exposed" (adversarial review, server.ts) is needed precisely because the mode is inferred from what is missing.
What the IdP requirement already implies
ADR 0005 made an IdP the operating prerequisite for remote AI clients. Every deployment that talks to an agent (the product's reason to exist) runs an IdP already. The GitHub login therefore only serves deployments that never connect an AI client, at the price of the second session shape, the stored user tokens, a second OAuth client to register and rotate, and request_oauth_on_install on the App manifest. The cloud dropped GitHub OAuth at the control plane in July (cells run per-cell DCR OIDC clients); the OSS host kept it for the zero-prerequisite quickstart.
Decision
Exactly two operating modes, selected explicitly by
LIVE_AUTH.LIVE_AUTH=oidc(default): the IdP is the only login. Prerequisites checked at startup:LIVE_OIDC_ISSUER,LIVE_OIDC_JWKS_URL,LIVE_OIDC_CLIENT_ID(the browser login is part of the mode, not an option) and a GitHub App connection (App id + private key on-prem; the control plane's token mint in a cell). Per-repo authorization runs app-side on installation tokens (ADR 0001); without an App an identity-only session can write nothing, so the combination is refused at startup rather than running silently read-only.LIVE_AUTH=none: no authentication. Never inferred: the server refuses to start inoidcmode without its prerequisites and names both options in the error. This replaces the implicit dev-token default and the guard around it.
nonemode is one principal, not a token. Every request (cookie or not, bearer or not, any ws token) resolves to the local principal: loginLIVE_LOCAL_USER, else the OS user name; providerlocal; no avatar.canWriteis true for every repository the registry knows: the staticGITHUB_REPOcheckout, or the App's installations when an App is configured (allowed: a trusted-network single-team host; releases are then bot-authored with the local principal as git author, soLIVE_LOCAL_USERshould be the person's GitHub login)./mcpanswers without a bearer; no protected-resource metadata, noWWW-Authenticate./api/configreportsauth: "none" | "oidc"; the web client never renders the login page and hides logout; the VS Code extension probes/api/meunauthenticated and skips sign-in when it answers. Clients keep sending thewsTokenfrom/api/meunchanged; innonemode it is an opaque constant and the ws join accepts any value. Startup logs one loud line.The dev token is retired.
LIVE_DEV_TOKEN,devUser, thedemodefault, theBearer demoheaders in tests, scripts and docs (14 files), the extension'sbpmLive.tokensetting. Everything that used it runs the host withLIVE_AUTH=none. Its semantics ("headless identity, all repos") AREnonemode's definition; keeping both would keep an unverified bearer as a second entrance.GitHub OAuth login is removed.
/auth/:providerand its callback, including the install-initiated branch;request_oauth_on_installgoes tofalsein the manifest;/setup/installedalready is the login-free post-install landing.GitProviderloseslabel,authorizeUrl,exchangeCode,refreshGrant,fetchUser,checkRepoAccessand keeps what a release needs:id,pushUrl,createPullRequest.AccessCachekeeps path 1 only.release.tsuses the installation token only (theLIVE_PUSH_URL_OVERRIDEtest hatch stays).GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRETare no longer read;create-appstops writing them (installation tokens use the App id + private key).@bpmiq/github-app/oauthloses its last consumer and is deleted. The control plane dropped it in cloud PR #24; confirm before the vendor bump.Sessions are identity-only, everywhere.
provider_token,refresh_token,token_expires_atand the at-rest cipher leaveSessionStore;TokenGrant/updateGrantgo with them. ADR 0001's target state is reached.SESSION_ENC_KEYkeeps its name (existing deployments, the cell env); its only remaining job is the HMAC key for the loginstate(fallbackCELL_TOKEN_KEY; theGITHUB_CLIENT_SECRETlink of the chain disappears). The installation-token cache (TokenService,CELL_TOKEN_KEY,domain/crypt.ts) is unaffected.What stays as is. The OIDC resource server (
auth/oidc.ts), the browser login (auth/oidc-login.ts), the editor sign-in (login code on the same flow), the ws ticket for the widget, the cell-mode machine credentials. The pasted-session-token command in VS Code (#173) is the last non-OAuth entrance; it is removed once every cell runs an image that serves/auth/exchange(separate PR, not gated on this ADR).
Consequences
- One identity contract, one authorization path. The two entrances (
sessionOffor HTTP,onAuthenticatefor the websocket) short-circuit on the injected local principal (auth/none.ts) and otherwise run the OIDC contract (auth/oidc.ts). A future trusted-proxy mode (X-Forwarded-Userbehind oauth2-proxy) would be a third source of the principal behind the same two seams. It is deferred until a customer asks. nonemode is gated against the browser's other tabs. A none-mode host on a developer's machine is reachable from every page that browser visits, so a request a browser labels as coming from another site (Fetch MetadataSec-Fetch-Site: cross-site, or anOriginthat is not the host's) is nobody, not the local principal. This holds at the REST routes,/mcpand the websocket join (a widget ticket still lets the MCP-App iframe in). Non-browser clients send neither header and are unaffected; same-site pages (another port of the same host) are allowed on purpose.nonemode has one identity, literally. Every human and every agent is the same login, so what keys on the login collapses: room presence marks every peer asyou, and agent-presence leases (one per room and login) merge concurrent MCP clients into one marker. Inherent to the mode, not a defect to fix; a team that needs told-apart people runsoidc. The same holds for the start page's favorites and recently opened repositories (#213): keyed on the login, they are shared host-wide innonemode, and kept apart from an IdP login of the same name by thelocal:provider prefix.- The on-prem quickstart changes hands. Today: register a GitHub App, paste five values, sign in with GitHub. Tomorrow: the same App plus an IdP that issues
github_login. The only documented recipe is WorkOS (docs/extending/mcp-idp-setup.md). This ADR is not complete without a self-contained IdP quickstart: a Keycloak compose underdeploy/keycloak/with a realm export (audience mapper,github_loginuser attribute → claim, a public PKCE client for the web login with redirect<LIVE_PUBLIC_URL>/auth/oidc/callback, a client for MCP), verified end-to-end before the docs claim it. Evaluation without any IdP isLIVE_AUTH=none, what the bare spike was for, now with the web app included. - Breaking release. Removed env vars (
LIVE_DEV_TOKEN,GITHUB_CLIENT_ID,GITHUB_CLIENT_SECRET), removed routes (/auth/github*), a required decision (LIVE_AUTH=none, or theoidcprerequisites), a required App foroidcmode. release-please:BREAKING CHANGEfooters → 4.0.0. The operating-modes table (docs/on-prem/README.md),configuration.md,deploy/.env.example, the live-host README's authentication section anddocs/extending/sso.mdare rewritten in the same release. - Tests.
stub-provider.tsloses/login/oauth/*,/user,/user/installations;release-e2e.shand the VS Code E2E run the host withLIVE_AUTH=none;session-crypt.test.tsgoes;access.test.tsloses its path-2 cases; tests that need a named, authenticated identity use the in-process OIDC test IdP already inoidc.test.ts(local JWKS +SignJWT).oauth-state.test.tsstays: the state HMAC guards the OIDC flow. - Cloud. Cells already run
oidcmode by construction;GITHUB_CLIENT_*in the cell env become dead values to clean up in the provisioner (bpm-iq-cloud, later).LIVE_AUTHdefaults tooidc, so the switch needs no cell env change. - Attribution. Release commits keep deriving the noreply address from the git provider (
<login>@users.noreply.github.com), not from the session's provider (unchanged). Innonemode the login is whateverLIVE_LOCAL_USERsays; in a container the OS fallback isroot/node, so the variable is documented as expected, not optional.
Rejected alternatives
- Keep GitHub OAuth as a third mode ("login without IdP"). Keeps two session shapes, two authorization paths, stored user tokens and a second OAuth client, for deployments that by ADR 0005 cannot connect an AI client. The IdP requirement is already the product's shape; the third mode only postponed it.
- Keep the dev token next to
nonemode. An unverified bearer is theater with a config knob; its semantics equalnonemode. - Infer
nonemode from missing configuration. That is the current state, and the reason a guard had to be written and adversarially reviewed. One explicit line in.envcosts less than an implicit mode. - Trusted-proxy header auth now. No customer has asked; the principal resolver seam accommodates it later without touching this decision.
- A self-built authorization server or password login. Still rejected (ADR 0005).
nonemode is not a login; it is the declared absence of one.
Rollout
Three stacked PRs on main, one major release:
nonemode replaces the dev token:LIVE_AUTH, the principal resolver,/api/config.auth, web + VS Code adaptation, tests and scripts onLIVE_AUTH=none,LIVE_DEV_TOKENremoved. BREAKING for dev-token users.- GitHub OAuth login and stored user tokens removed: routes, port,
AccessCachepath 2, session columns + cipher, client credentials,github-app/oauth, manifest flag,oidcmode prerequisites. BREAKING. - Docs + Keycloak quickstart:
deploy/keycloak/,docs/on-prem/idp-quickstart.md, rewritten modes/config/README/sso pages,.env.example.
PR 3's end-to-end verification (fresh host, Keycloak realm import, browser login, MCP bearer, one release) is the exit criterion for the release. It was met 2026-09-13: apps/live-host/test/keycloak-e2e.sh proves the prerequisite check, the browser login through Keycloak's form, an MCP bearer at /api/me and /mcp, a release with human attribution, and fail-closed refusal of a token without github_login at every entrance.