Files
familysync/apps/api/src/auth/linkOidc.ts
T
Lucas Berger efb80c8c1a feat(19-02): linkOidcToUser helper + POST /api/me/link-oidc initiation
apps/api/src/auth/linkOidc.ts (new):
- OidcLinkConflictError: thrown when iss+sub already belongs to a different user
- linkOidcToUser(userId, iss, sub): preflight SELECT for conflict, then db.transaction
  (UPDATE users SET oidc_iss/sub/claimed + DELETE local_credentials); atomic, no email (D-10)
- 88 lines; no email in source (D-10/T-19-08 assertion passes)

apps/api/src/routes/me.ts:
- POST /api/me/link-oidc: resolveUserId (401 if null), sign state JWT
  ({ linkUserId, nonce, iat, exp } HS256 with LOCAL_SESSION_SECRET, 10-min window)
- Returns { signedState, authorizationUrl } — 19-03 /callback reads linkUserId from state
- authorizationUrl constructed from OIDC env vars when configured, null otherwise
- T-19-09: per-request nonce in state prevents CSRF/replay
2026-06-17 16:38:14 -04:00

89 lines
4.0 KiB
TypeScript

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