This commit is contained in:
@@ -1,4 +1,6 @@
|
|||||||
import { UnauthorizedException } from '@nestjs/common';
|
import { UnauthorizedException } from '@nestjs/common';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
import { validate } from 'class-validator';
|
import { validate } from 'class-validator';
|
||||||
import { hashValue } from '../../common/utils/hash.util';
|
import { hashValue } from '../../common/utils/hash.util';
|
||||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||||
@@ -67,3 +69,119 @@ describe('AuthService change password', () => {
|
|||||||
expect(errors.some((error) => error.property === 'newPassword')).toBe(true);
|
expect(errors.some((error) => error.property === 'newPassword')).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('AuthService Google token login', () => {
|
||||||
|
const googleClientId =
|
||||||
|
'581263241018-j2rbg7nco2g341m65c76tfltgl5l2vho.apps.googleusercontent.com';
|
||||||
|
|
||||||
|
const createService = () => {
|
||||||
|
const storedUser = {
|
||||||
|
id: 'user-1',
|
||||||
|
username: 'google.user',
|
||||||
|
role: 'user',
|
||||||
|
googleId: 'google-sub-1',
|
||||||
|
isDisabled: false,
|
||||||
|
toObject: () => ({ id: 'user-1', email: 'google.user@example.com' }),
|
||||||
|
};
|
||||||
|
const usersService = {
|
||||||
|
findByGoogleId: jest.fn().mockResolvedValue(storedUser),
|
||||||
|
findByEmail: jest.fn(),
|
||||||
|
findByIdOrFail: jest.fn().mockResolvedValue(storedUser),
|
||||||
|
linkGoogleAccount: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
findByUsername: jest.fn(),
|
||||||
|
};
|
||||||
|
const authRepository = {
|
||||||
|
createRefreshToken: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const jwtService = {
|
||||||
|
signAsync: jest.fn().mockResolvedValueOnce('access-token').mockResolvedValueOnce('refresh-token'),
|
||||||
|
};
|
||||||
|
const configService = {
|
||||||
|
get: jest.fn((key: string) => {
|
||||||
|
const values: Record<string, string | number> = {
|
||||||
|
'google.clientId': ` ${googleClientId} `,
|
||||||
|
'jwt.accessSecret': 'access-secret-123',
|
||||||
|
'jwt.accessExpiresIn': '15m',
|
||||||
|
'jwt.refreshSecret': 'refresh-secret-123',
|
||||||
|
'jwt.refreshExpiresIn': '30d',
|
||||||
|
'security.refreshTokenHashSecret': 'refresh-hash-secret-123',
|
||||||
|
};
|
||||||
|
|
||||||
|
return values[key];
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const googleOAuthClient = {
|
||||||
|
verifyIdToken: jest.fn(),
|
||||||
|
};
|
||||||
|
const service = new AuthService(
|
||||||
|
usersService as any,
|
||||||
|
authRepository as any,
|
||||||
|
jwtService as any,
|
||||||
|
configService as any,
|
||||||
|
{} as any,
|
||||||
|
);
|
||||||
|
(service as any).googleOAuthClient = googleOAuthClient;
|
||||||
|
|
||||||
|
return { service, usersService, googleOAuthClient };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('verifies a Google ID token with the configured Web Client ID audience', async () => {
|
||||||
|
const { service, usersService, googleOAuthClient } = createService();
|
||||||
|
googleOAuthClient.verifyIdToken.mockResolvedValue({
|
||||||
|
getPayload: () => ({
|
||||||
|
sub: 'google-sub-1',
|
||||||
|
email: 'Google.User@example.com',
|
||||||
|
email_verified: true,
|
||||||
|
name: 'Google User',
|
||||||
|
picture: 'https://example.com/avatar.png',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.loginWithGoogleIdToken({
|
||||||
|
idToken: 'valid-google-id-token-with-correct-audience',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(googleOAuthClient.verifyIdToken).toHaveBeenCalledWith({
|
||||||
|
idToken: 'valid-google-id-token-with-correct-audience',
|
||||||
|
audience: googleClientId,
|
||||||
|
});
|
||||||
|
expect(usersService.findByGoogleId).toHaveBeenCalledWith('google-sub-1');
|
||||||
|
expect(result.accessToken).toBe('access-token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a Google ID token when the audience is wrong', async () => {
|
||||||
|
const { service, googleOAuthClient } = createService();
|
||||||
|
googleOAuthClient.verifyIdToken.mockRejectedValue(new Error('Wrong recipient, payload audience mismatch'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.loginWithGoogleIdToken({
|
||||||
|
idToken: 'valid-google-id-token-with-wrong-audience',
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
|
||||||
|
expect(googleOAuthClient.verifyIdToken).toHaveBeenCalledWith({
|
||||||
|
idToken: 'valid-google-id-token-with-wrong-audience',
|
||||||
|
audience: googleClientId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid Google ID token', async () => {
|
||||||
|
const { service, googleOAuthClient } = createService();
|
||||||
|
googleOAuthClient.verifyIdToken.mockRejectedValue(new Error('Invalid token signature'));
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.loginWithGoogleIdToken({
|
||||||
|
idToken: 'not-a-valid-google-id-token',
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the Google ID token path on google-auth-library instead of Firebase Admin', () => {
|
||||||
|
const authServiceSource = fs.readFileSync(path.join(__dirname, 'auth.service.ts'), 'utf8');
|
||||||
|
|
||||||
|
expect(authServiceSource).toContain('googleOAuthClient.verifyIdToken');
|
||||||
|
expect(authServiceSource).not.toContain('firebase-admin');
|
||||||
|
expect(authServiceSource).not.toContain('admin.auth().verifyIdToken');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -288,7 +288,7 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async loginWithGoogleIdToken(dto: GoogleTokenLoginDto): Promise<AuthResult> {
|
async loginWithGoogleIdToken(dto: GoogleTokenLoginDto): Promise<AuthResult> {
|
||||||
const clientId = this.configService.get<string>('google.clientId', { infer: true });
|
const clientId = this.getGoogleClientId();
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
throw new BadRequestException('Google client is not configured');
|
throw new BadRequestException('Google client is not configured');
|
||||||
}
|
}
|
||||||
@@ -308,7 +308,12 @@ export class AuthService {
|
|||||||
audience: clientId,
|
audience: clientId,
|
||||||
});
|
});
|
||||||
payload = ticket.getPayload();
|
payload = ticket.getPayload();
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Google id token verification failed: ${this.getErrorMessage(error)}; token=${this.maskToken(
|
||||||
|
dto.idToken,
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
throw new UnauthorizedException('Invalid Google id token');
|
throw new UnauthorizedException('Invalid Google id token');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -696,6 +701,22 @@ export class AuthService {
|
|||||||
return [...DEFAULT_SUPERADMIN_PERMISSIONS];
|
return [...DEFAULT_SUPERADMIN_PERMISSIONS];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getGoogleClientId(): string {
|
||||||
|
return (this.configService.get<string>('google.clientId', { infer: true }) ?? '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getErrorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
private maskToken(token: string): string {
|
||||||
|
if (token.length <= 16) {
|
||||||
|
return '[redacted]';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${token.slice(0, 8)}...${token.slice(-8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
private async issueEmailVerificationCode(userId: string, email: string): Promise<string> {
|
private async issueEmailVerificationCode(userId: string, email: string): Promise<string> {
|
||||||
const code = this.generateResetCode();
|
const code = this.generateResetCode();
|
||||||
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
|
const saltRounds = this.configService.get<number>('security.bcryptSaltRounds', { infer: true });
|
||||||
|
|||||||
Reference in New Issue
Block a user