native auth
This commit is contained in:
103
src/auth/auth.controller.ts
Normal file
103
src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
Post,
|
||||
UseGuards,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
ApiBadRequestResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginRequestDto } from './dto/login-request.dto';
|
||||
import { RegisterRequestDto } from './dto/register-request.dto';
|
||||
import { LoginResponseDto } from './dto/login-response.dto';
|
||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||
import {
|
||||
CurrentUser,
|
||||
type AuthenticatedUser,
|
||||
} from './decorators/current-user.decorator';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
private readonly logger = new Logger(AuthController.name);
|
||||
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: 'Register a new user' })
|
||||
@ApiResponse({ status: 201, description: 'User registered' })
|
||||
@ApiBadRequestResponse({ description: 'Invalid registration data' })
|
||||
async register(@Body() body: RegisterRequestDto) {
|
||||
const user = await this.authService.register(body);
|
||||
this.logger.log(`Registered user: ${user.id}`);
|
||||
return { id: user.id };
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Login with email and password' })
|
||||
@ApiResponse({ status: 200, type: LoginResponseDto })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid credentials' })
|
||||
async login(@Body() body: LoginRequestDto): Promise<LoginResponseDto> {
|
||||
return this.authService.login(body.email, body.password);
|
||||
}
|
||||
|
||||
@Post('change-password')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: 'Change current user password' })
|
||||
@ApiResponse({ status: 204, description: 'Password updated' })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid credentials' })
|
||||
async changePassword(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() body: ChangePasswordDto,
|
||||
): Promise<void> {
|
||||
await this.authService.changePassword(
|
||||
user.userId,
|
||||
body.currentPassword,
|
||||
body.newPassword,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('reset-password')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(204)
|
||||
@ApiOperation({ summary: 'Reset password with old password' })
|
||||
@ApiResponse({ status: 204, description: 'Password updated' })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid credentials' })
|
||||
async resetPassword(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
@Body() body: ResetPasswordDto,
|
||||
): Promise<void> {
|
||||
await this.authService.changePassword(
|
||||
user.userId,
|
||||
body.oldPassword,
|
||||
body.newPassword,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('refresh')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
@HttpCode(200)
|
||||
@ApiOperation({ summary: 'Refresh access token' })
|
||||
@ApiResponse({ status: 200, type: LoginResponseDto })
|
||||
@ApiUnauthorizedResponse({ description: 'Invalid token' })
|
||||
async refresh(
|
||||
@CurrentUser() user: AuthenticatedUser,
|
||||
): Promise<LoginResponseDto> {
|
||||
return this.authService.refreshToken(user);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { JwtVerificationService } from './services/jwt-verification.service';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { AuthController } from './auth.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -12,6 +13,7 @@ import { UsersModule } from '../users/users.module';
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
forwardRef(() => UsersModule),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [JwtStrategy, AuthService, JwtVerificationService],
|
||||
exports: [AuthService, PassportModule, JwtVerificationService],
|
||||
})
|
||||
|
||||
@@ -1,449 +0,0 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { URLSearchParams } from 'url';
|
||||
import axios from 'axios';
|
||||
import { AuthService } from './auth.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import type { AuthenticatedUser } from './decorators/current-user.decorator';
|
||||
import { User } from '../users/users.entity';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService;
|
||||
|
||||
const mockUser: User = {
|
||||
id: 'uuid-123',
|
||||
keycloakSub: 'f:realm:user123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
username: 'testuser',
|
||||
picture: 'https://example.com/avatar.jpg',
|
||||
roles: ['user', 'premium'],
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
lastLoginAt: new Date('2024-01-01'),
|
||||
activeDollId: null,
|
||||
};
|
||||
|
||||
const mockUsersService: jest.Mocked<
|
||||
Pick<UsersService, 'createFromToken' | 'findByKeycloakSub' | 'findOrCreate'>
|
||||
> = {
|
||||
createFromToken: jest.fn().mockResolvedValue(mockUser),
|
||||
findByKeycloakSub: jest.fn().mockResolvedValue(null),
|
||||
findOrCreate: jest.fn().mockResolvedValue(mockUser),
|
||||
};
|
||||
|
||||
const mockAuthUser: AuthenticatedUser = {
|
||||
keycloakSub: 'f:realm:user123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
username: 'testuser',
|
||||
picture: 'https://example.com/avatar.jpg',
|
||||
roles: ['user', 'premium'],
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AuthService,
|
||||
{
|
||||
provide: UsersService,
|
||||
useValue: mockUsersService,
|
||||
},
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: {
|
||||
get: (key: string) => {
|
||||
if (key === 'JWT_ISSUER') return 'https://auth.example.com';
|
||||
if (key === 'KEYCLOAK_CLIENT_ID') return 'friendolls-client';
|
||||
if (key === 'KEYCLOAK_CLIENT_SECRET') return 'secret';
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AuthService>(AuthService);
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('revokeToken', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(axios, 'post').mockReset();
|
||||
});
|
||||
|
||||
it('should skip when config missing', async () => {
|
||||
const missingConfigService = new ConfigService({});
|
||||
const localService = new AuthService(
|
||||
mockUsersService as unknown as UsersService,
|
||||
missingConfigService,
|
||||
);
|
||||
|
||||
const warnSpy = jest.spyOn<any, any>(localService['logger'], 'warn');
|
||||
const result = await localService.revokeToken('rt');
|
||||
expect(result).toBe(false);
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return true on successful revocation', async () => {
|
||||
jest.spyOn(axios, 'post').mockResolvedValue({ status: 200 });
|
||||
|
||||
const result = await service.revokeToken('rt-success');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(axios.post).toHaveBeenCalledWith(
|
||||
'https://auth.example.com/protocol/openid-connect/revoke',
|
||||
expect.any(URLSearchParams),
|
||||
expect.objectContaining({ headers: expect.any(Object) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false on non-2xx response', async () => {
|
||||
jest.spyOn(axios, 'post').mockResolvedValue({ status: 400 });
|
||||
const warnSpy = jest.spyOn<any, any>(service['logger'], 'warn');
|
||||
|
||||
const result = await service.revokeToken('rt-fail');
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return false on error', async () => {
|
||||
jest.spyOn(axios, 'post').mockRejectedValue({ message: 'boom' });
|
||||
const warnSpy = jest.spyOn<any, any>(service['logger'], 'warn');
|
||||
|
||||
const result = await service.revokeToken('rt-error');
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(warnSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncUserFromToken', () => {
|
||||
it('should create a new user if user does not exist', async () => {
|
||||
mockUsersService.createFromToken.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await service.syncUserFromToken(mockAuthUser);
|
||||
|
||||
expect(result).toEqual(mockUser);
|
||||
expect(mockUsersService.createFromToken).toHaveBeenCalledWith({
|
||||
keycloakSub: 'f:realm:user123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
username: 'testuser',
|
||||
picture: 'https://example.com/avatar.jpg',
|
||||
roles: ['user', 'premium'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle existing user via upsert', async () => {
|
||||
const updatedUser = { ...mockUser, lastLoginAt: new Date('2024-02-01') };
|
||||
mockUsersService.createFromToken.mockResolvedValue(updatedUser);
|
||||
|
||||
const result = await service.syncUserFromToken(mockAuthUser);
|
||||
|
||||
expect(result).toEqual(updatedUser);
|
||||
expect(mockUsersService.createFromToken).toHaveBeenCalledWith({
|
||||
keycloakSub: 'f:realm:user123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
username: 'testuser',
|
||||
picture: 'https://example.com/avatar.jpg',
|
||||
roles: ['user', 'premium'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle user with no email by using empty string', async () => {
|
||||
const authUserNoEmail: AuthenticatedUser = {
|
||||
keycloakSub: 'f:realm:user456',
|
||||
name: 'No Email User',
|
||||
};
|
||||
|
||||
mockUsersService.createFromToken.mockResolvedValue({
|
||||
...mockUser,
|
||||
email: '',
|
||||
name: 'No Email User',
|
||||
});
|
||||
|
||||
await service.syncUserFromToken(authUserNoEmail);
|
||||
|
||||
expect(mockUsersService.createFromToken).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
email: '',
|
||||
name: 'No Email User',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle user with no name by using username or fallback', async () => {
|
||||
const authUserNoName: AuthenticatedUser = {
|
||||
keycloakSub: 'f:realm:user789',
|
||||
username: 'fallbackuser',
|
||||
};
|
||||
|
||||
mockUsersService.createFromToken.mockResolvedValue({
|
||||
...mockUser,
|
||||
name: 'fallbackuser',
|
||||
});
|
||||
|
||||
await service.syncUserFromToken(authUserNoName);
|
||||
|
||||
expect(mockUsersService.createFromToken).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'fallbackuser',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use "Unknown User" when no name or username is available', async () => {
|
||||
const authUserMinimal: AuthenticatedUser = {
|
||||
keycloakSub: 'f:realm:minimal',
|
||||
};
|
||||
|
||||
mockUsersService.createFromToken.mockResolvedValue({
|
||||
...mockUser,
|
||||
name: 'Unknown User',
|
||||
});
|
||||
|
||||
await service.syncUserFromToken(authUserMinimal);
|
||||
|
||||
expect(mockUsersService.createFromToken).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'Unknown User',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty keycloakSub gracefully', async () => {
|
||||
const authUserEmptySub: AuthenticatedUser = {
|
||||
keycloakSub: '',
|
||||
email: 'empty@example.com',
|
||||
name: 'Empty Sub User',
|
||||
};
|
||||
|
||||
mockUsersService.createFromToken.mockResolvedValue({
|
||||
...mockUser,
|
||||
keycloakSub: '',
|
||||
email: 'empty@example.com',
|
||||
name: 'Empty Sub User',
|
||||
});
|
||||
|
||||
await service.syncUserFromToken(authUserEmptySub);
|
||||
|
||||
expect(mockUsersService.createFromToken).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
keycloakSub: '',
|
||||
email: 'empty@example.com',
|
||||
name: 'Empty Sub User',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle malformed keycloakSub', async () => {
|
||||
const authUserMalformed: AuthenticatedUser = {
|
||||
keycloakSub: 'invalid-format',
|
||||
email: 'malformed@example.com',
|
||||
name: 'Malformed User',
|
||||
};
|
||||
|
||||
mockUsersService.createFromToken.mockResolvedValue({
|
||||
...mockUser,
|
||||
keycloakSub: 'invalid-format',
|
||||
email: 'malformed@example.com',
|
||||
name: 'Malformed User',
|
||||
});
|
||||
|
||||
const result = await service.syncUserFromToken(authUserMalformed);
|
||||
|
||||
expect(mockUsersService.createFromToken).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
keycloakSub: 'invalid-format',
|
||||
email: 'malformed@example.com',
|
||||
name: 'Malformed User',
|
||||
}),
|
||||
);
|
||||
expect(result.keycloakSub).toBe('invalid-format');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureUserExists', () => {
|
||||
it('should call findOrCreate with correct params', async () => {
|
||||
mockUsersService.findOrCreate.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await service.ensureUserExists(mockAuthUser);
|
||||
|
||||
expect(result).toEqual(mockUser);
|
||||
expect(mockUsersService.findOrCreate).toHaveBeenCalledWith({
|
||||
keycloakSub: 'f:realm:user123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
username: 'testuser',
|
||||
picture: 'https://example.com/avatar.jpg',
|
||||
roles: ['user', 'premium'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing username gracefully', async () => {
|
||||
mockUsersService.findOrCreate.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await service.ensureUserExists({
|
||||
...mockAuthUser,
|
||||
username: undefined,
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockUser);
|
||||
expect(mockUsersService.findOrCreate).toHaveBeenCalledWith({
|
||||
keycloakSub: 'f:realm:user123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
username: undefined,
|
||||
picture: 'https://example.com/avatar.jpg',
|
||||
roles: ['user', 'premium'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasRole', () => {
|
||||
it('should return true if user has the required role', () => {
|
||||
const result = service.hasRole(mockAuthUser, 'user');
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if user does not have the required role', () => {
|
||||
const result = service.hasRole(mockAuthUser, 'admin');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if user has no roles', () => {
|
||||
const authUserNoRoles: AuthenticatedUser = {
|
||||
keycloakSub: 'f:realm:noroles',
|
||||
email: 'noroles@example.com',
|
||||
name: 'No Roles User',
|
||||
};
|
||||
|
||||
const result = service.hasRole(authUserNoRoles, 'user');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if user roles is empty array', () => {
|
||||
const authUserEmptyRoles: AuthenticatedUser = {
|
||||
keycloakSub: 'f:realm:emptyroles',
|
||||
email: 'empty@example.com',
|
||||
name: 'Empty Roles User',
|
||||
roles: [],
|
||||
};
|
||||
|
||||
const result = service.hasRole(authUserEmptyRoles, 'user');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasAnyRole', () => {
|
||||
it('should return true if user has at least one of the required roles', () => {
|
||||
const result = service.hasAnyRole(mockAuthUser, ['admin', 'premium']);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if user has none of the required roles', () => {
|
||||
const result = service.hasAnyRole(mockAuthUser, ['admin', 'moderator']);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if user has no roles', () => {
|
||||
const authUserNoRoles: AuthenticatedUser = {
|
||||
keycloakSub: 'f:realm:noroles',
|
||||
email: 'noroles@example.com',
|
||||
name: 'No Roles User',
|
||||
};
|
||||
|
||||
const result = service.hasAnyRole(authUserNoRoles, ['admin', 'user']);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if user roles is empty array', () => {
|
||||
const authUserEmptyRoles: AuthenticatedUser = {
|
||||
keycloakSub: 'f:realm:emptyroles',
|
||||
email: 'empty@example.com',
|
||||
name: 'Empty Roles User',
|
||||
roles: [],
|
||||
};
|
||||
|
||||
const result = service.hasAnyRole(authUserEmptyRoles, ['admin', 'user']);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle multiple matching roles', () => {
|
||||
const result = service.hasAnyRole(mockAuthUser, ['user', 'premium']);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasAllRoles', () => {
|
||||
it('should return true if user has all of the required roles', () => {
|
||||
const result = service.hasAllRoles(mockAuthUser, ['user', 'premium']);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if user has only some of the required roles', () => {
|
||||
const result = service.hasAllRoles(mockAuthUser, [
|
||||
'user',
|
||||
'premium',
|
||||
'admin',
|
||||
]);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if user has none of the required roles', () => {
|
||||
const result = service.hasAllRoles(mockAuthUser, ['admin', 'moderator']);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if user has no roles', () => {
|
||||
const authUserNoRoles: AuthenticatedUser = {
|
||||
keycloakSub: 'f:realm:noroles',
|
||||
email: 'noroles@example.com',
|
||||
name: 'No Roles User',
|
||||
};
|
||||
|
||||
const result = service.hasAllRoles(authUserNoRoles, ['user']);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if user roles is empty array', () => {
|
||||
const authUserEmptyRoles: AuthenticatedUser = {
|
||||
keycloakSub: 'f:realm:emptyroles',
|
||||
email: 'empty@example.com',
|
||||
name: 'Empty Roles User',
|
||||
roles: [],
|
||||
};
|
||||
|
||||
const result = service.hasAllRoles(authUserEmptyRoles, ['user']);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for single role check', () => {
|
||||
const result = service.hasAllRoles(mockAuthUser, ['user']);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,159 +1,173 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import { URLSearchParams } from 'url';
|
||||
import { sign } from 'jsonwebtoken';
|
||||
import { compare, hash } from 'bcryptjs';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import type { AuthenticatedUser } from './decorators/current-user.decorator';
|
||||
import { User } from '../users/users.entity';
|
||||
import type { AuthenticatedUser } from './decorators/current-user.decorator';
|
||||
|
||||
/**
|
||||
* Authentication Service
|
||||
*
|
||||
* Handles authentication-related business logic including:
|
||||
* - User login tracking from Keycloak tokens
|
||||
* - Profile synchronization from Keycloak
|
||||
* - Role-based authorization checks
|
||||
*
|
||||
* ## User Sync Strategy
|
||||
*
|
||||
* On every authentication:
|
||||
* - Creates new users with full profile data from Keycloak
|
||||
* - For existing users, compares Keycloak data with local database:
|
||||
* - If profile changed: Updates all fields (email, name, username, picture, roles, lastLoginAt)
|
||||
* - If profile unchanged: Only updates lastLoginAt (lightweight operation)
|
||||
*
|
||||
* This optimizes database performance since reads are cheaper than writes in PostgreSQL.
|
||||
* Most logins only update lastLoginAt, but profile changes sync automatically.
|
||||
*
|
||||
* For explicit profile sync (webhooks, admin operations), use:
|
||||
* - UsersService.syncProfileFromToken() - force sync regardless of changes
|
||||
*
|
||||
* ## Usage Guidelines
|
||||
*
|
||||
* - Use `syncUserFromToken()` for actual login events (WebSocket connections, /users/me)
|
||||
* - Use `ensureUserExists()` for regular API calls that just need the user record
|
||||
* Handles native authentication:
|
||||
* - User registration
|
||||
* - Login with email/password
|
||||
* - JWT issuance
|
||||
* - Password changes
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
private readonly jwtSecret: string;
|
||||
private readonly jwtIssuer: string;
|
||||
private readonly jwtAudience?: string;
|
||||
private readonly jwtExpiresInSeconds: number;
|
||||
|
||||
constructor(
|
||||
private readonly usersService: UsersService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Revoke refresh token via Keycloak token revocation endpoint, if configured.
|
||||
* Returns true on success; false on missing config or failure.
|
||||
*/
|
||||
async revokeToken(refreshToken: string): Promise<boolean> {
|
||||
const issuer = this.configService.get<string>('JWT_ISSUER');
|
||||
const clientId = this.configService.get<string>('KEYCLOAK_CLIENT_ID');
|
||||
const clientSecret = this.configService.get<string>(
|
||||
'KEYCLOAK_CLIENT_SECRET',
|
||||
) {
|
||||
this.jwtSecret = this.configService.get<string>('JWT_SECRET') || '';
|
||||
if (!this.jwtSecret) {
|
||||
throw new Error('JWT_SECRET must be configured');
|
||||
}
|
||||
this.jwtIssuer =
|
||||
this.configService.get<string>('JWT_ISSUER') || 'friendolls';
|
||||
this.jwtAudience = this.configService.get<string>('JWT_AUDIENCE');
|
||||
this.jwtExpiresInSeconds = Number(
|
||||
this.configService.get<string>('JWT_EXPIRES_IN_SECONDS') || '3600',
|
||||
);
|
||||
|
||||
if (!issuer || !clientId) {
|
||||
this.logger.warn(
|
||||
'JWT issuer or client id missing, skipping token revocation',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const revokeUrl = `${issuer}/protocol/openid-connect/revoke`;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
token: refreshToken,
|
||||
token_type_hint: 'refresh_token',
|
||||
});
|
||||
if (clientSecret) {
|
||||
params.set('client_secret', clientSecret);
|
||||
}
|
||||
|
||||
const response = await axios.post(revokeUrl, params, {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
timeout: 5000,
|
||||
validateStatus: (status) => status >= 200 && status < 500,
|
||||
});
|
||||
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
this.logger.log('Refresh token revoked');
|
||||
return true;
|
||||
}
|
||||
|
||||
this.logger.warn(
|
||||
`Token revocation failed with status ${response.status}`,
|
||||
);
|
||||
return false;
|
||||
} catch (error) {
|
||||
const err = error as AxiosError;
|
||||
this.logger.warn(
|
||||
`Failed to revoke token: ${err.response?.status} ${err.response?.statusText ?? err.message}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles user login and profile synchronization from Keycloak token.
|
||||
* Creates new users with full profile data.
|
||||
* For existing users, intelligently syncs only changed fields to optimize performance.
|
||||
*
|
||||
* The service compares Keycloak data with local database and:
|
||||
* - Updates profile fields only if they changed from Keycloak
|
||||
* - Always updates lastLoginAt to track login activity
|
||||
*
|
||||
* @param authenticatedUser - User data extracted from JWT token
|
||||
* @returns The user entity
|
||||
*/
|
||||
async syncUserFromToken(authenticatedUser: AuthenticatedUser): Promise<User> {
|
||||
const { keycloakSub, email, name, username, picture, roles } =
|
||||
authenticatedUser;
|
||||
async register(data: {
|
||||
email: string;
|
||||
password: string;
|
||||
name?: string;
|
||||
username?: string;
|
||||
}): Promise<User> {
|
||||
const { email, password, name, username } = data;
|
||||
|
||||
const user = await this.usersService.createFromToken({
|
||||
keycloakSub,
|
||||
email: email || '',
|
||||
const existing = await this.usersService.findByEmail(email);
|
||||
if (existing) {
|
||||
throw new BadRequestException('Email already registered');
|
||||
}
|
||||
|
||||
const passwordHash = await hash(password, 12);
|
||||
return this.usersService.createLocalUser({
|
||||
email,
|
||||
passwordHash,
|
||||
name: name || username || 'Unknown User',
|
||||
username,
|
||||
picture,
|
||||
roles,
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures a user exists in the local database without updating profile data.
|
||||
* This is optimized for regular API calls that need the user record but don't
|
||||
* need to sync profile data from Keycloak on every request.
|
||||
*/
|
||||
async ensureUserExists(authenticatedUser: AuthenticatedUser): Promise<User> {
|
||||
const { keycloakSub, email, name, username, picture, roles } =
|
||||
authenticatedUser;
|
||||
|
||||
return await this.usersService.findOrCreate({
|
||||
keycloakSub,
|
||||
email: email || '',
|
||||
name: name || username || 'Unknown User',
|
||||
username,
|
||||
picture,
|
||||
roles,
|
||||
});
|
||||
}
|
||||
|
||||
hasRole(user: AuthenticatedUser, requiredRole: string): boolean {
|
||||
async login(
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<{
|
||||
accessToken: string;
|
||||
expiresIn: number;
|
||||
}> {
|
||||
const user = await this.usersService.findByEmail(email);
|
||||
if (!user) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
const passwordOk = await this.verifyPassword(user, password);
|
||||
if (!passwordOk) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
await this.usersService.updateLastLogin(user.id);
|
||||
|
||||
const accessToken = this.issueToken({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
roles: user.roles,
|
||||
});
|
||||
|
||||
return { accessToken, expiresIn: this.jwtExpiresInSeconds };
|
||||
}
|
||||
|
||||
async changePassword(
|
||||
userId: string,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
): Promise<void> {
|
||||
const user = await this.usersService.findOne(userId);
|
||||
|
||||
const passwordOk = await this.verifyPassword(user, currentPassword);
|
||||
if (!passwordOk) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
|
||||
const passwordHash = await hash(newPassword, 12);
|
||||
await this.usersService.updatePasswordHash(userId, passwordHash);
|
||||
}
|
||||
|
||||
async refreshToken(user: AuthenticatedUser): Promise<{
|
||||
accessToken: string;
|
||||
expiresIn: number;
|
||||
}> {
|
||||
const existingUser = await this.usersService.findOne(user.userId);
|
||||
const accessToken = this.issueToken({
|
||||
userId: existingUser.id,
|
||||
email: existingUser.email,
|
||||
roles: existingUser.roles,
|
||||
});
|
||||
|
||||
return { accessToken, expiresIn: this.jwtExpiresInSeconds };
|
||||
}
|
||||
|
||||
private issueToken(payload: {
|
||||
userId: string;
|
||||
email: string;
|
||||
roles: string[];
|
||||
}): string {
|
||||
return sign(
|
||||
{
|
||||
sub: payload.userId,
|
||||
email: payload.email,
|
||||
roles: payload.roles,
|
||||
},
|
||||
this.jwtSecret,
|
||||
{
|
||||
issuer: this.jwtIssuer,
|
||||
audience: this.jwtAudience,
|
||||
expiresIn: this.jwtExpiresInSeconds,
|
||||
algorithm: 'HS256',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async verifyPassword(user: User, password: string): Promise<boolean> {
|
||||
const userWithPassword = user as unknown as {
|
||||
passwordHash?: string | null;
|
||||
};
|
||||
if (userWithPassword.passwordHash) {
|
||||
return compare(password, userWithPassword.passwordHash);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
hasRole(user: { roles?: string[] }, requiredRole: string): boolean {
|
||||
return user.roles?.includes(requiredRole) ?? false;
|
||||
}
|
||||
|
||||
hasAnyRole(user: AuthenticatedUser, requiredRoles: string[]): boolean {
|
||||
hasAnyRole(user: { roles?: string[] }, requiredRoles: string[]): boolean {
|
||||
if (!user.roles || user.roles.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return requiredRoles.some((role) => user.roles!.includes(role));
|
||||
}
|
||||
|
||||
hasAllRoles(user: AuthenticatedUser, requiredRoles: string[]): boolean {
|
||||
hasAllRoles(user: { roles?: string[] }, requiredRoles: string[]): boolean {
|
||||
if (!user.roles || user.roles.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -5,13 +5,9 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
* This matches the object returned by JwtStrategy.validate()
|
||||
*/
|
||||
export interface AuthenticatedUser {
|
||||
keycloakSub: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
username?: string;
|
||||
picture?: string;
|
||||
userId: string;
|
||||
email: string;
|
||||
roles?: string[];
|
||||
sessionState?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,8 +30,8 @@ export interface AuthenticatedUser {
|
||||
* ```typescript
|
||||
* @Get('profile')
|
||||
* @UseGuards(JwtAuthGuard)
|
||||
* async getProfile(@CurrentUser('keycloakSub') sub: string) {
|
||||
* return { sub };
|
||||
* async getProfile(@CurrentUser('userId') userId: string) {
|
||||
* return { userId };
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
15
src/auth/dto/change-password.dto.ts
Normal file
15
src/auth/dto/change-password.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@ApiProperty({ example: 'old password' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
currentPassword!: string;
|
||||
|
||||
@ApiProperty({ example: 'new strong password' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(8)
|
||||
newPassword!: string;
|
||||
}
|
||||
14
src/auth/dto/login-request.dto.ts
Normal file
14
src/auth/dto/login-request.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class LoginRequestDto {
|
||||
@ApiProperty({ example: 'user@example.com' })
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ example: 'correct horse battery staple' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(3)
|
||||
password!: string;
|
||||
}
|
||||
9
src/auth/dto/login-response.dto.ts
Normal file
9
src/auth/dto/login-response.dto.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class LoginResponseDto {
|
||||
@ApiProperty({ description: 'JWT access token' })
|
||||
accessToken: string;
|
||||
|
||||
@ApiProperty({ description: 'Access token expiration in seconds' })
|
||||
expiresIn: number;
|
||||
}
|
||||
30
src/auth/dto/register-request.dto.ts
Normal file
30
src/auth/dto/register-request.dto.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
IsEmail,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class RegisterRequestDto {
|
||||
@ApiProperty({ example: 'user@example.com' })
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ example: 'correct horse battery staple' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(8)
|
||||
password!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Jane Doe' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'janedoe' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
username?: string;
|
||||
}
|
||||
15
src/auth/dto/reset-password.dto.ts
Normal file
15
src/auth/dto/reset-password.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class ResetPasswordDto {
|
||||
@ApiProperty({ example: 'old password' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
oldPassword!: string;
|
||||
|
||||
@ApiProperty({ example: 'new strong password' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(8)
|
||||
newPassword!: string;
|
||||
}
|
||||
@@ -2,12 +2,12 @@ import { ExecutionContext, Injectable, Logger } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Observable } from 'rxjs';
|
||||
import type { Request } from 'express';
|
||||
import { User } from 'src/users/users.entity';
|
||||
import type { AuthenticatedUser } from '../decorators/current-user.decorator';
|
||||
|
||||
/**
|
||||
* JWT Authentication Guard
|
||||
*
|
||||
* This guard protects routes by requiring a valid JWT token from Keycloak.
|
||||
* This guard protects routes by requiring a valid JWT token.
|
||||
* It uses the JwtStrategy to validate the token and attach user info to the request.
|
||||
*
|
||||
* Usage:
|
||||
@@ -57,7 +57,7 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
|
||||
handleRequest(
|
||||
err: any,
|
||||
user: User,
|
||||
user: AuthenticatedUser,
|
||||
info: any,
|
||||
context: ExecutionContext,
|
||||
status?: any,
|
||||
@@ -82,7 +82,7 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
}
|
||||
} else {
|
||||
this.logger.debug(
|
||||
`✅ JWT Authentication successful for user: ${user.keycloakSub || 'unknown'}`,
|
||||
`✅ JWT Authentication successful for user: ${user.userId || 'unknown'}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ describe('JwtVerificationService', () => {
|
||||
const mockConfigService = {
|
||||
get: jest.fn((key: string) => {
|
||||
const config: Record<string, string> = {
|
||||
JWKS_URI: 'https://test.com/.well-known/jwks.json',
|
||||
JWT_SECRET: 'test-secret',
|
||||
JWT_ISSUER: 'https://test.com',
|
||||
JWT_AUDIENCE: 'test-audience',
|
||||
};
|
||||
|
||||
@@ -1,78 +1,36 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { verify, type JwtHeader } from 'jsonwebtoken';
|
||||
import { JwksClient, type SigningKey } from 'jwks-rsa';
|
||||
import { verify } from 'jsonwebtoken';
|
||||
import type { JwtPayload } from '../strategies/jwt.strategy';
|
||||
|
||||
const JWT_ALGORITHM = 'RS256';
|
||||
const JWT_ALGORITHM = 'HS256';
|
||||
const BEARER_PREFIX = 'Bearer ';
|
||||
|
||||
@Injectable()
|
||||
export class JwtVerificationService {
|
||||
private readonly logger = new Logger(JwtVerificationService.name);
|
||||
private readonly jwksClient: JwksClient;
|
||||
private readonly jwtSecret: string;
|
||||
private readonly issuer: string;
|
||||
private readonly audience: string | undefined;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
const jwksUri = this.configService.get<string>('JWKS_URI');
|
||||
this.issuer = this.configService.get<string>('JWT_ISSUER') || '';
|
||||
this.jwtSecret = this.configService.get<string>('JWT_SECRET') || '';
|
||||
this.issuer = this.configService.get<string>('JWT_ISSUER') || 'friendolls';
|
||||
this.audience = this.configService.get<string>('JWT_AUDIENCE');
|
||||
|
||||
if (!jwksUri) {
|
||||
throw new Error('JWKS_URI must be configured');
|
||||
if (!this.jwtSecret) {
|
||||
throw new Error('JWT_SECRET must be configured');
|
||||
}
|
||||
|
||||
if (!this.issuer) {
|
||||
throw new Error('JWT_ISSUER must be configured');
|
||||
}
|
||||
|
||||
this.jwksClient = new JwksClient({
|
||||
jwksUri,
|
||||
cache: true,
|
||||
rateLimit: true,
|
||||
jwksRequestsPerMinute: 5,
|
||||
});
|
||||
|
||||
this.logger.log('JWT Verification Service initialized');
|
||||
}
|
||||
|
||||
async verifyToken(token: string): Promise<JwtPayload> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const getKey = (
|
||||
header: JwtHeader,
|
||||
callback: (err: Error | null, signingKey?: string | Buffer) => void,
|
||||
) => {
|
||||
this.jwksClient.getSigningKey(
|
||||
header.kid,
|
||||
(err: Error | null, key?: SigningKey) => {
|
||||
if (err) {
|
||||
callback(err);
|
||||
return;
|
||||
}
|
||||
const signingKey = key?.getPublicKey();
|
||||
callback(null, signingKey);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
verify(
|
||||
token,
|
||||
getKey,
|
||||
{
|
||||
issuer: this.issuer,
|
||||
audience: this.audience,
|
||||
algorithms: [JWT_ALGORITHM],
|
||||
},
|
||||
(err, decoded) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
resolve(decoded as JwtPayload);
|
||||
},
|
||||
);
|
||||
});
|
||||
verifyToken(token: string): JwtPayload {
|
||||
return verify(token, this.jwtSecret, {
|
||||
issuer: this.issuer,
|
||||
audience: this.audience,
|
||||
algorithms: [JWT_ALGORITHM],
|
||||
}) as JwtPayload;
|
||||
}
|
||||
|
||||
extractToken(handshake: {
|
||||
|
||||
@@ -1,83 +1,48 @@
|
||||
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Strategy, ExtractJwt } from 'passport-jwt';
|
||||
|
||||
import { passportJwtSecret } from 'jwks-rsa';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
/**
|
||||
* JWT payload interface representing the decoded token from Keycloak
|
||||
* JWT payload interface representing the decoded token
|
||||
*/
|
||||
export interface JwtPayload {
|
||||
sub: string; // Subject (user identifier in Keycloak)
|
||||
email?: string;
|
||||
name?: string;
|
||||
preferred_username?: string;
|
||||
picture?: string;
|
||||
realm_access?: {
|
||||
roles: string[];
|
||||
};
|
||||
resource_access?: {
|
||||
[key: string]: {
|
||||
roles: string[];
|
||||
};
|
||||
};
|
||||
session_state?: string;
|
||||
iss: string; // Issuer
|
||||
aud: string | string[]; // Audience
|
||||
exp: number; // Expiration time
|
||||
iat: number; // Issued at
|
||||
sub: string; // User ID
|
||||
email: string;
|
||||
roles?: string[];
|
||||
iss: string;
|
||||
aud?: string;
|
||||
exp: number;
|
||||
iat: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT Strategy for validating Keycloak-issued JWT tokens.
|
||||
* This strategy validates tokens against Keycloak's public keys (JWKS)
|
||||
* and extracts user information from the token payload.
|
||||
* JWT Strategy for validating locally issued JWT tokens.
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
private readonly logger = new Logger(JwtStrategy.name);
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
const jwksUri = configService.get<string>('JWKS_URI');
|
||||
const issuer = configService.get<string>('JWT_ISSUER');
|
||||
const jwtSecret = configService.get<string>('JWT_SECRET');
|
||||
const issuer = configService.get<string>('JWT_ISSUER') || 'friendolls';
|
||||
const audience = configService.get<string>('JWT_AUDIENCE');
|
||||
|
||||
if (!jwksUri) {
|
||||
throw new Error('JWKS_URI must be configured in environment variables');
|
||||
}
|
||||
|
||||
if (!issuer) {
|
||||
throw new Error('JWT_ISSUER must be configured in environment variables');
|
||||
if (!jwtSecret) {
|
||||
throw new Error('JWT_SECRET must be configured in environment variables');
|
||||
}
|
||||
|
||||
super({
|
||||
// Extract JWT from Authorization header as Bearer token
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
|
||||
// Use JWKS to fetch and cache Keycloak's public keys for signature verification
|
||||
secretOrKeyProvider: passportJwtSecret({
|
||||
cache: true,
|
||||
rateLimit: true,
|
||||
jwksRequestsPerMinute: 5,
|
||||
jwksUri,
|
||||
}),
|
||||
|
||||
// Verify the issuer matches our Keycloak realm
|
||||
secretOrKey: jwtSecret,
|
||||
issuer,
|
||||
|
||||
// Verify the audience matches our client ID
|
||||
audience,
|
||||
|
||||
// Automatically reject expired tokens
|
||||
ignoreExpiration: false,
|
||||
|
||||
// Use RS256 algorithm (Keycloak's default)
|
||||
algorithms: ['RS256'],
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
|
||||
this.logger.log(`JWT Strategy initialized`);
|
||||
this.logger.log(` JWKS URI: ${jwksUri}`);
|
||||
this.logger.log(` Issuer: ${issuer}`);
|
||||
this.logger.log(` Audience: ${audience || 'NOT SET'}`);
|
||||
}
|
||||
@@ -91,19 +56,15 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
* @throws UnauthorizedException if the payload is invalid
|
||||
*/
|
||||
async validate(payload: JwtPayload): Promise<{
|
||||
keycloakSub: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
username?: string;
|
||||
picture?: string;
|
||||
userId: string;
|
||||
email: string;
|
||||
roles?: string[];
|
||||
sessionState?: string;
|
||||
}> {
|
||||
this.logger.debug(`Validating JWT token payload`);
|
||||
this.logger.debug(` Issuer: ${payload.iss}`);
|
||||
this.logger.debug(
|
||||
` Audience: ${Array.isArray(payload.aud) ? payload.aud.join(',') : payload.aud}`,
|
||||
);
|
||||
if (payload.aud) {
|
||||
this.logger.debug(` Audience: ${payload.aud}`);
|
||||
}
|
||||
this.logger.debug(` Subject: ${payload.sub}`);
|
||||
this.logger.debug(
|
||||
` Expires: ${new Date(payload.exp * 1000).toISOString()}`,
|
||||
@@ -114,31 +75,15 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
throw new UnauthorizedException('Invalid token: missing subject');
|
||||
}
|
||||
|
||||
// Extract roles from Keycloak's realm_access and resource_access
|
||||
const roles: string[] = [];
|
||||
|
||||
if (payload.realm_access?.roles) {
|
||||
roles.push(...payload.realm_access.roles);
|
||||
}
|
||||
|
||||
const clientId = this.configService.get<string>('KEYCLOAK_CLIENT_ID');
|
||||
if (clientId && payload.resource_access?.[clientId]?.roles) {
|
||||
roles.push(...payload.resource_access[clientId].roles);
|
||||
}
|
||||
|
||||
// Return user object that will be attached to request.user
|
||||
const user = {
|
||||
keycloakSub: payload.sub,
|
||||
userId: payload.sub,
|
||||
email: payload.email,
|
||||
name: payload.name,
|
||||
username: payload.preferred_username,
|
||||
picture: payload.picture,
|
||||
roles: roles.length > 0 ? roles : undefined,
|
||||
sessionState: payload.session_state,
|
||||
roles:
|
||||
payload.roles && payload.roles.length > 0 ? payload.roles : undefined,
|
||||
};
|
||||
|
||||
this.logger.log(
|
||||
`✅ Successfully validated token for user: ${payload.sub} (${payload.email ?? payload.preferred_username ?? 'no email'})`,
|
||||
`✅ Successfully validated token for user: ${payload.sub} (${payload.email})`,
|
||||
);
|
||||
|
||||
return Promise.resolve(user);
|
||||
|
||||
Reference in New Issue
Block a user