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

@@ -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 LoginDto {
@ApiProperty()
@NormalizeEmail()
@IsEmail()
email!: string;
@@ -18,9 +18,26 @@ jest.mock('openid-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', () => {
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 };
const originalEnv = { ...process.env };
@@ -33,7 +50,11 @@ describe('SsoService', () => {
process.env.OIDC_REDIRECT_URI =
'https://api.example.com/api/v1/auth/sso/callback';
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() };
ssoService = new SsoService(
usersService as unknown as UsersService,
@@ -50,6 +71,12 @@ describe('SsoService', () => {
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', () => {
delete process.env.OIDC_CLIENT_SECRET;
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 () => {
mockedClient.authorizationCodeGrant.mockResolvedValue({
claims: () => ({ sub: 'abc' }),
claims: () => ({ ...IDENTITY }),
} as unknown as Awaited<
ReturnType<typeof client.authorizationCodeGrant>
>);
@@ -132,7 +159,7 @@ describe('SsoService', () => {
it('returns no_account when no active local user matches the email claim', async () => {
mockedClient.authorizationCodeGrant.mockResolvedValue({
claims: () => ({ email: '[email protected]' }),
claims: () => ({ ...IDENTITY, email: '[email protected]' }),
} as unknown as Awaited<
ReturnType<typeof client.authorizationCodeGrant>
>);
@@ -149,7 +176,10 @@ describe('SsoService', () => {
it('signs a short-lived sso-complete token for an active matching user', async () => {
mockedClient.authorizationCodeGrant.mockResolvedValue({
claims: () => ({ preferred_username: '[email protected]' }),
claims: () => ({
...IDENTITY,
preferred_username: '[email protected]',
}),
} as unknown as Awaited<
ReturnType<typeof client.authorizationCodeGrant>
>);
@@ -175,6 +205,56 @@ describe('SsoService', () => {
expect.objectContaining({ sub: 'user-1', purpose: 'sso-complete' }),
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;
}
// 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 =
| { status: 'success'; ssoCode: string }
| { status: 'error'; reason: 'exchange_failed' | 'no_email' | 'no_account' };
@@ -36,12 +51,22 @@ export class SsoService {
) {}
isConfigured(): boolean {
return Boolean(
process.env.OIDC_ISSUER_URL &&
process.env.OIDC_CLIENT_ID &&
process.env.OIDC_CLIENT_SECRET &&
process.env.OIDC_REDIRECT_URI,
);
const issuer = process.env.OIDC_ISSUER_URL;
if (
!issuer ||
!process.env.OIDC_CLIENT_ID ||
!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> {
@@ -98,6 +123,27 @@ export class SsoService {
return { status: 'error', reason: 'exchange_failed' };
}
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 =
(claims?.email as string | undefined) ??
(claims?.preferred_username as string | undefined);
@@ -109,16 +155,27 @@ export class SsoService {
}
const user = await this.usersService.findByEmail(email);
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(
`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' };
}
const ssoCode = this.jwtService.sign(
{ sub: user.id, purpose: SSO_COMPLETE_TOKEN_PURPOSE },
// First sign-in: bind the account to this identity for good.
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),
);
return { status: 'success', ssoCode };
}
verifySsoCode(ssoCode: string): { userId: string } {