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:
1 parent
c4a11b4704
commit
a25265b88a
13 files changed
+239
-19
No files matched your search
@@ -1,8 +1,10 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail, IsString, MinLength } from 'class-validator';
|
||||
import { NormalizeEmail } from '../../common/validation/normalize-email';
|
||||
|
||||
export class CreateUserDto {
|
||||
@ApiProperty()
|
||||
@NormalizeEmail()
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEmail, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
import { NormalizeEmail } from '../../common/validation/normalize-email';
|
||||
|
||||
export class UpdateUserDto {
|
||||
@ApiPropertyOptional()
|
||||
@@ -16,6 +17,7 @@ export class UpdateUserDto {
|
||||
*/
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@NormalizeEmail()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ describe('UsersService', () => {
|
||||
user: {
|
||||
findMany: jest.Mock;
|
||||
findUnique: jest.Mock;
|
||||
findFirst: jest.Mock;
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
count: jest.Mock;
|
||||
@@ -73,6 +74,11 @@ describe('UsersService', () => {
|
||||
user: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
// Email lookups are case-insensitive (findFirst); the tests stub
|
||||
// both lookups through findUnique.
|
||||
findFirst: jest.fn(
|
||||
(args: unknown): unknown => prisma.user.findUnique(args) as unknown,
|
||||
),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
count: jest.fn(),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user