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 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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 } {
|
||||
|
||||
Reference in new issue
Block a user