Files
familysync/apps/api/src/routes/me.ts
T
Lucas Berger 23c8bb3402 fix(260607-l6l): derive displayName from OIDC claims in me.ts + resolveUserId
BUG 2: Both me.ts and events.ts resolveUserId were passing email (often
absent) as displayName to upsertUser, resulting in blank legend names.
Also, upsertUser returned existing rows unchanged even when displayName
was null and a better value was now available.

- me.ts: derive displayName via name → preferred_username → email →
  "Member <sub-prefix>" fallback, checked defensively. Updated JSDoc.
- events.ts resolveUserId: same derivation so write-path upserts don't
  re-blank a correctly-set displayName.
- user.ts: when existing row has null displayName and caller supplies one,
  issue an UPDATE so already-existing blank rows are corrected on next login.

Authelia-side emission of name/preferred_username is an operator concern
(claim mappings / userinfo scope in authelia config) — out of scope here.
The code now reads whatever claims are present and falls back sensibly.
2026-06-07 15:27:40 -04:00

87 lines
3.5 KiB
TypeScript

/**
* GET /api/me — returns the authenticated user's identity and assigned color.
*
* Flow (normal — OIDC active):
* 1. getAuth(c) reads iss + sub from the OIDC session JWT cookie
* (validated and refreshed by oidcAuthMiddleware — never reaches here unauthenticated)
* 2. Derives displayName from OIDC claims (name → preferred_username → email → sub fallback)
* then calls upsertUser(iss, sub, displayName) — writes on first visit, corrects a
* previously blank displayName on subsequent visits (idempotent, keyed on iss+sub, D-10)
* 3. Returns { user: { id, displayName, color } }
*
* Flow (dev bypass — DEV_AUTH_BYPASS=true, non-production):
* devAuthBypass() injects DEV_USER via c.set('user', DEV_USER). oidcAuthMiddleware
* is NOT mounted in index.ts when the bypass is active, so getAuth(c) is never called.
* This handler reads c.get('user') first and short-circuits to return the dev identity
* directly, skipping the DB upsert.
*
* The OIDC session cookie is httpOnly + Secure + SameSite (T-02-03).
* No credential or refresh-token data is included in the response (T-02-04).
*/
import { Hono } from 'hono'
import { getAuth } from '../auth/middleware.js'
import { upsertUser } from '../auth/user.js'
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
import '../auth/devBypass.js'
export const meRouter = new Hono()
meRouter.get('/', async (c) => {
// Dev-auth bypass path: devAuthBypass() sets c.get('user') to DEV_USER when active.
// Return the injected dev identity directly — no DB round-trip, no OIDC session needed.
const devUser = c.get('user')
if (devUser) {
return c.json({
user: {
id: devUser.id,
displayName: devUser.displayName,
color: devUser.color,
},
})
}
// Normal OIDC path: getAuth returns null only if the session is invalid.
// oidcAuthMiddleware on /api/* redirects unauthenticated requests before this handler
// is reached, so null here indicates a genuine session error.
const auth = await getAuth(c)
if (!auth) {
return c.json({ error: 'Unauthorized' }, 401)
}
// iss and sub are the stable identity fields — identity is always keyed on iss+sub (D-10).
const iss = (auth.iss as string | undefined) ?? ''
const sub = auth.sub ?? ''
// 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. sub — always present; not human-friendly but never blank
//
// Each candidate is tested defensively — Authelia may omit or blank-out any claim.
// Whether Authelia emits name/preferred_username is an operator configuration concern
// (e.g. userinfo scope, claim mappings in authelia config) — out of scope here.
const claimStr = (v: unknown): string | undefined =>
typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined
const displayName =
claimStr(auth.name) ??
claimStr(auth.preferred_username) ??
claimStr(auth.email) ??
`Member ${String(sub).slice(0, 8)}`
const user = await upsertUser(iss, sub, displayName)
if (!user) {
return c.json({ error: 'Could not resolve user' }, 500)
}
return c.json({
user: {
id: user.id,
displayName: user.displayName,
color: user.color,
},
})
})