- 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>
182 lines
7.7 KiB
TypeScript
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;
|
|
}
|