SPEC-0013: GitHub Login for the Web App Shell
Graph Edges
- Implements: ADR-0019 — GitHub as an additional human auth provider behind a minimal provider interface.
- Implements: ADR-0024 — enrollment is an operator mode (
allowlist,invite,open), and GitHub sign-in fails closed. - Extends: SPEC-0001 — the web app shell whose login page and session model this capability extends.
- Related: SPEC-0023 — Teams and tenancy: the users, identities and invitations the
inviteenrollment mode reads.
Overview
The web app shell (SPEC-0001) authenticates humans today via Pocket ID OIDC
(ADR-0013) and, outside production, dev_login_password. This capability adds
"Log in with GitHub" as a second production identity provider, realized
through the provider interface defined in ADR-0019. A GitHub login establishes
the same Cairn session a Pocket ID login does: the ambient web session that
the Bin (SPEC-0001) and the MCP OAuth consent screen (SPEC-0007) already read.
GitHub issues no OIDC ID token for user login, so identity verification is
OAuth 2.0 code exchange plus GET /user and GET /user/emails, with the
primary verified email as the identity anchor. The provider difference is
confined to one implementation; routes, session establishment, and CSRF are
shared and unchanged.
Amended 2026-09-22 (ADR-0024). Enrollment (an identity not yet linked to an
active Cairn user) is gated by an operator enrollment mode,
CAIRN_ENROLLMENT_MODE=allowlist|invite|open, defaulting to invite when GitHub
login is configured and open otherwise, and by an operator allowlist,
CAIRN_ENROLLMENT_ALLOW (requirements AL-1 to AL-6 below). Both match
Switchboard's SWITCHBOARD_ENROLLMENT_MODE and SWITCHBOARD_ENROLLMENT_ALLOW in
values, default and semantics. With GitHub configured and no invitations or
allowlist entries, nobody can enroll through GitHub, and the provider is treated
as unconfigured. Open signup requires the explicit open mode. This reverses
the design's original default of admitting any verified-email GitHub account.
The GitHub session subject becomes the numeric GitHub account id.
Requirements
Requirement: Provider Selection on the Login Page
The login page MUST render a "Log in with GitHub" button when the GitHub
provider is configured, and MUST NOT render it otherwise. Clicking it MUST
start the login flow at a visitor loads the login page with the page shows both the Pocket ID login control and a "Log in with GitHub" button, and the button links to a visitor loads the login page on a deployment without GitHub credentials configured, or with credentials but no way for a GitHub sign-in to succeed (AL-3) no GitHub login control is rendered and GET /auth/login?provider=github.Scenario: GitHub provider configured
CAIRN_GITHUB_CLIENT_ID and CAIRN_GITHUB_CLIENT_SECRET configured, and a GitHub sign-in could succeed (AL-3)/auth/login?provider=githubScenario: GitHub provider not configured
/auth/login?provider=github returns 404 without leaking whether the route exists
Requirement: GitHub OAuth Callback Exchange
Cairn MUST complete the GitHub authorization-code flow at
a user completes GitHub consent with a verified primary email Cairn establishes a session for that identity (issuer GitHub returns no primary email with login is rejected with a user-visible error, no session is established, and the reason is logged with the GitHub user id but never the token the callback's the callback is rejected before any token exchange, per the existing OIDC state rulesGET /auth/callback?provider=github&code=...&state=...: validate state
against the login cookie (same machinery as the OIDC flow), exchange the code
at https://github.com/login/oauth/access_token, fetch GET /user and
GET /user/emails with the resulting token, and select the primary email
where verified == true.Scenario: Successful GitHub login
https://github.com, subject the numeric GitHub account id as amended by ADR-0024, actor the primary verified email; the login is kept for display and logs) and redirects to the post-login destination, identical in behavior to an ADR-0013 loginScenario: Unverified or missing primary email
verified == trueScenario: Invalid or replayed state
state does not match the state cookie, or the state cookie is expired or absent
Requirement: Session Parity and Issuer Provenance
A session established via GitHub MUST be indistinguishable from a Pocket ID
session to all downstream consumers (Bin handlers, MCP consent screen), except
that the stored session MUST record the issuing provider ( a user logged in via GitHub opens the Bin every handler behaves exactly as it would for a Pocket ID session with the same actor identity a session is established by either provider the session record carries iss) and the
provider's subject (sub) so provenance is auditable.Scenario: GitHub session in the Bin
Scenario: Session provenance is recorded
iss and sub values identifying the provider and provider-side subject
Requirement: Token Containment
GitHub access tokens MUST be used only inside the callback to fetch the
profile and MUST NOT be persisted in sessions, cookies, logs, or the database.
GitHub API calls in steady state MUST be zero — the profile is captured at
login time only. a GitHub callback completes and the session is established the access token is dropped and never appears in any persisted store or log lineScenario: Token lifetime
Requirement: AL-1 Enrollment Mode and Allowlist Configuration
The operator MUST be able to configure enrollment with:
CAIRN_ENROLLMENT_MODE:allowlist,inviteoropen. When unset it MUST beinviteif the GitHub provider is configured andopenotherwise. The names, default and meanings MUST match Switchboard'sSWITCHBOARD_ENROLLMENT_MODE:allowlist: only identities matchingCAIRN_ENROLLMENT_ALLOWmay enroll;invite: only identities whose verified email holds a pending team invitation, plus operators (Cairn ADR-0029), may enroll;open: any identity the configured providers authenticate may enroll.
CAIRN_ENROLLMENT_ALLOW: comma-separated entries in the syntax of Switchboard'sSWITCHBOARD_ENROLLMENT_ALLOW:<issuer>|<subject>, a provider-qualified subject; a GitHub user ishttps://github.com|<numeric account id>;- a verified email address;
@<domain>, matching a verified email in that domain;github-org:<login>, matching an active member of that GitHub organisation.
Matching MUST be case-insensitive. A GitHub subject entry MUST match GitHub's
numeric account id from GET /user, never the login.
Startup MUST fail, naming the problem, when:
- an allowlist entry is malformed;
openis combined with a non-emptyCAIRN_ENROLLMENT_ALLOW.
Selecting invite MUST NOT fail startup, including on a build without team
invitations; there, invite admits nobody (AL-3).
When the effective mode is it may enroll, and a different account now holding the old login may not cairn serve refuses to start and names both variables GitHub credentials are set and the effective mode is GitHub credentials are set and cairn serve starts, any GitHub account may enroll, and the startup log carries a WARN that enrollment is open to every GitHub account only the OIDC provider is configured and the effective mode is open and the GitHub provider is configured, anyone
with a GitHub account can enroll. That is a risky setting, so it is never the
default with GitHub on: cairn serve MUST log a WARN at startup saying so, and the
self-hosting guide MUST call the combination out in a warning admonition. These settings are operator
configuration. No user, team admin, token or request may change them. There is
no deprecated alias for any earlier variable name.Scenario: Numeric id survives a rename
CAIRN_ENROLLMENT_MODE=allowlist, CAIRN_ENROLLMENT_ALLOW=https://github.com|583231, and that account has renamed its GitHub login since the entry was writtenScenario: Conflicting configuration refused
CAIRN_ENROLLMENT_MODE=open and CAIRN_ENROLLMENT_ALLOW=github-org:acme are both setScenario: Default mode with GitHub configured
CAIRN_ENROLLMENT_MODE is unsetinvite, and cairn serve startsScenario: Open signup with GitHub warns
CAIRN_ENROLLMENT_MODE=openScenario: Default mode without GitHub
CAIRN_ENROLLMENT_MODE is unsetopen, and OIDC sign-in behaves as before
Requirement: AL-2 The Gate Applies to Enrollment
A sign-in is an enrollment when its identity ( a GitHub user signs in on a build without persistent users the full enrollment gate runs for that sign-in persistent users exist, an already-linked user signs in, and the organisation membership API is failing they are signed in, because no membership call is made for a linked identity<issuer>, <subject>; for
GitHub, https://github.com and the numeric id) is not linked to an active
Cairn user, and its verified, lower-cased email does not link it to one. The
gate (AL-4) MUST apply to every enrollment from every provider, and MUST NOT
apply to an identity already linked to an active user. Until persistent users
exist (Cairn ADR-0029), every GitHub sign-in is an enrollment, and OIDC
sign-ins are not gated, because a returning OIDC user cannot yet be told from a
new one; the gate MUST cover OIDC in the release that adds users. Revoking an
existing user's access is suspension (ADR-0029), not the allowlist.Scenario: Today every sign-in is checked
Scenario: Existing user is not locked out by a GitHub API error
Requirement: AL-3 Fail Closed Without a Way In
In allowlist mode with an empty CAIRN_ENROLLMENT_ALLOW, and in invite mode
before team invitations exist, no enrollment may succeed. While no GitHub
sign-in could succeed at all (such a condition holding and no Cairn user having
a linked GitHub identity), the GitHub provider MUST be treated as unconfigured:
- no button is rendered;
/auth/login?provider=githuband a GitHub callback return 404, indistinguishable from an unknown provider;- cairn serve logs a startup warning naming the variables that would enable it.
Scenario: Credentials alone admit nobody
only CAIRN_GITHUB_CLIENT_ID and CAIRN_GITHUB_CLIENT_SECRET are set, on a build without team invitations
the effective mode is invite, the login page shows no GitHub button, /auth/login?provider=github returns 404, and the startup log warns that GitHub enrollment is closed until CAIRN_ENROLLMENT_MODE=allowlist with CAIRN_ENROLLMENT_ALLOW entries, or CAIRN_ENROLLMENT_MODE=open, is set
Requirement: AL-4 Enrollment Check Before Session
After the existing identity verification succeeds, and before any session is created, an enrollment MUST be admitted only when one of these holds:
CAIRN_ENROLLMENT_MODE=open;- in
invitemode, the identity is an operator, or its verified email holds a pending team invitation; - in
allowlistmode, a subject, verified-email or@<domain>entry inCAIRN_ENROLLMENT_ALLOWmatches; - in
allowlistmode, for a GitHub identity,GET /user/memberships/orgs/{org}with the user's token returns 200 withstate == "active"for somegithub-org:entry.
For each organisation checked:
- 404 (not a member),
state == "pending", and 403 (the organisation restricts the OAuth app) MUST be treated as not admitted by that organisation; - any other status, or a transport error, MUST deny the enrollment. Fail closed.
A denied enrollment MUST:
- create no session;
- clear the state cookie;
- render a generic 403 page that reveals nothing about which organisations, users, teams or invitations exist;
- log the GitHub login, the numeric id and the reason (
not_allowlisted,org_restricted_oauth_app,membership_check_failed), and never the token.
Scenario: Active org member admitted
CAIRN_ENROLLMENT_MODE=allowlist, CAIRN_ENROLLMENT_ALLOW=github-org:acme, and the enrolling user is an active member of acme
a session is established exactly as for any GitHub login
Scenario: Non-member denied without disclosure
the enrolling user matches no allowlist entry, no organisation, and no invitation
the response is the generic 403 page, which names no organisation or team, no session cookie is set, and the log reason is not_allowlisted
Scenario: Invited friend enrolls under the default mode
GitHub is configured, CAIRN_ENROLLMENT_MODE is unset, and a pending team invitation exists for friend@example.com (Cairn ADR-0029)
a GitHub account whose primary verified email is friend@example.com completes login
a session is established and no organisation membership call is made
Scenario: Pending organisation membership denied
the user's acme membership has state: "pending" and nothing else admits them
the enrollment is denied
Scenario: Organisation restricts the OAuth app
the membership call for acme returns 403
that organisation does not admit, the log reason is org_restricted_oauth_app, and the next allowed organisation (if any) is checked
Scenario: GitHub API failure denies an enrollment
the membership call times out or returns 502 during an enrollment
the enrollment is denied with reason membership_check_failed, and no session is created
Requirement: AL-5 Scope Minimisation
The authorisation request MUST include the the redirect to GitHub requests read:org scope if and only if the
mode is allowlist and CAIRN_ENROLLMENT_ALLOW holds a github-org: entry.
Otherwise the scopes MUST remain read:user user:email.Scenario: Subject-only allowlist does not ask for org access
CAIRN_ENROLLMENT_MODE=allowlist and CAIRN_ENROLLMENT_ALLOW holds only https://github.com|583231read:user user:email and not read:org
Requirement: AL-6 Numeric Subject and Login-Time Evaluation
A GitHub session MUST record:
iss:https://github.com;sub: the numeric account id;- the login, for display and logs only.
All enrollment checks MUST run only in the callback. The access token MUST still
be dropped when the callback returns (REQ "Token Containment"), and no GitHub
API call may happen outside the callback. The operator documentation MUST state
that leaving an allowed organisation does not end an existing session or
(after ADR-0029) an existing account: the operator suspends the user. a GitHub session is established its stored a signed-in user is removed from the allowed organisation their existing session continues until it expires or is revoked, and no GitHub API call is made in betweenScenario: Subject is the numeric id
sub is the numeric account id, not the loginScenario: Removed member keeps a live session
Security Requirements
This is a web-facing spec. The following apply per ADR-0019 and the project's security baseline; anything not restated here follows the existing shell behavior (SPEC-0001 and ADR-0013):
- Authentication: Per ADR-0013, sessions are HttpOnly, SameSite=Lax, signed cookies with server-side revocation. GitHub login reuses that machinery verbatim; no new cookie or session format is introduced.
- Authorization (enrollment): An identity enrolls only through the
operator's enrollment mode and allowlist (AL-1 to AL-4). GitHub enrollment
fails closed on an empty allowlist, on
invitewith no invitation, and on any membership-check error, and a refusal discloses nothing about organisations, teams or invitations. Mode and allowlist are operator configuration, never user-settable. - Rate limiting: The
/auth/loginand/auth/callbackroutes inherit whatever edge limits exist at the reverse proxy. The state cookie's single-use, short-TTL property is the primary anti-replay control; per-IP callback throttling SHOULD be added if abuse is observed. - Security headers: Per the existing shell middleware (CSP and friends) — unchanged by this capability; the login page's new button adds no inline script.
- Request body size limits: Per the existing shell middleware. This capability introduces no new body-consuming endpoints.
- CSRF protection: Per ADR-0013 — the OIDC state cookie (random value,
HMAC-bound, single-use, short TTL) is reused for the GitHub flow's
stateparameter, giving login CSRF protection identical to the existing flow. - Redirect validation: The callback redirect target after login MUST be
restricted to the allow-listed post-login destinations already used by the
OIDC flow; the
statecookie is the only carrier of the destination and arbitrarynextparameters MUST NOT be accepted from the query string of the GitHub-initiated callback.
Accessibility Requirements
This spec touches the login page UI. The following are MANDATORY for the GitHub login button, per WCAG 2.1 AA:
- WCAG 2.1 AA compliance — the minimum conformance target
- ARIA landmarks — the login page's existing landmarks are preserved
aria-labelon icon-only controls — the GitHub button MUST carry an accessible name ("Log in with GitHub"); if rendered with only the GitHub mark, the label is still requiredaria-liveregions for dynamic content — login error messages (rejected email, state mismatch, account not permitted) MUST be announced via a polite live region- Keyboard navigation — the button is reachable in tab order and activates with Enter/Space like the existing login controls
- Focus management in modals and dialogs — not applicable; the login flow introduces no modal