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

@@ -0,0 +1,15 @@
-- Microsoft sign-in is bound to the IdP identity ("<iss>|<oid>"), set on the
-- first SSO login; the unverified email claim no longer picks the account.
ALTER TABLE "User" ADD COLUMN "ssoSubject" TEXT;
CREATE UNIQUE INDEX "User_ssoSubject_key" ON "User"("ssoSubject");
-- Emails are compared case-insensitively from now on. Store existing ones
-- lower-case, except where that would collide with another account - those
-- stay as they are and are still found by the case-insensitive lookup.
UPDATE "User" AS u
SET "email" = lower(u."email")
WHERE u."email" <> lower(u."email")
AND NOT EXISTS (
SELECT 1 FROM "User" AS o
WHERE o."id" <> u."id" AND lower(o."email") = lower(u."email")
);
+5
View File
@@ -88,6 +88,11 @@ model User {
// nach einer Passwortaenderung nicht bis zu 15 Minuten weiter funktioniert. // nach einer Passwortaenderung nicht bis zu 15 Minuten weiter funktioniert.
passwordChangedAt DateTime? passwordChangedAt DateTime?
// Unveraenderliche Identitaet beim Identity Provider ("<iss>|<oid>"),
// gesetzt bei der ersten Microsoft-Anmeldung. Danach entscheidet nur sie,
// nicht mehr der (unverifizierte) E-Mail-Claim, welches Konto sich anmeldet.
ssoSubject String? @unique
refreshTokens RefreshToken[] refreshTokens RefreshToken[]
inspections Inspection[] inspections Inspection[]
@@ -1,8 +1,10 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsString, MinLength } from 'class-validator'; import { IsEmail, IsString, MinLength } from 'class-validator';
import { NormalizeEmail } from '../../common/validation/normalize-email';
export class LoginDto { export class LoginDto {
@ApiProperty() @ApiProperty()
@NormalizeEmail()
@IsEmail() @IsEmail()
email!: string; email!: string;
@@ -18,9 +18,26 @@ jest.mock('openid-client', () => ({
const mockedClient = client as jest.Mocked<typeof client>; const mockedClient = client as jest.Mocked<typeof client>;
const IDENTITY = {
iss: 'https://login.microsoftonline.com/tenant/v2.0',
sub: 'pairwise-sub',
oid: 'object-id-1',
};
const SSO_SUBJECT = `${IDENTITY.iss}|${IDENTITY.oid}`;
function grantWithClaims(claims: Record<string, unknown>) {
return {
claims: () => claims,
} as unknown as Awaited<ReturnType<typeof client.authorizationCodeGrant>>;
}
describe('SsoService', () => { describe('SsoService', () => {
let ssoService: SsoService; let ssoService: SsoService;
let usersService: { findByEmail: jest.Mock }; let usersService: {
findByEmail: jest.Mock;
findBySsoSubject: jest.Mock;
linkSsoSubject: jest.Mock;
};
let jwtService: { sign: jest.Mock; verify: jest.Mock }; let jwtService: { sign: jest.Mock; verify: jest.Mock };
const originalEnv = { ...process.env }; const originalEnv = { ...process.env };
@@ -33,7 +50,11 @@ describe('SsoService', () => {
process.env.OIDC_REDIRECT_URI = process.env.OIDC_REDIRECT_URI =
'https://api.example.com/api/v1/auth/sso/callback'; 'https://api.example.com/api/v1/auth/sso/callback';
process.env.JWT_ACCESS_SECRET = 'unit-test-jwt-access-secret'; process.env.JWT_ACCESS_SECRET = 'unit-test-jwt-access-secret';
usersService = { findByEmail: jest.fn() }; usersService = {
findByEmail: jest.fn(),
findBySsoSubject: jest.fn().mockResolvedValue(null),
linkSsoSubject: jest.fn(),
};
jwtService = { sign: jest.fn(), verify: jest.fn() }; jwtService = { sign: jest.fn(), verify: jest.fn() };
ssoService = new SsoService( ssoService = new SsoService(
usersService as unknown as UsersService, usersService as unknown as UsersService,
@@ -50,6 +71,12 @@ describe('SsoService', () => {
expect(ssoService.isConfigured()).toBe(true); expect(ssoService.isConfigured()).toBe(true);
}); });
it('returns false for a multi-tenant issuer', () => {
process.env.OIDC_ISSUER_URL =
'https://login.microsoftonline.com/common/v2.0';
expect(ssoService.isConfigured()).toBe(false);
});
it('returns false when a variable is missing', () => { it('returns false when a variable is missing', () => {
delete process.env.OIDC_CLIENT_SECRET; delete process.env.OIDC_CLIENT_SECRET;
expect(ssoService.isConfigured()).toBe(false); expect(ssoService.isConfigured()).toBe(false);
@@ -116,7 +143,7 @@ describe('SsoService', () => {
it('returns no_email when the ID token has neither email nor preferred_username', async () => { it('returns no_email when the ID token has neither email nor preferred_username', async () => {
mockedClient.authorizationCodeGrant.mockResolvedValue({ mockedClient.authorizationCodeGrant.mockResolvedValue({
claims: () => ({ sub: 'abc' }), claims: () => ({ ...IDENTITY }),
} as unknown as Awaited< } as unknown as Awaited<
ReturnType<typeof client.authorizationCodeGrant> ReturnType<typeof client.authorizationCodeGrant>
>); >);
@@ -132,7 +159,7 @@ describe('SsoService', () => {
it('returns no_account when no active local user matches the email claim', async () => { it('returns no_account when no active local user matches the email claim', async () => {
mockedClient.authorizationCodeGrant.mockResolvedValue({ mockedClient.authorizationCodeGrant.mockResolvedValue({
claims: () => ({ email: '[email protected]' }), claims: () => ({ ...IDENTITY, email: '[email protected]' }),
} as unknown as Awaited< } as unknown as Awaited<
ReturnType<typeof client.authorizationCodeGrant> ReturnType<typeof client.authorizationCodeGrant>
>); >);
@@ -149,7 +176,10 @@ describe('SsoService', () => {
it('signs a short-lived sso-complete token for an active matching user', async () => { it('signs a short-lived sso-complete token for an active matching user', async () => {
mockedClient.authorizationCodeGrant.mockResolvedValue({ mockedClient.authorizationCodeGrant.mockResolvedValue({
claims: () => ({ preferred_username: '[email protected]' }), claims: () => ({
...IDENTITY,
preferred_username: '[email protected]',
}),
} as unknown as Awaited< } as unknown as Awaited<
ReturnType<typeof client.authorizationCodeGrant> ReturnType<typeof client.authorizationCodeGrant>
>); >);
@@ -175,6 +205,56 @@ describe('SsoService', () => {
expect.objectContaining({ sub: 'user-1', purpose: 'sso-complete' }), expect.objectContaining({ sub: 'user-1', purpose: 'sso-complete' }),
expect.objectContaining({ expiresIn: '60s' }), expect.objectContaining({ expiresIn: '60s' }),
); );
expect(usersService.linkSsoSubject).toHaveBeenCalledWith(
'user-1',
SSO_SUBJECT,
);
});
it('signs in a linked account by its identity, whatever the email claim says', async () => {
mockedClient.authorizationCodeGrant.mockResolvedValue(
grantWithClaims({ ...IDENTITY, email: '[email protected]' }),
);
usersService.findBySsoSubject.mockResolvedValue({
id: 'user-1',
isActive: true,
});
jwtService.sign.mockReturnValue('signed-sso-code');
const result = await ssoService.handleCallback(
new URL('https://api.example.com/callback?code=x'),
'verifier',
'random-state',
);
expect(result).toEqual({ status: 'success', ssoCode: 'signed-sso-code' });
expect(usersService.findBySsoSubject).toHaveBeenCalledWith(SSO_SUBJECT);
expect(usersService.findByEmail).not.toHaveBeenCalled();
});
it('refuses a matching email when the account is linked to another identity', async () => {
mockedClient.authorizationCodeGrant.mockResolvedValue(
grantWithClaims({
...IDENTITY,
oid: 'attacker-object-id',
email: '[email protected]',
}),
);
usersService.findByEmail.mockResolvedValue({
id: 'user-1',
isActive: true,
ssoSubject: SSO_SUBJECT,
});
const result = await ssoService.handleCallback(
new URL('https://api.example.com/callback?code=x'),
'verifier',
'random-state',
);
expect(result).toEqual({ status: 'error', reason: 'no_account' });
expect(jwtService.sign).not.toHaveBeenCalled();
expect(usersService.linkSsoSubject).not.toHaveBeenCalled();
}); });
}); });
+67 -10
View File
@@ -21,6 +21,21 @@ interface SsoCompleteTokenPayload {
purpose: typeof SSO_COMPLETE_TOKEN_PURPOSE; purpose: typeof SSO_COMPLETE_TOKEN_PURPOSE;
} }
// Entra's shared endpoints accept users of ANY tenant - with them, anyone
// able to create an account in some tenant could present a matching email.
const MULTI_TENANT_SEGMENTS = new Set(['common', 'organizations', 'consumers']);
function isSingleTenantIssuer(issuerUrl: string): boolean {
try {
const segments = new URL(issuerUrl).pathname.split('/').filter(Boolean);
return !segments.some((segment) =>
MULTI_TENANT_SEGMENTS.has(segment.toLowerCase()),
);
} catch {
return false;
}
}
export type SsoCallbackResult = export type SsoCallbackResult =
| { status: 'success'; ssoCode: string } | { status: 'success'; ssoCode: string }
| { status: 'error'; reason: 'exchange_failed' | 'no_email' | 'no_account' }; | { status: 'error'; reason: 'exchange_failed' | 'no_email' | 'no_account' };
@@ -36,12 +51,22 @@ export class SsoService {
) {} ) {}
isConfigured(): boolean { isConfigured(): boolean {
return Boolean( const issuer = process.env.OIDC_ISSUER_URL;
process.env.OIDC_ISSUER_URL && if (
process.env.OIDC_CLIENT_ID && !issuer ||
process.env.OIDC_CLIENT_SECRET && !process.env.OIDC_CLIENT_ID ||
process.env.OIDC_REDIRECT_URI, !process.env.OIDC_CLIENT_SECRET ||
); !process.env.OIDC_REDIRECT_URI
) {
return false;
}
if (!isSingleTenantIssuer(issuer)) {
this.logger.error(
'SSO disabled: OIDC_ISSUER_URL must name one tenant (not common/organizations/consumers)',
);
return false;
}
return true;
} }
private async getConfig(): Promise<client.Configuration> { private async getConfig(): Promise<client.Configuration> {
@@ -98,6 +123,27 @@ export class SsoService {
return { status: 'error', reason: 'exchange_failed' }; return { status: 'error', reason: 'exchange_failed' };
} }
const claims = tokens.claims(); const claims = tokens.claims();
// The account is bound to the IdP's immutable identity (issuer + object
// id), not to the email claim: Entra does not verify email or
// preferred_username, so a mutable claim alone must never pick the
// account.
const objectId =
(claims?.oid as string | undefined) ?? claims?.sub ?? undefined;
const ssoSubject =
claims?.iss && objectId ? `${claims.iss}|${objectId}` : null;
if (!ssoSubject) {
this.logger.warn('SSO callback succeeded but ID token had no subject');
return { status: 'error', reason: 'no_account' };
}
const linked = await this.usersService.findBySsoSubject(ssoSubject);
if (linked) {
if (!linked.isActive) {
this.logger.warn(`SSO login denied: user ${linked.id} is deactivated`);
return { status: 'error', reason: 'no_account' };
}
return { status: 'success', ssoCode: this.signSsoCode(linked.id) };
}
const email = const email =
(claims?.email as string | undefined) ?? (claims?.email as string | undefined) ??
(claims?.preferred_username as string | undefined); (claims?.preferred_username as string | undefined);
@@ -109,16 +155,27 @@ export class SsoService {
} }
const user = await this.usersService.findByEmail(email); const user = await this.usersService.findByEmail(email);
if (!user || !user.isActive) { if (!user || !user.isActive) {
this.logger.warn('SSO login denied: no active local account for email');
return { status: 'error', reason: 'no_account' };
}
if (user.ssoSubject) {
// Already bound to a different IdP identity: someone else is
// presenting this email.
this.logger.warn( this.logger.warn(
`SSO login denied: no active local account for ${email}`, `SSO login denied: user ${user.id} is linked to another identity`,
); );
return { status: 'error', reason: 'no_account' }; return { status: 'error', reason: 'no_account' };
} }
const ssoCode = this.jwtService.sign( // First sign-in: bind the account to this identity for good.
{ sub: user.id, purpose: SSO_COMPLETE_TOKEN_PURPOSE }, await this.usersService.linkSsoSubject(user.id, ssoSubject);
return { status: 'success', ssoCode: this.signSsoCode(user.id) };
}
private signSsoCode(userId: string): string {
return this.jwtService.sign(
{ sub: userId, purpose: SSO_COMPLETE_TOKEN_PURPOSE },
jwtSignOptions(TOKEN_AUDIENCE.ssoComplete, SSO_COMPLETE_TOKEN_TTL), jwtSignOptions(TOKEN_AUDIENCE.ssoComplete, SSO_COMPLETE_TOKEN_TTL),
); );
return { status: 'success', ssoCode };
} }
verifySsoCode(ssoCode: string): { userId: string } { verifySsoCode(ssoCode: string): { userId: string } {
@@ -0,0 +1,12 @@
import { Transform } from 'class-transformer';
/**
* Emails are compared case-insensitively (see UsersService.findByEmail);
* storing them trimmed and lower-case keeps "[email protected]" and "[email protected]"
* from becoming two accounts.
*/
export function NormalizeEmail(): PropertyDecorator {
return Transform(({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim().toLowerCase() : value,
);
}
@@ -1,5 +1,6 @@
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEmail, IsOptional, IsString, MinLength } from 'class-validator'; import { IsEmail, IsOptional, IsString, MinLength } from 'class-validator';
import { NormalizeEmail } from '../../common/validation/normalize-email';
export class UpdateProfileDto { export class UpdateProfileDto {
@ApiPropertyOptional() @ApiPropertyOptional()
@@ -10,6 +11,7 @@ export class UpdateProfileDto {
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()
@NormalizeEmail()
@IsEmail() @IsEmail()
email?: string; email?: string;
@@ -20,6 +20,7 @@ describe('ProfileService', () => {
let prisma: { let prisma: {
user: { user: {
findUnique: jest.Mock; findUnique: jest.Mock;
findFirst: jest.Mock;
update: jest.Mock; update: jest.Mock;
}; };
refreshToken: { updateMany: jest.Mock }; refreshToken: { updateMany: jest.Mock };
@@ -48,7 +49,15 @@ describe('ProfileService', () => {
baseUser.mfaEnabled = false; baseUser.mfaEnabled = false;
baseUser.mfaSecret = null; baseUser.mfaSecret = null;
prisma = { prisma = {
user: { findUnique: jest.fn(), update: jest.fn() }, user: {
findUnique: jest.fn(),
// The email-in-use check is case-insensitive (findFirst); tests
// stub it through findUnique.
findFirst: jest.fn(
(args: unknown): unknown => prisma.user.findUnique(args) as unknown,
),
update: jest.fn(),
},
refreshToken: { updateMany: jest.fn().mockResolvedValue({}) }, refreshToken: { updateMany: jest.fn().mockResolvedValue({}) },
}; };
auditLog = { record: jest.fn().mockResolvedValue(undefined) }; auditLog = { record: jest.fn().mockResolvedValue(undefined) };
@@ -69,8 +69,8 @@ export class ProfileService {
'Aktuelles Passwort erforderlich, um die E-Mail-Adresse zu ändern', 'Aktuelles Passwort erforderlich, um die E-Mail-Adresse zu ändern',
); );
} }
const existing = await this.prisma.user.findUnique({ const existing = await this.prisma.user.findFirst({
where: { email: dto.email }, where: { email: { equals: dto.email, mode: 'insensitive' } },
}); });
if (existing) { if (existing) {
throw new ConflictException('Email already in use'); throw new ConflictException('Email already in use');
@@ -1,8 +1,10 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsString, MinLength } from 'class-validator'; import { IsEmail, IsString, MinLength } from 'class-validator';
import { NormalizeEmail } from '../../common/validation/normalize-email';
export class CreateUserDto { export class CreateUserDto {
@ApiProperty() @ApiProperty()
@NormalizeEmail()
@IsEmail() @IsEmail()
email!: string; email!: string;
@@ -1,5 +1,6 @@
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEmail, IsOptional, IsString, MinLength } from 'class-validator'; import { IsEmail, IsOptional, IsString, MinLength } from 'class-validator';
import { NormalizeEmail } from '../../common/validation/normalize-email';
export class UpdateUserDto { export class UpdateUserDto {
@ApiPropertyOptional() @ApiPropertyOptional()
@@ -16,6 +17,7 @@ export class UpdateUserDto {
*/ */
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()
@NormalizeEmail()
@IsEmail() @IsEmail()
email?: string; email?: string;
@@ -14,6 +14,7 @@ describe('UsersService', () => {
user: { user: {
findMany: jest.Mock; findMany: jest.Mock;
findUnique: jest.Mock; findUnique: jest.Mock;
findFirst: jest.Mock;
create: jest.Mock; create: jest.Mock;
update: jest.Mock; update: jest.Mock;
count: jest.Mock; count: jest.Mock;
@@ -73,6 +74,11 @@ describe('UsersService', () => {
user: { user: {
findMany: jest.fn(), findMany: jest.fn(),
findUnique: 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(), create: jest.fn(),
update: jest.fn(), update: jest.fn(),
count: jest.fn(), count: jest.fn(),
+29 -1
View File
@@ -169,8 +169,33 @@ export class UsersService {
return this.toSummary(await this.findById(id)); return this.toSummary(await this.findById(id));
} }
/** Case-insensitive: "[email protected]" and "[email protected]" are one account. */
async findByEmail(email: string): Promise<UserWithRole | null> { 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( async create(
@@ -233,6 +258,9 @@ export class UsersService {
name: dto.name, name: dto.name,
email: dto.email, email: dto.email,
roleId: dto.roleId, 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, ...USER_WITH_ROLE,
}); });