Files
familysync/apps/api/src/auth/user.ts
T
Lucas BergerandClaude Sonnet 4.6 687f9dc9fa fix(12): WR-01 narrow TOCTOU guard and set claimed=true for OIDC inserts
- apps/api/src/auth/user.ts: upsertUser step-5 insert now sets claimed=true
  for all OIDC-created users. An identity-bound OIDC user is never a pending
  wizard bootstrap user; explicit claimed=true prevents ambiguity with the
  (oidcIss IS NULL AND claimed=false) sentinel used by the TOCTOU guard and
  isSetupLocked. First-login-claims path is unaffected (it updates a
  pre-existing oidcIss=null row; this change only touches the fresh insert).

- apps/api/src/routes/setup.ts: TOCTOU guard in POST /credential now queries
  WHERE oidc_iss IS NULL AND claimed = false FOR UPDATE, matching the exact
  definition of a pending wizard bootstrap user. This provides defense-in-depth
  against any future path that could produce claimed=false OIDC rows.

- apps/api/tests/auth/user.test.ts: new WR-01 test asserts that the fresh
  OIDC insert sets claimed=true in the values passed to db.insert().

- apps/api/tests/routes/setup.test.ts: new WR-01 integration test seeds an
  OIDC user with claimed=false (oidcIss NOT NULL) and verifies POST /credential
  still succeeds (guard ignores the OIDC row, only counts local wizard rows).

All 402 API tests, 253 PWA tests, and typecheck pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 16:46:41 -04:00

182 lines
7.7 KiB
TypeScript

