Skip to content

Commit 85fb6e4

Browse files
authored
feat: Improve tests for loginUser (#21144)
1 parent 6133209 commit 85fb6e4

File tree

2 files changed

+231
-3
lines changed

2 files changed

+231
-3
lines changed

packages/cli/src/sso.ee/oidc/__tests__/oidc.service.ee.test.ts

Lines changed: 230 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import { OidcService } from '../oidc.service.ee';
1717
import { Publisher } from '@/scaling/pubsub/publisher.service';
1818
import type { OidcConfigDto } from '@n8n/api-types';
1919
import { type ProvisioningService } from '@/modules/provisioning.ee/provisioning.service.ee';
20+
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
21+
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
2022

2123
describe('OidcService', () => {
2224
let oidcService: OidcService;
@@ -27,6 +29,8 @@ describe('OidcService', () => {
2729
let logger: Logger;
2830
let jwtService: JwtService;
2931
let provisioningService: ProvisioningService;
32+
let userRepository: UserRepository;
33+
let authIdentityRepository: AuthIdentityRepository;
3034

3135
const mockOidcConfig = {
3236
clientId: 'test-client-id',
@@ -59,17 +63,18 @@ describe('OidcService', () => {
5963
logger = mockLogger();
6064
jwtService = mock<JwtService>();
6165
provisioningService = mock<ProvisioningService>();
62-
66+
userRepository = mock<UserRepository>();
67+
authIdentityRepository = mock<AuthIdentityRepository>();
6368
jest
6469
.spyOn(ssoHelpers, 'setCurrentAuthenticationMethod')
6570
.mockImplementation(async () => await Promise.resolve());
6671

6772
oidcService = new OidcService(
6873
settingsRepository,
69-
mock<AuthIdentityRepository>(),
74+
authIdentityRepository,
7075
mock<UrlService>(),
7176
globalConfig,
72-
mock<UserRepository>(),
77+
userRepository,
7378
cipher,
7479
logger,
7580
jwtService,
@@ -310,4 +315,226 @@ describe('OidcService', () => {
310315
expect(mockPublisher.publishCommand).not.toHaveBeenCalled();
311316
});
312317
});
318+
319+
describe('loginUser', () => {
320+
it('throws an error if authorizationCodeGrant throws an error', async () => {
321+
oidcService.verifyState = jest.fn().mockReturnValue('valid-state');
322+
oidcService.verifyNonce = jest.fn().mockReturnValue('valid-nonce');
323+
// @ts-expect-error - getOidcConfiguration is private and only accessible within class 'OidcService'
324+
oidcService.getOidcConfiguration = jest.fn().mockResolvedValue({} as client.Configuration);
325+
jest
326+
.spyOn(client, 'authorizationCodeGrant')
327+
.mockRejectedValue(new Error('Authorization code grant failed'));
328+
329+
const callbackUrl = new URL('https://example.com/callback');
330+
const storedState = oidcService.generateState().signed;
331+
const storedNonce = oidcService.generateNonce().signed;
332+
333+
await expect(oidcService.loginUser(callbackUrl, storedState, storedNonce)).rejects.toThrow(
334+
new BadRequestError('Invalid authorization code'),
335+
);
336+
});
337+
338+
it('throws an error if claims() throws an error', async () => {
339+
oidcService.verifyState = jest.fn().mockReturnValue('valid-state');
340+
oidcService.verifyNonce = jest.fn().mockReturnValue('valid-nonce');
341+
// @ts-expect-error - getOidcConfiguration is private and only accessible within class 'OidcService'
342+
oidcService.getOidcConfiguration = jest.fn().mockResolvedValue({} as client.Configuration);
343+
jest.spyOn(client, 'authorizationCodeGrant').mockResolvedValue({
344+
access_token: 'valid-access-token',
345+
token_type: 'bearer',
346+
claims: () => {
347+
throw new Error('Claims extraction failed');
348+
},
349+
} as unknown as client.TokenEndpointResponse & client.TokenEndpointResponseHelpers);
350+
const callbackUrl = new URL('https://example.com/callback');
351+
const storedState = oidcService.generateState().signed;
352+
const storedNonce = oidcService.generateNonce().signed;
353+
354+
await expect(oidcService.loginUser(callbackUrl, storedState, storedNonce)).rejects.toThrow(
355+
new BadRequestError('Invalid token'),
356+
);
357+
});
358+
359+
it('should throw an error if there are no claims', async () => {
360+
oidcService.verifyState = jest.fn().mockReturnValue('valid-state');
361+
oidcService.verifyNonce = jest.fn().mockReturnValue('valid-nonce');
362+
// @ts-expect-error - getOidcConfiguration is private and only accessible within class 'OidcService'
363+
oidcService.getOidcConfiguration = jest.fn().mockResolvedValue({} as client.Configuration);
364+
jest.spyOn(client, 'authorizationCodeGrant').mockResolvedValue({
365+
access_token: 'valid-access-token',
366+
token_type: 'bearer',
367+
claims: () => {
368+
return undefined;
369+
},
370+
} as unknown as client.TokenEndpointResponse & client.TokenEndpointResponseHelpers);
371+
const callbackUrl = new URL('https://example.com/callback');
372+
const storedState = oidcService.generateState().signed;
373+
const storedNonce = oidcService.generateNonce().signed;
374+
375+
await expect(oidcService.loginUser(callbackUrl, storedState, storedNonce)).rejects.toThrow(
376+
new ForbiddenError('No claims found in the OIDC token'),
377+
);
378+
});
379+
380+
it('throws an error if fetchUserInfo throws an error', async () => {
381+
oidcService.verifyState = jest.fn().mockReturnValue('valid-state');
382+
oidcService.verifyNonce = jest.fn().mockReturnValue('valid-nonce');
383+
// @ts-expect-error - getOidcConfiguration is private and only accessible within class 'OidcService'
384+
oidcService.getOidcConfiguration = jest.fn().mockResolvedValue({} as client.Configuration);
385+
jest.spyOn(client, 'authorizationCodeGrant').mockResolvedValue({
386+
access_token: 'valid-access-token',
387+
token_type: 'bearer',
388+
claims: () => {
389+
return { sub: 'valid-subject' };
390+
},
391+
} as unknown as client.TokenEndpointResponse & client.TokenEndpointResponseHelpers);
392+
jest.spyOn(client, 'fetchUserInfo').mockRejectedValue(new Error('Fetch user info failed'));
393+
const callbackUrl = new URL('https://example.com/callback');
394+
const storedState = oidcService.generateState().signed;
395+
const storedNonce = oidcService.generateNonce().signed;
396+
397+
await expect(oidcService.loginUser(callbackUrl, storedState, storedNonce)).rejects.toThrow(
398+
new BadRequestError('Invalid token'),
399+
);
400+
});
401+
402+
it('throws an error if there is no email', async () => {
403+
oidcService.verifyState = jest.fn().mockReturnValue('valid-state');
404+
oidcService.verifyNonce = jest.fn().mockReturnValue('valid-nonce');
405+
// @ts-expect-error - getOidcConfiguration is private and only accessible within class 'OidcService'
406+
oidcService.getOidcConfiguration = jest.fn().mockResolvedValue({} as client.Configuration);
407+
jest.spyOn(client, 'authorizationCodeGrant').mockResolvedValue({
408+
access_token: 'valid-access-token',
409+
token_type: 'bearer',
410+
claims: () => {
411+
return { sub: 'valid-subject' };
412+
},
413+
} as unknown as client.TokenEndpointResponse & client.TokenEndpointResponseHelpers);
414+
jest.spyOn(client, 'fetchUserInfo').mockResolvedValue({ email_verified: true } as any);
415+
const callbackUrl = new URL('https://example.com/callback');
416+
const storedState = oidcService.generateState().signed;
417+
const storedNonce = oidcService.generateNonce().signed;
418+
419+
await expect(oidcService.loginUser(callbackUrl, storedState, storedNonce)).rejects.toThrow(
420+
new BadRequestError('An email is required'),
421+
);
422+
});
423+
424+
it('throws an error if the email is invalid', async () => {
425+
oidcService.verifyState = jest.fn().mockReturnValue('valid-state');
426+
oidcService.verifyNonce = jest.fn().mockReturnValue('valid-nonce');
427+
// @ts-expect-error - getOidcConfiguration is private and only accessible within class 'OidcService'
428+
oidcService.getOidcConfiguration = jest.fn().mockResolvedValue({} as client.Configuration);
429+
jest.spyOn(client, 'authorizationCodeGrant').mockResolvedValue({
430+
access_token: 'valid-access-token',
431+
token_type: 'bearer',
432+
claims: () => {
433+
return { sub: 'valid-subject' };
434+
},
435+
} as unknown as client.TokenEndpointResponse & client.TokenEndpointResponseHelpers);
436+
jest
437+
.spyOn(client, 'fetchUserInfo')
438+
.mockResolvedValue({ email_verified: true, email: 'invalid-email' } as any);
439+
const callbackUrl = new URL('https://example.com/callback');
440+
const storedState = oidcService.generateState().signed;
441+
const storedNonce = oidcService.generateNonce().signed;
442+
443+
await expect(oidcService.loginUser(callbackUrl, storedState, storedNonce)).rejects.toThrow(
444+
new BadRequestError('Invalid email format'),
445+
);
446+
});
447+
448+
it('should return the user if the auth identity already exists', async () => {
449+
oidcService.verifyState = jest.fn().mockReturnValue('valid-state');
450+
oidcService.verifyNonce = jest.fn().mockReturnValue('valid-nonce');
451+
// @ts-expect-error - getOidcConfiguration is private and only accessible within class 'OidcService'
452+
oidcService.getOidcConfiguration = jest.fn().mockResolvedValue({} as client.Configuration);
453+
// @ts-expect-error - applySsoProvisioning is private and only accessible within class 'OidcService'
454+
oidcService.applySsoProvisioning = jest.fn().mockResolvedValue(undefined);
455+
authIdentityRepository.findOne = jest
456+
.fn()
457+
.mockResolvedValue({ user: { email: '[email protected]' } as any });
458+
459+
jest.spyOn(client, 'authorizationCodeGrant').mockResolvedValue({
460+
access_token: 'valid-access-token',
461+
token_type: 'bearer',
462+
claims: () => {
463+
return { sub: 'valid-subject' };
464+
},
465+
} as unknown as client.TokenEndpointResponse & client.TokenEndpointResponseHelpers);
466+
jest
467+
.spyOn(client, 'fetchUserInfo')
468+
.mockResolvedValue({ email_verified: true, email: '[email protected]' } as any);
469+
const callbackUrl = new URL('https://example.com/callback');
470+
const storedState = oidcService.generateState().signed;
471+
const storedNonce = oidcService.generateNonce().signed;
472+
473+
const user = await oidcService.loginUser(callbackUrl, storedState, storedNonce);
474+
expect(user).toBeDefined();
475+
expect(user.email).toEqual('[email protected]');
476+
// @ts-expect-error - applySsoProvisioning is private and only accessible within class 'OidcService'
477+
expect(oidcService.applySsoProvisioning).toHaveBeenCalledWith(user, { sub: 'valid-subject' });
478+
});
479+
480+
it('should return a user if the user exists but the auth identity does not', async () => {
481+
oidcService.verifyState = jest.fn().mockReturnValue('valid-state');
482+
oidcService.verifyNonce = jest.fn().mockReturnValue('valid-nonce');
483+
// @ts-expect-error - getOidcConfiguration is private and only accessible within class 'OidcService'
484+
oidcService.getOidcConfiguration = jest.fn().mockResolvedValue({} as client.Configuration);
485+
// @ts-expect-error - applySsoProvisioning is private and only accessible within class 'OidcService'
486+
oidcService.applySsoProvisioning = jest.fn().mockResolvedValue(undefined);
487+
userRepository.findOne = jest.fn().mockResolvedValue({ email: '[email protected]' } as any);
488+
489+
jest.spyOn(client, 'authorizationCodeGrant').mockResolvedValue({
490+
access_token: 'valid-access-token',
491+
token_type: 'bearer',
492+
claims: () => {
493+
return { sub: 'valid-subject' };
494+
},
495+
} as unknown as client.TokenEndpointResponse & client.TokenEndpointResponseHelpers);
496+
jest
497+
.spyOn(client, 'fetchUserInfo')
498+
.mockResolvedValue({ email_verified: true, email: '[email protected]' } as any);
499+
const callbackUrl = new URL('https://example.com/callback');
500+
const storedState = oidcService.generateState().signed;
501+
const storedNonce = oidcService.generateNonce().signed;
502+
503+
const user = await oidcService.loginUser(callbackUrl, storedState, storedNonce);
504+
expect(user).toBeDefined();
505+
expect(user.email).toEqual('[email protected]');
506+
// @ts-expect-error - applySsoProvisioning is private and only accessible within class 'OidcService'
507+
expect(oidcService.applySsoProvisioning).toHaveBeenCalledWith(user, { sub: 'valid-subject' });
508+
});
509+
510+
it('should create a new user if the user does not exist', async () => {
511+
oidcService.verifyState = jest.fn().mockReturnValue('valid-state');
512+
oidcService.verifyNonce = jest.fn().mockReturnValue('valid-nonce');
513+
// @ts-expect-error - getOidcConfiguration is private and only accessible within class 'OidcService'
514+
oidcService.getOidcConfiguration = jest.fn().mockResolvedValue({} as client.Configuration);
515+
// @ts-expect-error - applySsoProvisioning is private and only accessible within class 'OidcService'
516+
oidcService.applySsoProvisioning = jest.fn().mockResolvedValue(undefined);
517+
userRepository.manager.transaction = jest
518+
.fn()
519+
.mockResolvedValue({ email: '[email protected]' } as any);
520+
521+
jest.spyOn(client, 'authorizationCodeGrant').mockResolvedValue({
522+
access_token: 'valid-access-token',
523+
token_type: 'bearer',
524+
claims: () => {
525+
return { sub: 'valid-subject' };
526+
},
527+
} as unknown as client.TokenEndpointResponse & client.TokenEndpointResponseHelpers);
528+
jest
529+
.spyOn(client, 'fetchUserInfo')
530+
.mockResolvedValue({ email_verified: true, email: '[email protected]' } as any);
531+
const callbackUrl = new URL('https://example.com/callback');
532+
const storedState = oidcService.generateState().signed;
533+
const storedNonce = oidcService.generateNonce().signed;
534+
535+
const user = await oidcService.loginUser(callbackUrl, storedState, storedNonce);
536+
expect(user).toBeDefined();
537+
expect(user.email).toEqual('[email protected]');
538+
});
539+
});
313540
});

packages/cli/src/sso.ee/oidc/oidc.service.ee.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,7 @@ export class OidcService {
277277
});
278278

279279
await this.authIdentityRepository.save(id);
280+
await this.applySsoProvisioning(foundUser, claims);
280281

281282
return foundUser;
282283
}

0 commit comments

Comments
 (0)