/** * 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)); }); }