/**
* User identity upsert + stable per-member color assignment.
*
* Identity is keyed on oidc_iss + oidc_sub — never email (D-10).
* Color is auto-assigned from a curated palette on first login, round-robin
* by join order (D-06). Stable across sessions: re-upsert returns same row.
*
* Source: RESEARCH.md § "User upsert with color assignment"
*/
import { and, eq, isNull, sql } from 'drizzle-orm';
import { db } from '../db/client.js';
import { users, appConfig } from '../db/schema.js';
/**
* Accessible, visually-distinct palette for per-member member-color assignment.
* A new member is given the first entry not already in use (see upsertUser).
*
* Ordering matters: the SHARED-family calendar is reserved rose (#F25C7A, D-06),
* so the warm near-rose hues (coral, amber) are placed LAST. Early members get
* cool colors (blue, green, teal) that read clearly distinct from the shared
* lane — otherwise a member's coral was mistaken for the shared rose.
* Values are Claude's choice per D-06.
*/
export const COLOR_PALETTE: string[] = [
'#4A90D9', // calm blue
'#5BA85A', // forest green
'#3AAFA9', // teal
'#9B6DC5', // soft purple
'#E8A840', // warm amber (near shared rose — assigned only after cool colors)
'#E8734A', // warm coral (closest to shared rose — assigned last)
];
/** Coerce an OIDC claim to a trimmed non-empty string, else undefined. */
const claimStr = (v: unknown): string | undefined =>
typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined;
/**
* Derive the best available display name from OIDC claims, in preference order:
* 1. name — full name set by the IdP (most human-friendly)
* 2. preferred_username — often the login handle; still readable
* 3. email — readable but reveals contact info; acceptable fallback
* 4. null — no usable claim; the UI degrades to a generic "Member"
*
* Returns null (NOT a synthetic `Member <sub>`) when no real claim is present so
* we never persist an ugly sub-derived string. Whether Authelia emits
* name/preferred_username/email in the ID TOKEN (not just at the userinfo
* endpoint) is an operator config concern: Authelia 4.39+ requires a
* `claims_policies` entry adding those claims to `id_token` for this client,
* because @hono/oidc-auth reads ID-token claims only (no userinfo fetch). Until
* that is set, every claim here is absent and the legend shows "Member".
*
* Shared by every call site that upserts a user (me.ts, events.ts resolveUserId)
* so the write-path upsert agrees with /api/me.
*/
export function deriveDisplayName(claims: {
name?: unknown;
preferred_username?: unknown;
email?: unknown;
}): string | null {
return (
claimStr(claims.name) ?? claimStr(claims.preferred_username) ?? claimStr(claims.email) ?? null
);
}
/**
* Upsert a user by their OIDC identity (iss + sub).
*
* - If a row with matching (oidc_iss, oidc_sub) exists, return it unchanged.
* - Otherwise, count current users to pick the next palette color, insert a
* new row, then re-select and return it.
*
* Never keys on email or displayName for identity. displayName is stored as a
* display hint only and may change without affecting identity.
*/
export async function upsertUser(oidcIss: string, oidcSub: string, displayName?: string | null) {
// 1. Look up by composite identity key (iss + sub) — never email
const existing = await db
.select()
.from(users)
.where(and(eq(users.oidcIss, oidcIss), eq(users.oidcSub, oidcSub)))
.limit(1);
if (existing[0]) {
// Track the IdP display name authoritatively: when the caller supplies a
// real (non-null) name that differs from what's stored, update it. This both
// corrects rows created before robust claim derivation (blank → name) and
// self-heals once an operator adds the name/email claims to Authelia's ID
// token (e.g. a stale "Member …"/email → the real name) — no DB surgery.
// A null displayName (no usable claim this request) never overwrites a good
// stored value.
if (displayName != null && displayName !== existing[0].displayName) {
await db.update(users).set({ displayName }).where(eq(users.id, existing[0].id));
return { ...existing[0], displayName };
}
return existing[0];
}
// 2. Check app_config.setup_complete and attempt first-login-claims (D-08).
// When setup is complete, the first OIDC login from an unknown identity claims
// the single unclaimed local user (oidcIss IS NULL AND claimed=false), binding
// the OIDC identity to the wizard-provisioned row. This preserves is_admin and
// the CalDAV credential stored by the wizard.
// MUST NOT match by email — query is strictly isNull(oidcIss) AND claimed=false (D-10).
const [flagRow] = await db
.select({ value: appConfig.value })
.from(appConfig)
.where(eq(appConfig.key, 'setup_complete'))
.limit(1);
if (flagRow?.value === 'true') {
const [unclaimed] = await db
.select()
.from(users)
.where(and(isNull(users.oidcIss), eq(users.claimed, false)))
.limit(1);
if (unclaimed) {
// Claim: bind the OIDC identity, mark claimed=true, update displayName if provided.
// is_admin is NOT overwritten — it was pre-set by the wizard (operator's intent).
await db
.update(users)
.set({
oidcIss,
oidcSub,
claimed: true,
displayName: displayName ?? unclaimed.displayName,
})
.where(eq(users.id, unclaimed.id));
return { ...unclaimed, oidcIss, oidcSub, claimed: true };
}
}
// 3. Assign the first palette color NOT already in use by another member.
// A plain COUNT(*) % palette collides under deletions: a deleted user
// shifts the count so the next insert reuses an in-use slot (observed in
// Gate 2 — two members both got #E8734A). Selecting the first unused color
// guarantees distinct, stable colors for up to COLOR_PALETTE.length members
// (AUTH-03). Falls back to round-robin by count only once the palette is
// exhausted (more members than colors).
const usedRows = await db.select({ color: users.color }).from(users);
const usedColors = new Set(usedRows.map((r) => r.color));
const color =
COLOR_PALETTE.find((c) => !usedColors.has(c)) ??
COLOR_PALETTE[usedColors.size % COLOR_PALETTE.length];
// 4. First-login-wins is_admin bootstrap (D-01), tightened by Phase 12 (D-08).
// When setup_complete is true, a claimed admin already exists — new users must
// NOT auto-promote. Only grant admin when setup is not yet complete AND no admins
// exist (the original first-login-wins bootstrap for fresh pre-wizard instances).
const [{ count }] = await db
.select({ count: sql<number>`COUNT(*)` })
.from(users)
.where(eq(users.isAdmin, true))
.limit(1);
const shouldBeAdmin = flagRow?.value !== 'true' && Number(count) === 0;
// 5. Insert new user row
// claimed=true: an OIDC-created user is identity-bound at insert time and is
// never a pending wizard bootstrap user. Setting this explicitly prevents the
// TOCTOU guard in POST /credential (which counts WHERE oidc_iss IS NULL AND
// claimed = false) from ever treating a fresh OIDC insert as an unclaimed
// wizard row (WR-01).
// mysql2 has no RETURNING clause — use $returningId() then re-select
const [inserted] = await db
.insert(users)
.values({
oidcIss,
oidcSub,
displayName: displayName ?? null,
color,
isAdmin: shouldBeAdmin,
claimed: true,
})
.$returningId();
// 6. Re-select to return the full typed row
const [newUser] = await db.select().from(users).where(eq(users.id, inserted.id)).limit(1);
return newUser;
}