Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions apps/api/src/auth/github-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,110 @@ export type CallbackErrorCode =
| 'github_unreachable'
| 'email_unverified';

/**
* Outcomes for the link-github callback. Mirrors `CallbackOutcome` but
* with the link-specific error codes from specs/api/auth.md.
*/
export type LinkCallbackOutcome =
| { kind: 'linked'; personId: string }
| { kind: 'error'; code: LinkCallbackErrorCode };

export type LinkCallbackErrorCode =
| 'github_unreachable'
| 'github_already_linked'
| 'github_id_in_use_elsewhere';

export interface CompleteLinkCallbackParams {
readonly fastify: FastifyInstance;
readonly request: FastifyRequest;
readonly code: string;
readonly codeVerifier: string;
readonly redirectUri: string;
readonly linkPersonId: string;
}

/**
* Run the link-mode OAuth callback pipeline. Differs from
* `completeCallback`:
* - No matching; the target Person is named in the cookie.
* - Two conflict cases: the calling Person already has a link, or
* the GitHub identity is bound to a different Person.
* - No session minted; the user is already signed-in.
*
* The callback route is responsible for redirecting to
* `/account?linked=github` on success or `/account?error=<code>` on
* any of the error outcomes.
*/
export async function completeLinkCallback(
params: CompleteLinkCallbackParams,
): Promise<LinkCallbackOutcome> {
const { fastify, request, code, codeVerifier, redirectUri, linkPersonId } = params;
const cfg = fastify.config;

if (!cfg.GITHUB_OAUTH_CLIENT_ID || !cfg.GITHUB_OAUTH_CLIENT_SECRET) {
return { kind: 'error', code: 'github_unreachable' };
}

const linkingPerson = fastify.inMemoryState.people.get(linkPersonId);
if (!linkingPerson || linkingPerson.deletedAt) {
// The cookie pointed at a person who no longer exists or is deleted.
// Treat as github_unreachable for the user — this should be very rare
// (cookie is 10m and Persons rarely vanish in that window).
return { kind: 'error', code: 'github_unreachable' };
}
if (typeof linkingPerson.githubUserId === 'number') {
return { kind: 'error', code: 'github_already_linked' };
}

let accessToken: string;
try {
accessToken = await exchangeCodeForToken({
clientId: cfg.GITHUB_OAUTH_CLIENT_ID,
clientSecret: cfg.GITHUB_OAUTH_CLIENT_SECRET,
code,
codeVerifier,
redirectUri,
});
} catch (err) {
fastify.log.warn({ err }, 'link-github: token exchange failed');
return { kind: 'error', code: 'github_unreachable' };
}

let identity: ResolvedGitHubIdentity;
try {
const [ghUser, rawEmails] = await Promise.all([
fetchGitHubUser(accessToken),
fetchGitHubEmails(accessToken),
]);
identity = resolveIdentitySnapshot(ghUser, rawEmails);
} catch (err) {
fastify.log.warn({ err }, 'link-github: user/emails fetch failed');
return { kind: 'error', code: 'github_unreachable' };
}

// Conflict: this GitHub identity is bound to a different Person.
for (const person of fastify.inMemoryState.people.values()) {
if (person.githubUserId === identity.id && person.id !== linkPersonId) {
return { kind: 'error', code: 'github_id_in_use_elsewhere' };
}
}

const result = await fastify.store.transact(
buildTransactionOptions({
request,
action: 'person.github-link',
subjectType: 'person',
subjectId: linkPersonId,
subjectSlug: linkingPerson.slug,
responseCode: 302,
}),
async (tx) => fastify.services.githubAccount.linkToExisting(tx, linkingPerson, identity),
);
result.value.stateApply.apply(fastify.inMemoryState, fastify.fts);

return { kind: 'linked', personId: linkPersonId };
}

