514 lines
16 KiB
TypeScript
514 lines
16 KiB
TypeScript
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(),
|
|
findFirst: 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' }),
|
|
});
|
|
});
|
|
|
|
it('derives username from email local-part when provider username is missing', async () => {
|
|
const state = service.startSso(
|
|
'google',
|
|
'http://127.0.0.1:43123/callback',
|
|
).state;
|
|
const profileWithoutUsername: SocialAuthProfile = {
|
|
...socialProfile,
|
|
email: 'Alice@example.com',
|
|
username: undefined,
|
|
};
|
|
|
|
const txUserCreate = jest.fn().mockResolvedValue({ id: 'user-1' });
|
|
const txIdentityCreate = jest.fn().mockResolvedValue(undefined);
|
|
mockPrismaService.authIdentity.findUnique.mockResolvedValue(null);
|
|
mockPrismaService.user.findFirst = jest.fn().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, profileWithoutUsername);
|
|
|
|
expect(txUserCreate).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({ username: 'alice' }),
|
|
});
|
|
expect(txIdentityCreate).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({ providerUsername: 'alice' }),
|
|
});
|
|
});
|
|
|
|
it('adds a numeric suffix when derived username is already taken', async () => {
|
|
const state = service.startSso(
|
|
'google',
|
|
'http://127.0.0.1:43123/callback',
|
|
).state;
|
|
const profileWithoutUsername: SocialAuthProfile = {
|
|
...socialProfile,
|
|
email: 'Alice@example.com',
|
|
username: undefined,
|
|
};
|
|
|
|
const txUserCreate = jest.fn().mockResolvedValue({ id: 'user-1' });
|
|
const txIdentityCreate = jest.fn().mockResolvedValue(undefined);
|
|
mockPrismaService.authIdentity.findUnique.mockResolvedValue(null);
|
|
mockPrismaService.user.findFirst = jest
|
|
.fn()
|
|
.mockResolvedValueOnce({ id: 'existing-user' })
|
|
.mockResolvedValueOnce(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, profileWithoutUsername);
|
|
|
|
expect(txUserCreate).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({ username: 'alice2' }),
|
|
});
|
|
});
|
|
});
|
|
});
|