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.
This commit is contained in:
Lucas Berger
2026-06-07 15:27:40 -04:00
parent 28704132d0
commit 23c8bb3402
3 changed files with 43 additions and 7 deletions
+9
View File
@@ -49,6 +49,15 @@ export async function upsertUser(
.limit(1) .limit(1)
if (existing[0]) { if (existing[0]) {
// If the existing row has no displayName but the caller supplies one, update it now.
// This corrects rows created before robust claim derivation was in place (BUG 2 fix).
if (!existing[0].displayName && displayName) {
await db
.update(users)
.set({ displayName })
.where(eq(users.id, existing[0].id))
return { ...existing[0], displayName }
}
return existing[0] return existing[0]
} }
+12 -2
View File
@@ -65,9 +65,19 @@ async function resolveUserId(c: any): Promise<number | null> {
const iss = (auth.iss as string | undefined) ?? '' const iss = (auth.iss as string | undefined) ?? ''
const sub = auth.sub ?? '' const sub = auth.sub ?? ''
const email = typeof auth.email === 'string' ? auth.email : undefined
const user = await upsertUser(iss, sub, email) // Derive displayName with same preference order as me.ts (name → preferred_username
// → email → sub fallback). Both call sites must agree so a write-path upsert does not
// overwrite a correctly-derived name with a worse one.
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)
return user?.id ?? null return user?.id ?? null
} }
+22 -5
View File
@@ -4,8 +4,9 @@
* Flow (normal — OIDC active): * Flow (normal — OIDC active):
* 1. getAuth(c) reads iss + sub from the OIDC session JWT cookie * 1. getAuth(c) reads iss + sub from the OIDC session JWT cookie
* (validated and refreshed by oidcAuthMiddleware — never reaches here unauthenticated) * (validated and refreshed by oidcAuthMiddleware — never reaches here unauthenticated)
* 2. upsertUser(iss, sub, email) writes the user row on first visit, returns * 2. Derives displayName from OIDC claims (name → preferred_username → email → sub fallback)
* the existing row on subsequent visits (idempotent, keyed on iss+sub, D-10) * 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 } } * 3. Returns { user: { id, displayName, color } }
* *
* Flow (dev bypass — DEV_AUTH_BYPASS=true, non-production): * Flow (dev bypass — DEV_AUTH_BYPASS=true, non-production):
@@ -48,12 +49,28 @@ meRouter.get('/', async (c) => {
return c.json({ error: 'Unauthorized' }, 401) return c.json({ error: 'Unauthorized' }, 401)
} }
// iss and sub are the stable identity fields; email is a display hint only (D-10) // 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 iss = (auth.iss as string | undefined) ?? ''
const sub = auth.sub ?? '' const sub = auth.sub ?? ''
const email = typeof auth.email === 'string' ? auth.email : undefined
const user = await upsertUser(iss, sub, email) // 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) { if (!user) {
return c.json({ error: 'Could not resolve user' }, 500) return c.json({ error: 'Could not resolve user' }, 500)