| status | done | |||
|---|---|---|---|---|
| depends |
|
|||
| specs |
|
|||
| issues | ||||
| pr | 41 |
Replace the auth-jwt-substrate issuance stub with the real GitHub OAuth flow: GET /api/auth/github/start, GET /api/auth/github/callback, PKCE, identity-resolution-to-Person via the matching algorithm. The callback resolves to one of three outcomes — existing-linked, fresh, or claim-pending — and either issues a session or hands off a claim-pending JWT to be consumed by the next plan.
Out of scope: the account-claim screens + endpoints (account-claim follows); SAML IdP (saml-idp). After this plan, signing in via GitHub works end-to-end for "existing GitHub-linked user" and "brand-new user" cases. Legacy users without a GitHub link land on a claim screen that 501s until the next plan unstubs it.
- api/auth.md — the
/github/startand/github/callbackendpoints; PKCE; CSRF state cookie; claim-pending JWT issuance - behaviors/account-migration.md — the matching algorithm (githubUserId → existing linked → email match → username weak match → outcome routing). The actual consumption of the claim-pending JWT is in
account-claim. - screens/login.md — the "Sign in with GitHub" button now actually navigates to the live
/startendpoint;?error=…query parameters render the documented messages
-
GET /api/auth/github/start:- Generate 32-byte CSPRNG state token
- Generate PKCE code verifier + S256 challenge
- Sign a session cookie
cfp_oauth_sessioncarrying{ state, codeVerifier, return } - Redirect to
https://github.com/login/oauth/authorizewith the documented query params +scope=read:user user:email
-
GET /api/auth/github/callback:- Decode
cfp_oauth_session; verify state matches query param - Exchange code at
https://github.com/login/oauth/access_token(POST) with the code_verifier - Fetch
GET https://api.github.com/user+GET https://api.github.com/user/emails - Filter
emailsto{verified: true}entries - Resolve to a Person via the matching algorithm (below)
- One of three outcomes: existing → session, fresh → session, candidates → claim-pending JWT
- Decode
Per behaviors/account-migration.md:
async function resolveIdentity(gh: GhIdentity, store: Store): Promise<MatchResult> {
// 1. Direct hit
const linked = store.public.byGithubUserId.get(gh.id);
if (linked) return { kind: 'existing', personId: linked.id };
// 2. Email match against any verified GH email
const verifiedEmails = gh.emails.filter(e => e.verified).map(e => e.email.toLowerCase());
const emailMatches = new Set<string>();
for (const email of verifiedEmails) {
const pid = await store.private.findPersonIdByEmail(email);
if (pid) {
const person = store.public.byId.get(pid);
if (person && !person.githubUserId) emailMatches.add(pid); // skip already-linked
}
}
// 3. Username weak match
const usernameMatch = store.public.bySlug.person.get(gh.login.toLowerCase());
const candidates = new Set(emailMatches);
if (usernameMatch && !usernameMatch.githubUserId && !candidates.has(usernameMatch.id)) {
candidates.add(usernameMatch.id);
}
// 4. Route
if (candidates.size === 0) return { kind: 'create-fresh' };
return { kind: 'candidates', candidates: [...candidates], matchedEmail: verifiedEmails[0] };
}apps/api/src/routes/auth.ts /github/callback handler:
const match = await resolveIdentity(gh, store);
switch (match.kind) {
case 'existing':
await refreshGitHubLogin(match.personId, gh); // update Person.githubLogin
await refreshEmail(match.personId, gh.primaryEmail); // update PrivateProfile.email
mintSessionFor(match.personId, reply);
return reply.redirect(safeReturn(state.return));
case 'create-fresh':
const person = await createPersonFromGitHub(gh, store);
await createPrivateProfile(person.id, gh.primaryEmail, store);
mintSessionFor(person.id, reply);
return reply.redirect(safeReturn(state.return));
case 'candidates':
mintClaimPendingFor(gh, match.candidates, reply);
return reply.redirect(`/account-claim?return=${encodeURIComponent(state.return)}`);
}mintSessionFor and mintClaimPendingFor from auth-jwt-substrate.
Inside store.transact:
- Generate UUIDv7 for
Person.id - Derive
Person.slugfromgh.login(slugify, dedupe with-2/-3) - Populate from GitHub:
fullName,githubUserId,githubLogin,githubLinkedAt,slackSamlNameId = slug tx.public.sheet('people').upsert(person)tx.private.putProfile({ personId, email, emailRefreshedAt: now, updatedAt: now })
The commit's trailers carry Action: person.create, Actor-Slug: '<slug>' (the new user themselves are the actor), etc.
If candidates: the API mints cfp_claim with { candidates, ghLogin, ghName, ghEmails, exp:+5m } and redirects to /account-claim. The /account-claim screen (currently 501ed) is built in the next plan.
For this plan: add a temporary placeholder route in the web app at /account-claim that shows "Claim flow coming in the next plan" + the OAuth identity it received (so a test user can see "yeah this is the right account"). Replaced fully in account-claim.
Per api/auth.md: redirect to /login?error=<code> on each failure mode. The <Login> component (already in web-shell + public-screens) renders the documented messages.
GET /api/auth/me now returns me.email (from PrivateProfile) for the authenticated user. This is a small read-api adjustment landing in this plan since the data didn't exist before.
- OAuth happy path: never-seen-this-user → clicks Sign in with GitHub → GitHub auth → callback → fresh Person + PrivateProfile created → session issued → redirected to
/ - OAuth returning user: a Person with
githubUserIdset → callback → session issued → no Person/PrivateProfile mutations beyond email refresh + githubLogin refresh - OAuth with candidates: a Person without
githubUserIdexists whose PrivateProfile.email matches a GitHub-verified email → callback → claim-pending JWT issued → redirected to/account-claimplaceholder - CSRF: tampering with the
statequery param → 401oauth_state_mismatch - PKCE: GitHub returns an error → handled gracefully → redirected to
/login?error=… - User denies on GitHub (
error=access_denied) → redirected to/login?error=access_deniedwith the documented message - GitHub returns no verified emails → redirected to
/login?error=email_unverifiedwith the documented help message -
cfp_oauth_sessioncookie expires after 10 minutes; expired sessions fail withoauth_session_invalid - Tests: mock GitHub via the test-harness mocks; cover each outcome (existing / fresh / candidates) + each error mode
- GitHub OAuth app setup. Need a GitHub OAuth App registered with
https://codeforphilly.org/api/auth/github/callback(and adev.codeforphilly.org-style callback for staging). Document the setup in the plan's Notes as we go. - Email-visibility quirks. Some users have all emails set to private on GitHub. We've documented
email_unverifiedas the dead end; verify GitHub returns something useful in that case. gh.loginslugify collisions.kebab-case-this-namemight already be taken by an existing legacy slug. The dedup-with--2/-3is a clean fallback but the resulting fresh slug could look weird (jane-doe-3). Acceptable for v1; staff can rename later if needed.
- Hand-rolled PKCE over
@fastify/oauth2. Verifier = base64url(32 random bytes); challenge = base64url(sha256(verifier)). Keeps the dependency surface small and the flow legible. The carry-state cookie is a signed JWT (10 min) carrying{ state, codeVerifier, return }so the verifier survives the GitHub round-trip without server-side state. - State + session cookies both scoped to
/api/auth. Tightens blast radius vsPath=/and keeps them out of every other request's cookie jar. Cleared on every callback regardless of outcome. - Redirect-for-every-error. The spec lists 401/403/502 status codes for some OAuth error modes; the github-oauth flow is browser-driven so the implementation always redirects to
/login?error=<code>. Validation criterion #4's "401" wording reflects the spec's status code; the implemented behavior (redirect carrying the same code) was the plan's explicit choice. See follow-up #42. callbackRedirectUriis derived from the inbound request (honoringX-Forwarded-ProtoandX-Forwarded-Host) so dev/staging/prod each round-trip to themselves without an env var per environment. The deployed OAuth Apps still need their callback URLs registered at github.com/settings/developers.- Fresh-user slug derivation.
slugifyGitHubLogin(login, ghId)lowercases, handles reserved-slug collision (user-<login>), and falls back touser-<gh-id>if both shape and reservation lose.ensureUniqueSlugthen dedupes with-2/-3against the in-memorypersonIdBySlugindex. - Fresh-user transaction uses
writeOrder: 'private-first'. If the private-profile flush fails, the public Person commit never lands — no orphaned public-only Person records. - Email refresh on every existing-linked sign-in. Per spec,
PrivateProfile.emailalways tracks the user's current GitHub primary verified email.refreshLinkedrewrites the private profile even when the email is unchanged soemailRefreshedAtbumps. - No welcome notification on fresh signup. Wired LoggingNotifier doesn't expose
notifyAccountWelcomeyet. Tracked as follow-up #43. - Test parallelism flakiness. The full API suite under default vitest parallelism is flaky on contended machines (worker timeouts in
read-api/write-api). Running with--no-file-parallelismyields 158/158. Pre-existing onmain— not introduced by this plan. Each new test ingithub-oauth.test.tsuses a uniqueremoteAddressto avoid the 10-req/min/IP cap on/api/auth/*.
- Issue #42 — clarify auth.md status codes vs redirects for OAuth error modes
- Issue #43 — send welcome notification on fresh-user OAuth signup
- Deferred to
account-claim— full/account-claimUI consuming thecfp_claimcookie (this plan only ships the placeholder page)