@@ -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
4646function 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+
7882async 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 //
0 commit comments