Skip to content

Commit 8d4893e

Browse files
Merge pull request #121 from CodeForPhilly/feat/login-migration-phase-d
feat(auth): link-github route + /account banner for legacy users
2 parents ef4068e + 5098d32 commit 8d4893e

9 files changed

Lines changed: 1025 additions & 35 deletions

File tree

apps/api/src/auth/github-oauth.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,110 @@ export type CallbackErrorCode =
5757
| 'github_unreachable'
5858
| 'email_unverified';
5959

60+
/**
61+
* Outcomes for the link-github callback. Mirrors `CallbackOutcome` but
62+
* with the link-specific error codes from specs/api/auth.md.
63+
*/
64+
export type LinkCallbackOutcome =
65+
| { kind: 'linked'; personId: string }
66+
| { kind: 'error'; code: LinkCallbackErrorCode };
67+
68+
export type LinkCallbackErrorCode =
69+
| 'github_unreachable'
70+
| 'github_already_linked'
71+
| 'github_id_in_use_elsewhere';
72+
73+
export interface CompleteLinkCallbackParams {
74+
readonly fastify: FastifyInstance;
75+
readonly request: FastifyRequest;
76+
readonly code: string;
77+
readonly codeVerifier: string;
78+
readonly redirectUri: string;
79+
readonly linkPersonId: string;
80+
}
81+
82+
/**
83+
* Run the link-mode OAuth callback pipeline. Differs from
84+
* `completeCallback`:
85+
* - No matching; the target Person is named in the cookie.
86+
* - Two conflict cases: the calling Person already has a link, or
87+
* the GitHub identity is bound to a different Person.
88+
* - No session minted; the user is already signed-in.
89+
*
90+
* The callback route is responsible for redirecting to
91+
* `/account?linked=github` on success or `/account?error=<code>` on
92+
* any of the error outcomes.
93+
*/
94+
export async function completeLinkCallback(
95+
params: CompleteLinkCallbackParams,
96+
): Promise<LinkCallbackOutcome> {
97+
const { fastify, request, code, codeVerifier, redirectUri, linkPersonId } = params;
98+
const cfg = fastify.config;
99+
100+
if (!cfg.GITHUB_OAUTH_CLIENT_ID || !cfg.GITHUB_OAUTH_CLIENT_SECRET) {
101+
return { kind: 'error', code: 'github_unreachable' };
102+
}
103+
104+
const linkingPerson = fastify.inMemoryState.people.get(linkPersonId);
105+
if (!linkingPerson || linkingPerson.deletedAt) {
106+
// The cookie pointed at a person who no longer exists or is deleted.
107+
// Treat as github_unreachable for the user — this should be very rare
108+
// (cookie is 10m and Persons rarely vanish in that window).
109+
return { kind: 'error', code: 'github_unreachable' };
110+
}
111+
if (typeof linkingPerson.githubUserId === 'number') {
112+
return { kind: 'error', code: 'github_already_linked' };
113+
}
114+
115+
let accessToken: string;
116+
try {
117+
accessToken = await exchangeCodeForToken({
118+
clientId: cfg.GITHUB_OAUTH_CLIENT_ID,
119+
clientSecret: cfg.GITHUB_OAUTH_CLIENT_SECRET,
120+
code,
121+
codeVerifier,
122+
redirectUri,
123+
});
124+
} catch (err) {
125+
fastify.log.warn({ err }, 'link-github: token exchange failed');
126+
return { kind: 'error', code: 'github_unreachable' };
127+
}
128+
129+
let identity: ResolvedGitHubIdentity;
130+
try {
131+
const [ghUser, rawEmails] = await Promise.all([
132+
fetchGitHubUser(accessToken),
133+
fetchGitHubEmails(accessToken),
134+
]);
135+
identity = resolveIdentitySnapshot(ghUser, rawEmails);
136+
} catch (err) {
137+
fastify.log.warn({ err }, 'link-github: user/emails fetch failed');
138+
return { kind: 'error', code: 'github_unreachable' };
139+
}
140+
141+
// Conflict: this GitHub identity is bound to a different Person.
142+
for (const person of fastify.inMemoryState.people.values()) {
143+
if (person.githubUserId === identity.id && person.id !== linkPersonId) {
144+
return { kind: 'error', code: 'github_id_in_use_elsewhere' };
145+
}
146+
}
147+
148+
const result = await fastify.store.transact(
149+
buildTransactionOptions({
150+
request,
151+
action: 'person.github-link',
152+
subjectType: 'person',
153+
subjectId: linkPersonId,
154+
subjectSlug: linkingPerson.slug,
155+
responseCode: 302,
156+
}),
157+
async (tx) => fastify.services.githubAccount.linkToExisting(tx, linkingPerson, identity),
158+
);
159+
result.value.stateApply.apply(fastify.inMemoryState, fastify.fts);
160+
161+
return { kind: 'linked', personId: linkPersonId };
162+
}
163+
60164
export interface CompleteCallbackParams {
61165
readonly fastify: FastifyInstance;
62166
readonly request: FastifyRequest;

apps/api/src/auth/oauth-session-cookie.ts

Lines changed: 40 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,21 @@ import { uuidv7 } from 'uuidv7';
1515
const OAUTH_SESSION_TTL_SECONDS = 10 * 60;
1616
const CLOCK_SKEW_SECONDS = 60;
1717

18+
/**
19+
* The OAuth round-trip can be either a fresh sign-in (`login`, the default)
20+
* or a link-existing-account-to-GitHub flow (`link`). `linkPersonId` is set
21+
* iff `mode === 'link'` and identifies the signed-in Person who initiated
22+
* the linking; the callback uses it to mutate the right Person record.
23+
*
24+
* Pre-link-flow cookies don't carry these fields; verify defaults `mode`
25+
* to `'login'` so the existing flow stays back-compat.
26+
*/
1827
export interface OAuthSessionClaims {
1928
readonly state: string;
2029
readonly codeVerifier: string;
2130
readonly return: string;
31+
readonly mode?: 'login' | 'link';
32+
readonly linkPersonId?: string;
2233
}
2334

2435
function keyBytes(signingKey: string): Uint8Array {
@@ -30,18 +41,24 @@ export async function signOAuthSession(
3041
signingKey: string,
3142
): Promise<string> {
3243
const now = Math.floor(Date.now() / 1000);
33-
return new SignJWT({
44+
const payload: Partial<JWTPayload> & {
45+
state: string;
46+
codeVerifier: string;
47+
return: string;
48+
scope: string;
49+
mode?: 'login' | 'link';
50+
linkPersonId?: string;
51+
} = {
3452
state: claims.state,
3553
codeVerifier: claims.codeVerifier,
3654
return: claims.return,
3755
scope: 'oauth_session',
3856
jti: uuidv7(),
39-
} satisfies Partial<JWTPayload> & {
40-
state: string;
41-
codeVerifier: string;
42-
return: string;
43-
scope: string;
44-
})
57+
};
58+
if (claims.mode) payload.mode = claims.mode;
59+
if (claims.linkPersonId) payload.linkPersonId = claims.linkPersonId;
60+
61+
return new SignJWT(payload)
4562
.setProtectedHeader({ alg: 'HS256' })
4663
.setIssuedAt(now)
4764
.setExpirationTime(now + OAUTH_SESSION_TTL_SECONDS)
@@ -69,5 +86,20 @@ export async function verifyOAuthSession(
6986
throw new Error('Invalid oauth session claims');
7087
}
7188

72-
return { state, codeVerifier, return: returnUrl };
89+
// Default mode = 'login' for back-compat with cookies issued before
90+
// the link-flow shipped. linkPersonId is only present in link mode.
91+
const rawMode = payload['mode'];
92+
const mode: 'login' | 'link' = rawMode === 'link' ? 'link' : 'login';
93+
const rawLinkPersonId = payload['linkPersonId'];
94+
const linkPersonId = typeof rawLinkPersonId === 'string' ? rawLinkPersonId : undefined;
95+
96+
if (mode === 'link' && !linkPersonId) {
97+
throw new Error('Invalid oauth session claims: link mode requires linkPersonId');
98+
}
99+
100+
const out: OAuthSessionClaims = { state, codeVerifier, return: returnUrl, mode };
101+
if (linkPersonId) {
102+
return { ...out, linkPersonId };
103+
}
104+
return out;
73105
}

apps/api/src/routes/auth.ts

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import {
4141
signOAuthSession,
4242
verifyOAuthSession,
4343
} from '../auth/oauth-session-cookie.js';
44-
import { buildAuthorizeUrl, completeCallback } from '../auth/github-oauth.js';
44+
import { buildAuthorizeUrl, completeCallback, completeLinkCallback } from '../auth/github-oauth.js';
4545

4646
function clientIp(request: FastifyRequest): string {
4747
const forwarded = request.headers['x-forwarded-for'];
@@ -75,6 +75,10 @@ function loginErrorRedirect(reply: FastifyReply, code: string): FastifyReply {
7575
return reply.redirect(`/login?error=${encodeURIComponent(code)}`);
7676
}
7777

78+
function accountErrorRedirect(reply: FastifyReply, code: string): FastifyReply {
79+
return reply.redirect(`/account?error=${encodeURIComponent(code)}`);
80+
}
81+
7882
async function persistSessionMetadata(
7983
fastify: FastifyInstance,
8084
request: FastifyRequest,
@@ -207,9 +211,32 @@ export async function authRoutes(fastify: FastifyInstance): Promise<void> {
207211
return loginErrorRedirect(reply, 'oauth_state_mismatch');
208212
}
209213

214+
const isLinkMode = sessionClaims.mode === 'link';
215+
210216
if (!query.code) {
211217
clearOAuthCookies(reply);
212-
return loginErrorRedirect(reply, 'github_unreachable');
218+
return isLinkMode
219+
? accountErrorRedirect(reply, 'github_unreachable')
220+
: loginErrorRedirect(reply, 'github_unreachable');
221+
}
222+
223+
// Link mode: completely separate pipeline. No matching, no session
224+
// mint — just bind the GitHub identity to the named Person and
225+
// redirect back to /account.
226+
if (isLinkMode && sessionClaims.linkPersonId) {
227+
const linkOutcome = await completeLinkCallback({
228+
fastify,
229+
request,
230+
code: query.code,
231+
codeVerifier: sessionClaims.codeVerifier,
232+
redirectUri: callbackRedirectUri(request),
233+
linkPersonId: sessionClaims.linkPersonId,
234+
});
235+
clearOAuthCookies(reply);
236+
if (linkOutcome.kind === 'error') {
237+
return accountErrorRedirect(reply, linkOutcome.code);
238+
}
239+
return reply.redirect('/account?linked=github');
213240
}
214241

215242
// Pipeline: code → token → user/emails → match → outcome.
@@ -274,6 +301,81 @@ export async function authRoutes(fastify: FastifyInstance): Promise<void> {
274301
},
275302
);
276303

304+
// ---------------------------------------------------------------------------
305+
// POST /api/auth/link-github — initiate GitHub-link flow for current session
306+
// ---------------------------------------------------------------------------
307+
//
308+
// Per specs/api/auth.md `POST /api/auth/link-github`. Auth-required. Signs
309+
// a link-mode `cfp_oauth_session` cookie carrying the current personId,
310+
// then 302s to GitHub OAuth. The callback at `/api/auth/github/callback`
311+
// recognizes the mode and binds the GitHub identity to the signed-in
312+
// Person instead of minting a new session.
313+
// ---------------------------------------------------------------------------
314+
315+
fastify.post(
316+
'/api/auth/link-github',
317+
{
318+
schema: {
319+
tags: ['auth'],
320+
summary: 'Link the current session to a GitHub identity',
321+
querystring: {
322+
type: 'object',
323+
properties: { return: { type: 'string' } },
324+
},
325+
},
326+
},
327+
async (request, reply) => {
328+
requireAuth(request, ['user']);
329+
const cfg = fastify.config;
330+
if (!cfg.GITHUB_OAUTH_CLIENT_ID || !cfg.GITHUB_OAUTH_CLIENT_SECRET) {
331+
return accountErrorRedirect(reply, 'github_unreachable');
332+
}
333+
334+
const personId = request.session.person?.id;
335+
if (!personId) {
336+
// requireAuth above already throws on no session; this is purely
337+
// a type-narrowing guard for the linePersonId argument below.
338+
throw new UnauthenticatedError('No session', 'no_session');
339+
}
340+
341+
// Fast-fail before round-tripping to GitHub if already linked.
342+
const person = fastify.inMemoryState.people.get(personId);
343+
if (person && typeof person.githubUserId === 'number') {
344+
return accountErrorRedirect(reply, 'github_already_linked');
345+
}
346+
347+
const { return: returnParam } = request.query as { return?: string };
348+
const returnPath = safeReturnPath(returnParam) === '/' ? '/account' : safeReturnPath(returnParam);
349+
350+
const state = generateCsrfState();
351+
const codeVerifier = generatePkceVerifier();
352+
const codeChallenge = pkceChallengeFromVerifier(codeVerifier);
353+
354+
const sessionToken = await signOAuthSession(
355+
{
356+
state,
357+
codeVerifier,
358+
return: returnPath,
359+
mode: 'link',
360+
linkPersonId: personId,
361+
},
362+
cfg.CFP_JWT_SIGNING_KEY,
363+
);
364+
365+
setOAuthStateCookie(reply, state, cfg.NODE_ENV);
366+
setOAuthSessionCookie(reply, sessionToken, cfg.NODE_ENV);
367+
368+
const url = buildAuthorizeUrl({
369+
clientId: cfg.GITHUB_OAUTH_CLIENT_ID,
370+
redirectUri: callbackRedirectUri(request),
371+
state,
372+
codeChallenge,
373+
});
374+
375+
return reply.redirect(url);
376+
},
377+
);
378+
277379
// ---------------------------------------------------------------------------
278380
// POST /api/auth/login — legacy password sign-in
279381
//

apps/api/src/services/github-account.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,4 +179,33 @@ export class GitHubAccountService {
179179

180180
return { person: updated, stateApply, publicChanged };
181181
}
182+
183+
/**
184+
* Bind a GitHub identity to a Person that currently has none. Used by
185+
* `POST /api/auth/link-github`'s callback branch. Per
186+
* specs/behaviors/account-migration.md the link records the GitHub
187+
* fields but does NOT refresh `PrivateProfile.email` in v1 — that
188+
* requires a consent toggle on a link-confirmation screen that
189+
* doesn't yet exist. The user's existing email-on-file stays.
190+
*
191+
* Caller is responsible for the conflict checks (the Person isn't
192+
* already linked, and the GitHub identity isn't bound to a *different*
193+
* Person); this method assumes both invariants hold.
194+
*/
195+
async linkToExisting(
196+
tx: DualStoreTx,
197+
existing: Person,
198+
identity: ResolvedGitHubIdentity,
199+
): Promise<{ person: Person; stateApply: StateApply }> {
200+
const now = nowIso();
201+
const updated: Person = PersonSchema.parse({
202+
...existing,
203+
githubUserId: identity.id,
204+
githubLogin: identity.login,
205+
githubLinkedAt: now,
206+
updatedAt: now,
207+
});
208+
await tx.public.people.upsert(updated);
209+
return { person: updated, stateApply: new StateApply().upsertPerson(updated) };
210+
}
182211
}

0 commit comments

Comments
 (0)