SSO auth (1)
This commit is contained in:
428
src/auth/auth.service.spec.ts
Normal file
428
src/auth/auth.service.spec.ts
Normal file
@@ -0,0 +1,428 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ServiceUnavailableException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { decode, sign } from 'jsonwebtoken';
|
||||
import { PrismaService } from '../database/prisma.service';
|
||||
import { AuthService } from './auth.service';
|
||||
import { sha256 } from './auth.utils';
|
||||
import type { SocialAuthProfile } from './types/social-auth-profile';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService;
|
||||
|
||||
const applyDefaultConfig = () => {
|
||||
mockConfigService.get.mockImplementation((key: string) => {
|
||||
const config: Record<string, string | undefined> = {
|
||||
JWT_SECRET: 'test-secret',
|
||||
JWT_ISSUER: 'friendolls',
|
||||
JWT_AUDIENCE: 'friendolls-api',
|
||||
JWT_EXPIRES_IN_SECONDS: '3600',
|
||||
GOOGLE_CLIENT_ID: 'google-client-id',
|
||||
GOOGLE_CLIENT_SECRET: 'google-client-secret',
|
||||
GOOGLE_CALLBACK_URL: 'http://localhost:3000/auth/sso/google/callback',
|
||||
DISCORD_CLIENT_ID: 'discord-client-id',
|
||||
DISCORD_CLIENT_SECRET: 'discord-client-secret',
|
||||
DISCORD_CALLBACK_URL: 'http://localhost:3000/auth/sso/discord/callback',
|
||||
};
|
||||
|
||||
return config[key];
|
||||
});
|
||||
};
|
||||
|
||||
const mockConfigService = {
|
||||
get: jest.fn(),
|
||||
};
|
||||
|
||||
const mockPrismaService = {
|
||||
authExchangeCode: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
authIdentity: {
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
authSession: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
user: {
|
||||
update: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
},
|
||||
$queryRaw: jest.fn(),
|
||||
$transaction: jest.fn(),
|
||||
};
|
||||
|
||||
const socialProfile: SocialAuthProfile = {
|
||||
provider: 'google',
|
||||
providerSubject: 'google-user-123',
|
||||
email: 'jane@example.com',
|
||||
emailVerified: true,
|
||||
displayName: 'Jane Example',
|
||||
username: 'jane',
|
||||
picture: 'https://example.com/jane.png',
|
||||
};
|
||||
|
||||
const createRefreshToken = (overrides: Record<string, unknown> = {}) =>
|
||||
sign(
|
||||
{
|
||||
sub: 'user-1',
|
||||
sid: 'session-1',
|
||||
jti: 'refresh-jti',
|
||||
typ: 'refresh',
|
||||
...overrides,
|
||||
},
|
||||
'test-secret',
|
||||
{
|
||||
issuer: 'friendolls',
|
||||
audience: 'friendolls-api',
|
||||
expiresIn: 60,
|
||||
algorithm: 'HS256',
|
||||
},
|
||||
);
|
||||
|
||||
beforeEach(async () => {
|
||||
applyDefaultConfig();
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AuthService,
|
||||
{ provide: PrismaService, useValue: mockPrismaService },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AuthService>(AuthService);
|
||||
jest.clearAllMocks();
|
||||
applyDefaultConfig();
|
||||
});
|
||||
|
||||
describe('startSso', () => {
|
||||
it('returns a signed state token for configured providers', () => {
|
||||
const result = service.startSso(
|
||||
'google',
|
||||
'http://127.0.0.1:43123/callback',
|
||||
);
|
||||
|
||||
expect(result.state).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('rejects unconfigured providers', () => {
|
||||
mockConfigService.get.mockImplementation((key: string) => {
|
||||
if (key.startsWith('GOOGLE_')) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const config: Record<string, string | undefined> = {
|
||||
JWT_SECRET: 'test-secret',
|
||||
JWT_ISSUER: 'friendolls',
|
||||
JWT_AUDIENCE: 'friendolls-api',
|
||||
JWT_EXPIRES_IN_SECONDS: '3600',
|
||||
DISCORD_CLIENT_ID: 'discord-client-id',
|
||||
DISCORD_CLIENT_SECRET: 'discord-client-secret',
|
||||
DISCORD_CALLBACK_URL:
|
||||
'http://localhost:3000/auth/sso/discord/callback',
|
||||
};
|
||||
|
||||
return config[key];
|
||||
});
|
||||
|
||||
const localService = new AuthService(
|
||||
mockPrismaService as unknown as PrismaService,
|
||||
mockConfigService as unknown as ConfigService,
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
localService.startSso('google', 'http://127.0.0.1:43123/callback'),
|
||||
).toThrow(ServiceUnavailableException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exchangeSsoCode', () => {
|
||||
it('throws when the exchange code was already consumed', async () => {
|
||||
mockPrismaService.$queryRaw.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(service.exchangeSsoCode('used-code')).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshTokens', () => {
|
||||
it('throws unauthorized on malformed refresh token', async () => {
|
||||
await expect(service.refreshTokens('not-a-jwt')).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
|
||||
it('revokes the session when a stale refresh token is replayed', async () => {
|
||||
const refreshToken = createRefreshToken();
|
||||
|
||||
mockPrismaService.$queryRaw
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'session-1',
|
||||
refresh_token_hash: 'different-hash',
|
||||
expires_at: new Date(Date.now() + 60_000),
|
||||
revoked_at: null,
|
||||
provider: 'GOOGLE',
|
||||
user_id: 'user-1',
|
||||
email: 'jane@example.com',
|
||||
roles: ['user'],
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([{ id: 'session-1' }]);
|
||||
|
||||
await expect(service.refreshTokens(refreshToken)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
expect(mockPrismaService.$queryRaw).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('issues a distinct refresh token on rotation', async () => {
|
||||
const refreshToken = createRefreshToken();
|
||||
|
||||
mockPrismaService.$queryRaw
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'session-1',
|
||||
refresh_token_hash: sha256(refreshToken),
|
||||
expires_at: new Date(Date.now() + 60_000),
|
||||
revoked_at: null,
|
||||
provider: 'GOOGLE',
|
||||
user_id: 'user-1',
|
||||
email: 'jane@example.com',
|
||||
roles: ['user'],
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([{ id: 'session-1' }]);
|
||||
|
||||
const result = await service.refreshTokens(refreshToken);
|
||||
|
||||
expect(result.refreshToken).not.toBe(refreshToken);
|
||||
const payload = decode(result.refreshToken) as { jti?: string } | null;
|
||||
expect(payload?.jti).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('throws when refresh rotation loses the race', async () => {
|
||||
const refreshToken = createRefreshToken();
|
||||
|
||||
mockPrismaService.$queryRaw
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: 'session-1',
|
||||
refresh_token_hash: sha256(refreshToken),
|
||||
expires_at: new Date(Date.now() + 60_000),
|
||||
revoked_at: null,
|
||||
provider: 'GOOGLE',
|
||||
user_id: 'user-1',
|
||||
email: 'jane@example.com',
|
||||
roles: ['user'],
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([{ id: 'session-1' }]);
|
||||
|
||||
await expect(service.refreshTokens(refreshToken)).rejects.toThrow(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('completeSso', () => {
|
||||
it('creates a new account when the provider email is verified', async () => {
|
||||
const state = service.startSso(
|
||||
'google',
|
||||
'http://127.0.0.1:43123/callback',
|
||||
).state;
|
||||
const createdUser = {
|
||||
id: 'user-1',
|
||||
email: 'jane@example.com',
|
||||
name: 'Jane Example',
|
||||
username: 'jane',
|
||||
picture: 'https://example.com/jane.png',
|
||||
roles: [],
|
||||
keycloakSub: null,
|
||||
passwordHash: null,
|
||||
lastLoginAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
activeDollId: null,
|
||||
};
|
||||
const txUserCreate = jest.fn().mockResolvedValue(createdUser);
|
||||
|
||||
mockPrismaService.authIdentity.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.$transaction.mockImplementation((callback) =>
|
||||
Promise.resolve(
|
||||
callback({
|
||||
user: {
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
create: txUserCreate,
|
||||
},
|
||||
authIdentity: {
|
||||
create: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
mockPrismaService.authExchangeCode.create.mockResolvedValue({
|
||||
id: 'code-1',
|
||||
});
|
||||
|
||||
const redirectUri = await service.completeSso(
|
||||
'google',
|
||||
state,
|
||||
socialProfile,
|
||||
);
|
||||
|
||||
expect(mockPrismaService.$transaction).toHaveBeenCalled();
|
||||
expect(txUserCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
email: 'jane@example.com',
|
||||
}),
|
||||
});
|
||||
expect(mockPrismaService.authExchangeCode.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
provider: 'GOOGLE',
|
||||
userId: 'user-1',
|
||||
}),
|
||||
});
|
||||
expect(redirectUri).toContain('http://127.0.0.1:43123/callback');
|
||||
expect(redirectUri).toContain('code=');
|
||||
});
|
||||
|
||||
it('rejects creating an account when provider email is unverified', async () => {
|
||||
const state = service.startSso(
|
||||
'google',
|
||||
'http://127.0.0.1:43123/callback',
|
||||
).state;
|
||||
const unverifiedProfile: SocialAuthProfile = {
|
||||
...socialProfile,
|
||||
emailVerified: false,
|
||||
};
|
||||
|
||||
mockPrismaService.authIdentity.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.completeSso('google', state, unverifiedProfile),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects auto-linking to an existing local account', async () => {
|
||||
const state = service.startSso(
|
||||
'google',
|
||||
'http://127.0.0.1:43123/callback',
|
||||
).state;
|
||||
|
||||
mockPrismaService.authIdentity.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.$transaction.mockImplementation((callback) =>
|
||||
Promise.resolve(
|
||||
callback({
|
||||
user: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'user-1' }),
|
||||
create: jest.fn(),
|
||||
},
|
||||
authIdentity: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.completeSso('google', state, socialProfile),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('allows an existing linked identity to sign in without an email', async () => {
|
||||
const state = service.startSso(
|
||||
'discord',
|
||||
'http://127.0.0.1:43123/callback',
|
||||
).state;
|
||||
const linkedUser = {
|
||||
id: 'user-1',
|
||||
email: 'jane@example.com',
|
||||
name: 'Jane Example',
|
||||
username: 'jane',
|
||||
picture: 'https://example.com/jane.png',
|
||||
roles: ['user'],
|
||||
keycloakSub: null,
|
||||
passwordHash: null,
|
||||
lastLoginAt: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
activeDollId: null,
|
||||
};
|
||||
|
||||
mockPrismaService.authIdentity.findUnique.mockResolvedValue({
|
||||
id: 'identity-1',
|
||||
user: linkedUser,
|
||||
});
|
||||
mockPrismaService.authIdentity.update.mockResolvedValue(undefined);
|
||||
mockPrismaService.user.update.mockResolvedValue(linkedUser);
|
||||
mockPrismaService.authExchangeCode.create.mockResolvedValue({
|
||||
id: 'code-1',
|
||||
});
|
||||
|
||||
const redirectUri = await service.completeSso('discord', state, {
|
||||
provider: 'discord',
|
||||
providerSubject: 'google-user-123',
|
||||
email: null,
|
||||
emailVerified: false,
|
||||
displayName: 'Jane Example',
|
||||
username: 'jane',
|
||||
});
|
||||
|
||||
expect(redirectUri).toContain('code=');
|
||||
expect(mockPrismaService.user.update).toHaveBeenCalledWith({
|
||||
where: { id: 'user-1' },
|
||||
data: expect.not.objectContaining({ email: expect.anything() }),
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes provider emails before creating users and identities', async () => {
|
||||
const state = service.startSso(
|
||||
'google',
|
||||
'http://127.0.0.1:43123/callback',
|
||||
).state;
|
||||
const mixedCaseProfile: SocialAuthProfile = {
|
||||
...socialProfile,
|
||||
email: ' Jane@Example.COM ',
|
||||
};
|
||||
|
||||
const txUserCreate = jest.fn().mockResolvedValue({ id: 'user-1' });
|
||||
const txIdentityCreate = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockPrismaService.authIdentity.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.$transaction.mockImplementation((callback) =>
|
||||
Promise.resolve(
|
||||
callback({
|
||||
user: {
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
create: txUserCreate,
|
||||
},
|
||||
authIdentity: {
|
||||
create: txIdentityCreate,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
mockPrismaService.authExchangeCode.create.mockResolvedValue({
|
||||
id: 'code-1',
|
||||
});
|
||||
|
||||
await service.completeSso('google', state, mixedCaseProfile);
|
||||
|
||||
expect(txUserCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ email: 'jane@example.com' }),
|
||||
});
|
||||
expect(txIdentityCreate).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ providerEmail: 'jane@example.com' }),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user