export interface CompleteCallbackParams {
readonly fastify: FastifyInstance;
readonly request: FastifyRequest;
Expand Down
48 changes: 40 additions & 8 deletions apps/api/src/auth/oauth-session-cookie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,21 @@ import { uuidv7 } from 'uuidv7';
const OAUTH_SESSION_TTL_SECONDS = 10 * 60;
const CLOCK_SKEW_SECONDS = 60;

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

function keyBytes(signingKey: string): Uint8Array {
Expand All @@ -30,18 +41,24 @@ export async function signOAuthSession(
signingKey: string,
): Promise<string> {
const now = Math.floor(Date.now() / 1000);
return new SignJWT({
const payload: Partial<JWTPayload> & {
state: string;
codeVerifier: string;
return: string;
scope: string;
mode?: 'login' | 'link';
linkPersonId?: string;
} = {
state: claims.state,
codeVerifier: claims.codeVerifier,
return: claims.return,
scope: 'oauth_session',
jti: uuidv7(),
} satisfies Partial<JWTPayload> & {
state: string;
codeVerifier: string;
return: string;
scope: string;
})
};
if (claims.mode) payload.mode = claims.mode;
if (claims.linkPersonId) payload.linkPersonId = claims.linkPersonId;

return new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt(now)
.setExpirationTime(now + OAUTH_SESSION_TTL_SECONDS)
Expand Down Expand Up @@ -69,5 +86,20 @@ export async function verifyOAuthSession(
throw new Error('Invalid oauth session claims');
}

return { state, codeVerifier, return: returnUrl };
// Default mode = 'login' for back-compat with cookies issued before
// the link-flow shipped. linkPersonId is only present in link mode.
const rawMode = payload['mode'];
const mode: 'login' | 'link' = rawMode === 'link' ? 'link' : 'login';
const rawLinkPersonId = payload['linkPersonId'];
const linkPersonId = typeof rawLinkPersonId === 'string' ? rawLinkPersonId : undefined;

if (mode === 'link' && !linkPersonId) {
throw new Error('Invalid oauth session claims: link mode requires linkPersonId');
}

const out: OAuthSessionClaims = { state, codeVerifier, return: returnUrl, mode };
if (linkPersonId) {
return { ...out, linkPersonId };
}
return out;
}
106 changes: 104 additions & 2 deletions apps/api/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import {
signOAuthSession,
verifyOAuthSession,
} from '../auth/oauth-session-cookie.js';
import { buildAuthorizeUrl, completeCallback } from '../auth/github-oauth.js';
import { buildAuthorizeUrl, completeCallback, completeLinkCallback } from '../auth/github-oauth.js';

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

function accountErrorRedirect(reply: FastifyReply, code: string): FastifyReply {
return reply.redirect(`/account?error=${encodeURIComponent(code)}`);
}

async function persistSessionMetadata(
fastify: FastifyInstance,
request: FastifyRequest,
Expand Down Expand Up @@ -207,9 +211,32 @@ export async function authRoutes(fastify: FastifyInstance): Promise<void> {
return loginErrorRedirect(reply, 'oauth_state_mismatch');
}

const isLinkMode = sessionClaims.mode === 'link';

if (!query.code) {
clearOAuthCookies(reply);
return loginErrorRedirect(reply, 'github_unreachable');
return isLinkMode
? accountErrorRedirect(reply, 'github_unreachable')
: loginErrorRedirect(reply, 'github_unreachable');
}

// Link mode: completely separate pipeline. No matching, no session
// mint — just bind the GitHub identity to the named Person and
// redirect back to /account.
if (isLinkMode && sessionClaims.linkPersonId) {
const linkOutcome = await completeLinkCallback({
fastify,
request,
code: query.code,
codeVerifier: sessionClaims.codeVerifier,
redirectUri: callbackRedirectUri(request),
linkPersonId: sessionClaims.linkPersonId,
});
clearOAuthCookies(reply);
if (linkOutcome.kind === 'error') {
return accountErrorRedirect(reply, linkOutcome.code);
}
return reply.redirect('/account?linked=github');
}

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

// ---------------------------------------------------------------------------
// POST /api/auth/link-github — initiate GitHub-link flow for current session
// ---------------------------------------------------------------------------
//
// Per specs/api/auth.md `POST /api/auth/link-github`. Auth-required. Signs
// a link-mode `cfp_oauth_session` cookie carrying the current personId,
// then 302s to GitHub OAuth. The callback at `/api/auth/github/callback`
// recognizes the mode and binds the GitHub identity to the signed-in
// Person instead of minting a new session.
// ---------------------------------------------------------------------------

fastify.post(
'/api/auth/link-github',
{
schema: {
tags: ['auth'],
summary: 'Link the current session to a GitHub identity',
querystring: {
type: 'object',
properties: { return: { type: 'string' } },
},
},
},
async (request, reply) => {
requireAuth(request, ['user']);
const cfg = fastify.config;
if (!cfg.GITHUB_OAUTH_CLIENT_ID || !cfg.GITHUB_OAUTH_CLIENT_SECRET) {
return accountErrorRedirect(reply, 'github_unreachable');
}

const personId = request.session.person?.id;
if (!personId) {
// requireAuth above already throws on no session; this is purely
// a type-narrowing guard for the linePersonId argument below.
throw new UnauthenticatedError('No session', 'no_session');
}

// Fast-fail before round-tripping to GitHub if already linked.
const person = fastify.inMemoryState.people.get(personId);
if (person && typeof person.githubUserId === 'number') {
return accountErrorRedirect(reply, 'github_already_linked');
}

const { return: returnParam } = request.query as { return?: string };
const returnPath = safeReturnPath(returnParam) === '/' ? '/account' : safeReturnPath(returnParam);

const state = generateCsrfState();
const codeVerifier = generatePkceVerifier();
const codeChallenge = pkceChallengeFromVerifier(codeVerifier);

const sessionToken = await signOAuthSession(
{
state,
codeVerifier,
return: returnPath,
mode: 'link',
linkPersonId: personId,
},
cfg.CFP_JWT_SIGNING_KEY,
);

setOAuthStateCookie(reply, state, cfg.NODE_ENV);
setOAuthSessionCookie(reply, sessionToken, cfg.NODE_ENV);

const url = buildAuthorizeUrl({
clientId: cfg.GITHUB_OAUTH_CLIENT_ID,
redirectUri: callbackRedirectUri(request),
state,
codeChallenge,
});

return reply.redirect(url);
},
);

// ---------------------------------------------------------------------------
// POST /api/auth/login — legacy password sign-in
//
Expand Down
29 changes: 29 additions & 0 deletions apps/api/src/services/github-account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,4 +179,33 @@ export class GitHubAccountService {

return { person: updated, stateApply, publicChanged };
}

/**
* Bind a GitHub identity to a Person that currently has none. Used by
* `POST /api/auth/link-github`'s callback branch. Per
* specs/behaviors/account-migration.md the link records the GitHub
* fields but does NOT refresh `PrivateProfile.email` in v1 — that
* requires a consent toggle on a link-confirmation screen that
* doesn't yet exist. The user's existing email-on-file stays.
*
* Caller is responsible for the conflict checks (the Person isn't
* already linked, and the GitHub identity isn't bound to a *different*
* Person); this method assumes both invariants hold.
*/
async linkToExisting(
tx: DualStoreTx,
existing: Person,
identity: ResolvedGitHubIdentity,
): Promise<{ person: Person; stateApply: StateApply }> {
const now = nowIso();
const updated: Person = PersonSchema.parse({
...existing,
githubUserId: identity.id,
githubLogin: identity.login,
githubLinkedAt: now,
updatedAt: now,
});
await tx.public.people.upsert(updated);
return { person: updated, stateApply: new StateApply().upsertPerson(updated) };
}
}
Loading