fix(oidc): a sign-in links to an account by email only when the address was not set by the account itself

An SSO login that matched no account by its subject took the account with the
same email address and linked the provider identity to it. That address proves
nothing when the account typed it in itself: anyone could sign up with, or
switch their profile to, a colleague's address and receive the colleague's
first SSO login. The provider switch branch (#2110) relinked a row on any email
match without even asking for email_verified, and also for another subject of
the same provider.

users.email_self_asserted remembers an address the account set itself: signup
(not the first account) and an email change in the profile settings set it, an
admin entering a new address clears it, and accounts the provider creates start
without it. Existing accounts keep linking, except those whose address moved
away from the registered one without an admin editing it. A login by email now
needs a verified address and an unmarked account, and is refused for another
subject of the same issuer, with email_link_blocked and a translated message on
the login screen. An email change in the profile settings writes a
user.email_change audit row.

Refs #2110
This commit is contained in:
Maurice
2026-10-12 17:06:52 +02:00
parent 1b58acc78f
commit 3d7e8be9aa
49 changed files with 688 additions and 29 deletions
+2
View File
@@ -115,6 +115,7 @@ export class Users {
airtrail_write_enabled?: number | null = 0;
display_name?: string | null;
immich_allow_insecure_tls: number & Opt = 0;
email_self_asserted: number & Opt = 0;
assignment_participants_collection = new Collection<AssignmentParticipants>(this);
audit_log_collection = new Collection<AuditLog>(this);
bucket_list_collection = new Collection<BucketList>(this);
@@ -255,6 +256,7 @@ export const UsersSchema = defineEntity({
airtrail_write_enabled: p.integer().nullable(),
display_name: p.text().nullable(),
immich_allow_insecure_tls: p.integer().default(0),
email_self_asserted: p.integer().default(0),
assignment_participants_collection: () => p.oneToMany(AssignmentParticipants).mappedBy('user').hidden(),
audit_log_collection: () => p.oneToMany(AuditLog).mappedBy('user').hidden(),
bucket_list_collection: () => p.oneToMany(BucketList).mappedBy('user').hidden(),
+1
View File
@@ -43,4 +43,5 @@ export interface UsersTable {
airtrail_write_enabled: InsertOptional<number | null>;
display_name: InsertOptional<string | null>;
immich_allow_insecure_tls: InsertOptional<number>;
email_self_asserted: InsertOptional<number>;
}
@@ -0,0 +1,80 @@
import { addColumnIfMissing } from '../migration-utils';
import { Migration } from '@mikro-orm/migrations';
interface UserEmailRow {
id: number;
email: string;
}
interface AuditRow {
user_id: number | null;
resource: string | null;
details: string | null;
}
/** The parsed `details` of an audit row, or null for an empty or broken one. */
function detailsOf(row: AuditRow): Record<string, unknown> | null {
if (!row.details) return null;
try {
const parsed: unknown = JSON.parse(row.details);
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : null;
} catch {
return null;
}
}
/**
* Remembers whether an account's email address is one the account typed in
* itself.
*
* An SSO sign-in that matches no linked account falls back to the email
* address and links the provider identity to whichever account holds it. That
* address proves nothing when the account chose it at signup or in its profile
* settings: anyone could put a colleague's address on their own account and
* receive the colleague's first SSO sign-in. `email_self_asserted` is 1 for
* such an address and keeps the sign-in from linking to it; an address an admin
* set, or one the provider created the account with, stays 0.
*
* Existing accounts start at 0, so instances keep linking the way they did.
* The exception is an account whose address provably moved away from the one
* it registered with (its `user.register` audit row) without an admin ever
* editing its email (an `admin.user_update` row naming `email`): only the
* account itself can have changed it. The audit details are JSON text, read
* and compared here rather than in SQL.
*/
export class Migration20200101042800_an_email_the_account_typed_in_itself extends Migration {
override name = 'Migration20200101042800_an_email_the_account_typed_in_itself';
override async up(): Promise<void> {
await addColumnIfMissing(this, 'users', 'email_self_asserted', 'email_self_asserted INTEGER NOT NULL DEFAULT 0');
const registrations = (await this.execute(
`SELECT user_id, resource, details FROM audit_log WHERE action = 'user.register' AND user_id IS NOT NULL`,
)) as AuditRow[];
if (registrations.length === 0) return;
const registeredWith = new Map<number, string[]>();
for (const row of registrations) {
const email = detailsOf(row)?.email;
if (typeof email !== 'string' || row.user_id === null) continue;
registeredWith.set(row.user_id, [...(registeredWith.get(row.user_id) ?? []), email.trim().toLowerCase()]);
}
const adminEdits = (await this.execute(
`SELECT user_id, resource, details FROM audit_log WHERE action = 'admin.user_update'`,
)) as AuditRow[];
const editedByAdmin = new Set<string>();
for (const row of adminEdits) {
const fields = detailsOf(row)?.fields;
if (row.resource && Array.isArray(fields) && fields.includes('email')) editedByAdmin.add(row.resource);
}
const users = (await this.execute(`SELECT id, email FROM users WHERE COALESCE(is_guest, 0) = 0`)) as UserEmailRow[];
for (const user of users) {
const registered = registeredWith.get(user.id);
if (!registered || editedByAdmin.has(String(user.id))) continue;
if (registered.includes(user.email.trim().toLowerCase())) continue;
await this.execute('UPDATE users SET email_self_asserted = 1 WHERE id = ?', [user.id]);
}
}
}
+10 -11
View File
@@ -63,6 +63,7 @@ export interface UserRow {
airtrail_write_enabled: number | null;
display_name: string | null;
immich_allow_insecure_tls: number;
email_self_asserted: number;
}
/**
@@ -85,6 +86,8 @@ export interface NewUserRow {
oidc_sub?: string | null;
oidc_issuer?: string | null;
avatar?: string | null;
/** 1 for an address typed into the signup form (nobody vouches for it), 0 for the first account and SSO ones. */
email_self_asserted?: number;
}
/**
@@ -119,6 +122,7 @@ export interface UserProfilePatch {
amap_api_key?: string | null;
username?: string;
email?: string;
email_self_asserted?: number;
}
/** `UserProfileService.getApiKeyColumns`/`currentKeys` (UP1). */
@@ -280,6 +284,7 @@ export interface AdminEditPatch {
role?: string;
password_hash?: string;
password_version?: number;
email_self_asserted?: number;
}
export class UsersRepository extends TrekRepository<Users> {
@@ -474,6 +479,7 @@ export class UsersRepository extends TrekRepository<Users> {
oidc_sub: row.oidc_sub ?? null,
oidc_issuer: row.oidc_issuer ?? null,
avatar: row.avatar ?? null,
email_self_asserted: row.email_self_asserted ?? 0,
}),
);
const inserted = await this.findById(id);
@@ -990,17 +996,10 @@ export class UsersRepository extends TrekRepository<Users> {
// ---------------------------------------------------------------------
/**
* `` UPDATE users SET ${updates.join(', ')}, updated_at = CURRENT_TIMESTAMP
* WHERE id = ? `` — the SET clause is built at the CALL SITE from a fixed
* set of 6 possible literal column names, never here: `patchProfile` takes
* only the already-decided final columns as a typed partial and writes
* them in ONE `nativeUpdate` (never `assign`+`flush`, which would be a
* second statement shape for the identical intent) — the entity-API
* equivalent of the legacy's dynamic `SET` list, without ever building SQL
* text. `updated_at` is always stamped, matching the legacy: this method's
* only caller (`UserProfileService.updateSettings`) calls it only when at
* least one field changed (`updates.length > 0`), the same guard the
* legacy statement was built under.
* `` UPDATE users SET ${updates.join(', ')}, updated_at = CURRENT_TIMESTAMP WHERE id = ? ``: the
* caller decides the columns of {@link UserProfilePatch}, this writes them in ONE `nativeUpdate`
* (never `assign`+`flush`) and never builds SQL text. `updated_at` is always stamped, as in the
* legacy; the only caller (`UserProfileService.updateSettings`) calls it only when a field changed.
*/
async patchProfile(id: number, changes: UserProfilePatch): Promise<void> {
const platform = this.getEntityManager().getPlatform();