fix(sso): bind Microsoft sign-in to the IdP identity, not the email claim

SSO picked the local account by the email/preferred_username claim, which
Entra does not verify. Anyone able to present a matching email (another
tenant with a multi-tenant issuer, or an editable attribute) could sign in
as that user without a password.

- The first SSO login links the account to "<iss>|<oid>" (new unique
  column User.ssoSubject); afterwards only that identity signs in, and a
  matching email from another identity is refused.
- Multi-tenant issuers (common/organizations/consumers) disable SSO.
- Emails are compared case-insensitively and stored lower-case, so
  [email protected] and [email protected] can no longer be two accounts; the migration
  lower-cases existing emails where that does not collide.
- An admin changing a user's email clears the link.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 01:20:49 +02:00
1 parent c4a11b4704
commit a25265b88a
13 files changed
+239 -19

No files matched your search

+29 -1
View File
@@ -169,8 +169,33 @@ export class UsersService {
return this.toSummary(await this.findById(id));
}
/** Case-insensitive: "[email protected]" and "[email protected]" are one account. */
async findByEmail(email: string): Promise<UserWithRole | null> {
return this.prisma.user.findUnique({ where: { email }, ...USER_WITH_ROLE });
return this.prisma.user.findFirst({
where: { email: { equals: email.trim(), mode: 'insensitive' } },
...USER_WITH_ROLE,
});
}
async findBySsoSubject(ssoSubject: string): Promise<UserWithRole | null> {
return this.prisma.user.findUnique({
where: { ssoSubject },
...USER_WITH_ROLE,
});
}
async linkSsoSubject(userId: string, ssoSubject: string): Promise<void> {
await this.prisma.user.update({
where: { id: userId },
data: { ssoSubject },
});
await this.auditLog.record({
entityType: 'User',
entityId: userId,
action: 'UPDATE',
userId,
afterData: { ssoLinked: true },
});
}
async create(
@@ -233,6 +258,9 @@ export class UsersService {
name: dto.name,
email: dto.email,
roleId: dto.roleId,
// A new email is a new identity: the next Microsoft sign-in links
// afresh instead of reusing the old account's binding.
...(emailChanged ? { ssoSubject: null } : {}),
},
...USER_WITH_ROLE,
});