feat(oidc): use the picture claim as avatar when none is uploaded

When a user signs in via OIDC and hasn't uploaded a custom avatar, their
`picture` claim is now used as their avatar. The users.avatar column holds
either an uploaded file name or an absolute https URL from the claim, and a
single resolver on each side (server avatarUrl, client avatarSrc) renders
both. An uploaded avatar always wins and is never overwritten; the picture
refreshes on each login otherwise. Only https URLs are stored, matching the
image CSP.

All the scattered /uploads/avatars/ builders now go through the resolvers,
which also fixes collection member avatars that were rendering a bare file
name.

Discussion #1399
This commit is contained in:
Maurice
2026-07-02 11:16:49 +02:00
committed by Maurice
parent 05269f0710
commit bfc857d246
22 changed files with 193 additions and 27 deletions
+25 -3
View File
@@ -32,6 +32,8 @@ export interface OidcUserInfo {
email_verified?: boolean | string;
name?: string;
preferred_username?: string;
// Standard OIDC profile claim: URL of the user's profile picture.
picture?: string;
groups?: string[];
roles?: string[];
[key: string]: unknown;
@@ -355,6 +357,17 @@ export async function verifyIdToken(
// Find or create user by OIDC sub / email
// ---------------------------------------------------------------------------
// Sanitize the OIDC `picture` claim before we store it as the avatar. Only https
// URLs are usable: the app's CSP allows https image sources but not http, and we
// render the value directly. Non-strings, non-https and oversized values (e.g. a
// large data: URI) are ignored so a user payload never carries junk. #1399
function safeOidcPicture(picture: unknown): string | null {
if (typeof picture !== 'string') return null;
const url = picture.trim();
if (!url || url.length > 1024) return null;
return /^https:\/\//i.test(url) ? url : null;
}
export function findOrCreateUser(
userInfo: OidcUserInfo,
config: OidcConfig,
@@ -363,6 +376,7 @@ export function findOrCreateUser(
const email = userInfo.email!.trim().toLowerCase();
const name = userInfo.name || userInfo.preferred_username || email.split('@')[0];
const sub = userInfo.sub;
const picture = safeOidcPicture(userInfo.picture);
// Try to find existing user by sub, then by email
let user = db.prepare('SELECT * FROM users WHERE oidc_sub = ? AND oidc_issuer = ?').get(sub, config.issuer) as User | undefined;
@@ -402,6 +416,14 @@ export function findOrCreateUser(
}
}
}
// Keep the avatar in sync with the OIDC picture, but never clobber a custom
// upload: only fill it when empty or when the current value is itself an OIDC
// picture URL, so the picture refreshes on each login without overriding an
// uploaded one. #1399
if (picture && picture !== user.avatar && (!user.avatar || /^https:\/\//i.test(user.avatar))) {
db.prepare('UPDATE users SET avatar = ? WHERE id = ?').run(picture, user.id);
user = { ...user, avatar: picture } as User;
}
return { user };
}
@@ -452,11 +474,11 @@ export function findOrCreateUser(
if (updated.changes === 0) throw inviteRaceError;
}
return db.prepare(
'INSERT INTO users (username, email, password_hash, role, oidc_sub, oidc_issuer, first_seen_version, login_count) VALUES (?, ?, ?, ?, ?, ?, ?, 0)',
).run(username, email, hash, role, sub, config.issuer, process.env.APP_VERSION || '0.0.0');
'INSERT INTO users (username, email, password_hash, role, oidc_sub, oidc_issuer, avatar, first_seen_version, login_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)',
).run(username, email, hash, role, sub, config.issuer, picture, process.env.APP_VERSION || '0.0.0');
});
const result = createUser() as { lastInsertRowid: number | bigint };
user = { id: Number(result.lastInsertRowid), username, email, role } as User;
user = { id: Number(result.lastInsertRowid), username, email, role, avatar: picture } as User;
return { user };
} catch (err) {
if (err === inviteRaceError) {