diff --git a/apps/api/src/auth/linkOidc.ts b/apps/api/src/auth/linkOidc.ts new file mode 100644 index 0000000..a11f8c2 --- /dev/null +++ b/apps/api/src/auth/linkOidc.ts @@ -0,0 +1,88 @@ +/** + * linkOidc.ts — OIDC-link binding helper (AUTH-LOCAL-10, D-12). + * + * Exports: + * - OidcLinkConflictError: thrown when iss+sub already belongs to a DIFFERENT user. + * - linkOidcToUser(userId, iss, sub): binds oidc_iss+oidc_sub to the user row and + * deletes their local_credentials row in an atomic transaction. + * + * Security contract (T-19-08): + * - Preflight SELECT checks iss+sub uniqueness BEFORE any write. + * - If a different user already owns iss+sub: throw OidcLinkConflictError; NO write occurs. + * - db.transaction wraps the UPDATE users + DELETE local_credentials so both succeed or + * neither does — no partial state where oidc is bound but local cred survives or vice versa. + * - Identity binding uses iss+sub ONLY — never the user's address field (D-10). + * - The uniq_oidc_identity DB constraint on users is the backstop behind the preflight + * (RESEARCH Pitfall 6 — preflight prevents the race-case before hitting the constraint). + * + * Called by: + * - apps/api/src/routes/me.ts POST /link-oidc (initiates OIDC flow, state carries userId) + * - apps/api/src/routes/localAuth.ts /callback (19-03) — reads linkUserId from state, + * calls linkOidcToUser after verifying the OIDC token. + */ + +import { and, eq, ne } from 'drizzle-orm'; +import { db } from '../db/client.js'; +import { users, localCredentials } from '../db/schema.js'; + +/** + * Thrown by linkOidcToUser when iss+sub already belongs to a DIFFERENT user. + * + * Callers should translate this to a 409 response or error-redirect to the PWA. + * The thrown error deliberately carries no raw iss/sub values to avoid leaking + * identity correlation info in error logs (T-19-08). + */ +export class OidcLinkConflictError extends Error { + readonly name = 'OidcLinkConflictError'; + + constructor() { + super('OIDC identity already linked to a different account'); + Object.setPrototypeOf(this, OidcLinkConflictError.prototype); + } +} + +/** + * Bind an OIDC identity (iss+sub) to the given userId and delete that user's + * local_credentials row (D-12: OIDC-link replaces local credential). + * + * Steps: + * 1. Preflight SELECT: check if iss+sub belongs to a user with id ≠ userId. + * If so: throw OidcLinkConflictError (NO writes). + * 2. db.transaction: + * a. UPDATE users SET oidc_iss=iss, oidc_sub=sub, claimed=true WHERE id=userId + * b. DELETE FROM local_credentials WHERE user_id=userId + * (user becomes OIDC-only; no local credential remains) + * + * D-10 constraint: binding is strictly iss+sub — no address claim or contact field used. + * T-19-08: abort before any write on conflict; uniq_oidc_identity constraint is backstop. + * + * @throws OidcLinkConflictError if iss+sub is already owned by a DIFFERENT userId. + */ +export async function linkOidcToUser(userId: number, iss: string, sub: string): Promise { + // Preflight: check if another user already holds this iss+sub (T-19-08 / RESEARCH Pitfall 6) + // We SELECT WHERE oidc_iss=iss AND oidc_sub=sub AND id ≠ userId — only a DIFFERENT user is a conflict. + // If the same userId already has iss+sub (idempotent re-link): allow the UPDATE to proceed. + const [conflicting] = await db + .select({ id: users.id }) + .from(users) + .where(and(eq(users.oidcIss, iss), eq(users.oidcSub, sub), ne(users.id, userId))) + .limit(1); + + if (conflicting) { + // iss+sub belongs to a DIFFERENT user — abort before any write (T-19-08) + throw new OidcLinkConflictError(); + } + + // Atomic: UPDATE users + DELETE local_credentials — both or neither (D-12) + await db.transaction(async (tx) => { + // a. Bind the OIDC identity and mark the user as claimed + await tx + .update(users) + .set({ oidcIss: iss, oidcSub: sub, claimed: true }) + .where(eq(users.id, userId)); + + // b. Delete the local_credentials row — user is now OIDC-only (D-12) + // Silently succeeds even if no local_credentials row exists (DELETE 0 rows is fine) + await tx.delete(localCredentials).where(eq(localCredentials.userId, userId)); + }); +} diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts index 5cd2640..6587312 100644 --- a/apps/api/src/routes/me.ts +++ b/apps/api/src/routes/me.ts @@ -30,6 +30,8 @@ import type { Context } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; import { eq } from 'drizzle-orm'; +import { Jwt } from 'hono/utils/jwt'; +import { randomBytes } from 'node:crypto'; import { getAuth } from '../auth/middleware.js'; import { upsertUser, deriveDisplayName } from '../auth/user.js'; import { db } from '../db/client.js'; @@ -274,3 +276,66 @@ meRouter.post( } }, ); + +// --------------------------------------------------------------------------- +// POST /api/me/link-oidc — initiate OIDC identity-link flow (AUTH-LOCAL-10, T-19-09) +// +// Purpose: initiates the OIDC authorization-code flow for the currently-authenticated +// local user. The current userId is encoded in a signed OIDC `state` parameter so that +// the /callback handler (plan 19-03) can bind the returned iss+sub to this user. +// +// Security contract (T-19-09 — OIDC-link CSRF): +// - state payload: { linkUserId, nonce } signed with LOCAL_SESSION_SECRET (HS256) +// - nonce: 16-byte random hex string per request — prevents state replay +// - Only the user encoded in `state.linkUserId` is bound on callback (T-19-09) +// +// Returns: +// { signedState: string, authorizationUrl: string | null } +// signedState: JWT for the OIDC state parameter (plan 19-03 /callback reads this) +// authorizationUrl: OIDC authorization endpoint URL with state, or null if OIDC not configured +// +// The PWA (Surface 13) redirects the user to authorizationUrl. The actual binding +// (UPDATE users + DELETE local_credentials) happens in the /callback handler (19-03). +// --------------------------------------------------------------------------- + +meRouter.post('/link-oidc', async (c) => { + const currentUserId = await resolveUserId(c); + if (!currentUserId) { + return c.json({ error: 'Unauthorized' }, 401); + } + + const secret = process.env.LOCAL_SESSION_SECRET; + if (!secret) { + return c.json({ error: 'Service unavailable' }, 503); + } + + // Produce a signed state token encoding the current userId + a per-request nonce + // T-19-09: nonce prevents state replay attacks (each link attempt gets a fresh nonce) + const nonce = randomBytes(16).toString('hex'); + const now = Math.floor(Date.now() / 1000); + const signedState = await Jwt.sign( + { linkUserId: currentUserId, nonce, iat: now, exp: now + 600 }, // 10-minute window + secret, + 'HS256', + ); + + // Build the OIDC authorization URL if OIDC is configured (else return null — PWA disables button) + const issuer = process.env.OIDC_ISSUER ?? null; + const clientId = process.env.OIDC_CLIENT_ID ?? null; + const redirectUri = process.env.OIDC_REDIRECT_URI ?? null; + + let authorizationUrl: string | null = null; + if (issuer && clientId && redirectUri) { + // Construct the authorization URL. plan 19-03 will handle the full PKCE flow; + // for now encode the signed state so the callback can read linkUserId. + const url = new URL(`${issuer.replace(/\/$/, '')}/api/oidc/authorization`); + url.searchParams.set('response_type', 'code'); + url.searchParams.set('client_id', clientId); + url.searchParams.set('redirect_uri', redirectUri); + url.searchParams.set('scope', 'openid profile email'); + url.searchParams.set('state', signedState); + authorizationUrl = url.toString(); + } + + return c.json({ signedState, authorizationUrl }, 200); +});