refined auth

This commit is contained in:
2025-12-06 01:17:44 +08:00
parent f04ffea612
commit c8cfca8de8
5 changed files with 584 additions and 280 deletions

View File

@@ -7,13 +7,9 @@ import { User } from '../users/users.entity';
describe('AuthService', () => { describe('AuthService', () => {
let service: AuthService; let service: AuthService;
const mockFindByKeycloakSub = jest.fn();
const mockUpdateFromToken = jest.fn();
const mockCreateFromToken = jest.fn(); const mockCreateFromToken = jest.fn();
const mockUsersService = { const mockUsersService = {
findByKeycloakSub: mockFindByKeycloakSub,
updateFromToken: mockUpdateFromToken,
createFromToken: mockCreateFromToken, createFromToken: mockCreateFromToken,
}; };
@@ -62,13 +58,11 @@ describe('AuthService', () => {
describe('syncUserFromToken', () => { describe('syncUserFromToken', () => {
it('should create a new user if user does not exist', async () => { it('should create a new user if user does not exist', async () => {
mockFindByKeycloakSub.mockReturnValue(null);
mockCreateFromToken.mockReturnValue(mockUser); mockCreateFromToken.mockReturnValue(mockUser);
const result = await service.syncUserFromToken(mockAuthUser); const result = await service.syncUserFromToken(mockAuthUser);
expect(result).toEqual(mockUser); expect(result).toEqual(mockUser);
expect(mockFindByKeycloakSub).toHaveBeenCalledWith('f:realm:user123');
expect(mockCreateFromToken).toHaveBeenCalledWith({ expect(mockCreateFromToken).toHaveBeenCalledWith({
keycloakSub: 'f:realm:user123', keycloakSub: 'f:realm:user123',
email: 'test@example.com', email: 'test@example.com',
@@ -77,32 +71,23 @@ describe('AuthService', () => {
picture: 'https://example.com/avatar.jpg', picture: 'https://example.com/avatar.jpg',
roles: ['user', 'premium'], roles: ['user', 'premium'],
}); });
expect(mockUpdateFromToken).not.toHaveBeenCalled();
}); });
it('should update existing user if user exists', async () => { it('should handle existing user via upsert', async () => {
const updatedUser = { ...mockUser, lastLoginAt: new Date('2024-02-01') }; const updatedUser = { ...mockUser, lastLoginAt: new Date('2024-02-01') };
mockFindByKeycloakSub.mockReturnValue(mockUser); mockCreateFromToken.mockReturnValue(updatedUser);
mockUpdateFromToken.mockReturnValue(updatedUser);
const result = await service.syncUserFromToken(mockAuthUser); const result = await service.syncUserFromToken(mockAuthUser);
expect(result).toEqual(updatedUser); expect(result).toEqual(updatedUser);
expect(mockFindByKeycloakSub).toHaveBeenCalledWith('f:realm:user123'); expect(mockCreateFromToken).toHaveBeenCalledWith({
keycloakSub: 'f:realm:user123',
expect(mockUpdateFromToken).toHaveBeenCalledWith( email: 'test@example.com',
'f:realm:user123', name: 'Test User',
expect.objectContaining({ username: 'testuser',
email: 'test@example.com', picture: 'https://example.com/avatar.jpg',
name: 'Test User', roles: ['user', 'premium'],
username: 'testuser', });
picture: 'https://example.com/avatar.jpg',
roles: ['user', 'premium'],
lastLoginAt: expect.any(Date),
}),
);
expect(mockCreateFromToken).not.toHaveBeenCalled();
}); });
it('should handle user with no email by using empty string', async () => { it('should handle user with no email by using empty string', async () => {
@@ -111,7 +96,6 @@ describe('AuthService', () => {
name: 'No Email User', name: 'No Email User',
}; };
mockFindByKeycloakSub.mockReturnValue(null);
mockCreateFromToken.mockReturnValue({ mockCreateFromToken.mockReturnValue({
...mockUser, ...mockUser,
email: '', email: '',
@@ -131,30 +115,28 @@ describe('AuthService', () => {
it('should handle user with no name by using username or fallback', async () => { it('should handle user with no name by using username or fallback', async () => {
const authUserNoName: AuthenticatedUser = { const authUserNoName: AuthenticatedUser = {
keycloakSub: 'f:realm:user789', keycloakSub: 'f:realm:user789',
username: 'someusername', username: 'fallbackuser',
}; };
mockFindByKeycloakSub.mockReturnValue(null);
mockCreateFromToken.mockReturnValue({ mockCreateFromToken.mockReturnValue({
...mockUser, ...mockUser,
name: 'someusername', name: 'fallbackuser',
}); });
await service.syncUserFromToken(authUserNoName); await service.syncUserFromToken(authUserNoName);
expect(mockCreateFromToken).toHaveBeenCalledWith( expect(mockCreateFromToken).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
name: 'someusername', name: 'fallbackuser',
}), }),
); );
}); });
it('should use "Unknown User" when no name or username is available', async () => { it('should use "Unknown User" when no name or username is available', async () => {
const authUserMinimal: AuthenticatedUser = { const authUserMinimal: AuthenticatedUser = {
keycloakSub: 'f:realm:user000', keycloakSub: 'f:realm:minimal',
}; };
mockFindByKeycloakSub.mockReturnValue(null);
mockCreateFromToken.mockReturnValue({ mockCreateFromToken.mockReturnValue({
...mockUser, ...mockUser,
name: 'Unknown User', name: 'Unknown User',
@@ -176,7 +158,6 @@ describe('AuthService', () => {
name: 'Empty Sub User', name: 'Empty Sub User',
}; };
mockFindByKeycloakSub.mockReturnValue(null);
mockCreateFromToken.mockReturnValue({ mockCreateFromToken.mockReturnValue({
...mockUser, ...mockUser,
keycloakSub: '', keycloakSub: '',
@@ -186,7 +167,6 @@ describe('AuthService', () => {
const result = await service.syncUserFromToken(authUserEmptySub); const result = await service.syncUserFromToken(authUserEmptySub);
expect(mockFindByKeycloakSub).toHaveBeenCalledWith('');
expect(mockCreateFromToken).toHaveBeenCalledWith( expect(mockCreateFromToken).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
keycloakSub: '', keycloakSub: '',
@@ -203,7 +183,6 @@ describe('AuthService', () => {
name: 'Malformed User', name: 'Malformed User',
}; };
mockFindByKeycloakSub.mockReturnValue(null);
mockCreateFromToken.mockReturnValue({ mockCreateFromToken.mockReturnValue({
...mockUser, ...mockUser,
keycloakSub: 'invalid-format', keycloakSub: 'invalid-format',
@@ -213,7 +192,6 @@ describe('AuthService', () => {
const result = await service.syncUserFromToken(authUserMalformed); const result = await service.syncUserFromToken(authUserMalformed);
expect(mockFindByKeycloakSub).toHaveBeenCalledWith('invalid-format');
expect(mockCreateFromToken).toHaveBeenCalledWith( expect(mockCreateFromToken).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
keycloakSub: 'invalid-format', keycloakSub: 'invalid-format',

View File

@@ -7,8 +7,23 @@ import { User } from '../users/users.entity';
* Authentication Service * Authentication Service
* *
* Handles authentication-related business logic including: * Handles authentication-related business logic including:
* - User synchronization from Keycloak tokens * - User login tracking from Keycloak tokens
* - Profile updates for authenticated users * - 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
*/ */
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@@ -17,42 +32,31 @@ export class AuthService {
constructor(private readonly usersService: UsersService) {} constructor(private readonly usersService: UsersService) {}
/** /**
* Synchronizes a user from Keycloak token to local database. * Handles user login and profile synchronization from Keycloak token.
* Creates a new user if they don't exist, or updates their last login time. * 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 * @param authenticatedUser - User data extracted from JWT token
* @returns The synchronized user entity * @returns The user entity
*/ */
async syncUserFromToken(authenticatedUser: AuthenticatedUser): Promise<User> { async syncUserFromToken(authenticatedUser: AuthenticatedUser): Promise<User> {
const { keycloakSub, email, name, username, picture, roles } = const { keycloakSub, email, name, username, picture, roles } =
authenticatedUser; authenticatedUser;
// Try to find existing user by Keycloak subject // Use createFromToken which handles upsert atomically
let user = await this.usersService.findByKeycloakSub(keycloakSub); // This prevents race conditions when multiple requests arrive simultaneously
const user = await this.usersService.createFromToken({
if (user) { keycloakSub,
// User exists - update last login and sync profile data email: email || '',
this.logger.debug(`Syncing existing user: ${keycloakSub}`); name: name || username || 'Unknown User',
user = await this.usersService.updateFromToken(keycloakSub, { username,
email, picture,
name, roles,
username, });
picture,
roles,
lastLoginAt: new Date(),
});
} else {
// New user - create from token data
this.logger.log(`Creating new user from token: ${keycloakSub}`);
user = await this.usersService.createFromToken({
keycloakSub,
email: email || '',
name: name || username || 'Unknown User',
username,
picture,
roles,
});
}
return user; return user;
} }

View File

@@ -1,25 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, IsString, MinLength, MaxLength } from 'class-validator';
/** /**
* DTO for updating user profile. * DTO for updating user profile.
* Only allows updating safe, user-controlled fields. *
* Security-sensitive fields (keycloakSub, roles, email, etc.) are managed by Keycloak. * Currently all profile fields (name, email, username, picture, roles) are managed by Keycloak
* and cannot be updated locally. This DTO exists for future extensibility if local profile
* fields are added (e.g., preferences, settings, bio, etc.).
*
* To update profile data, users must update their profile in Keycloak. Changes will sync
* automatically on next login.
*/ */
export class UpdateUserDto { export class UpdateUserDto {
/** // Currently no fields are updatable locally
* User's display name // Reserved for future local profile extensions
*/
@ApiProperty({
description: "User's display name",
example: 'John Doe',
required: false,
minLength: 1,
maxLength: 100,
})
@IsOptional()
@IsString()
@MinLength(1, { message: 'Name must not be empty' })
@MaxLength(100, { message: 'Name must not exceed 100 characters' })
name?: string;
} }

View File

@@ -1,9 +1,9 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException, ForbiddenException } from '@nestjs/common';
import { UsersService } from './users.service'; import { UsersService } from './users.service';
import { PrismaService } from '../database/prisma.service'; import { PrismaService } from '../database/prisma.service';
import type { UpdateUserDto } from './dto/update-user.dto'; import { NotFoundException, ForbiddenException } from '@nestjs/common';
import type { User } from '@prisma/client'; import { User } from '@prisma/client';
import { UpdateUserDto } from './dto/update-user.dto';
describe('UsersService', () => { describe('UsersService', () => {
let service: UsersService; let service: UsersService;
@@ -12,14 +12,14 @@ describe('UsersService', () => {
const mockUser: User = { const mockUser: User = {
id: '550e8400-e29b-41d4-a716-446655440000', id: '550e8400-e29b-41d4-a716-446655440000',
keycloakSub: 'f:realm:user123', keycloakSub: 'f:realm:user123',
email: 'john@example.com', email: 'test@example.com',
name: 'John Doe', name: 'Test User',
username: 'johndoe', username: 'testuser',
picture: 'https://example.com/avatar.jpg', picture: 'https://example.com/avatar.jpg',
roles: ['user', 'premium'], roles: ['user', 'premium'],
createdAt: new Date('2024-01-15T10:30:00.000Z'),
updatedAt: new Date('2024-01-15T10:30:00.000Z'),
lastLoginAt: new Date('2024-01-15T10:30:00.000Z'), lastLoginAt: new Date('2024-01-15T10:30:00.000Z'),
createdAt: new Date('2024-01-01T00:00:00.000Z'),
updatedAt: new Date('2024-01-15T10:30:00.000Z'),
}; };
const mockPrismaService = { const mockPrismaService = {
@@ -28,6 +28,7 @@ describe('UsersService', () => {
findUnique: jest.fn(), findUnique: jest.fn(),
update: jest.fn(), update: jest.fn(),
delete: jest.fn(), delete: jest.fn(),
upsert: jest.fn(),
}, },
}; };
@@ -45,7 +46,6 @@ describe('UsersService', () => {
service = module.get<UsersService>(UsersService); service = module.get<UsersService>(UsersService);
prismaService = module.get<PrismaService>(PrismaService); prismaService = module.get<PrismaService>(PrismaService);
// Clear all mocks before each test
jest.clearAllMocks(); jest.clearAllMocks();
}); });
@@ -54,9 +54,9 @@ describe('UsersService', () => {
}); });
describe('createFromToken', () => { describe('createFromToken', () => {
it('should create a new user from Keycloak token data', async () => { it('should create a new user when user does not exist', async () => {
const tokenData = { const tokenData = {
keycloakSub: 'f:realm:user123', keycloakSub: 'f:realm:newuser',
email: 'john@example.com', email: 'john@example.com',
name: 'John Doe', name: 'John Doe',
username: 'johndoe', username: 'johndoe',
@@ -65,53 +65,146 @@ describe('UsersService', () => {
}; };
mockPrismaService.user.findUnique.mockResolvedValue(null); mockPrismaService.user.findUnique.mockResolvedValue(null);
mockPrismaService.user.create.mockResolvedValue(mockUser); mockPrismaService.user.upsert.mockResolvedValue({
...mockUser,
...tokenData,
});
const user = await service.createFromToken(tokenData); const user = await service.createFromToken(tokenData);
expect(user).toBeDefined(); expect(user).toBeDefined();
expect(user.id).toBeDefined();
expect(user.keycloakSub).toBe('f:realm:user123');
expect(user.email).toBe('john@example.com');
expect(user.name).toBe('John Doe');
expect(user.username).toBe('johndoe');
expect(user.picture).toBe('https://example.com/avatar.jpg');
expect(user.roles).toEqual(['user', 'premium']);
expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({ expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({
where: { keycloakSub: tokenData.keycloakSub }, where: { keycloakSub: tokenData.keycloakSub },
}); });
expect(mockPrismaService.user.create).toHaveBeenCalledWith({ expect(mockPrismaService.user.upsert).toHaveBeenCalledWith({
data: expect.objectContaining({ where: { keycloakSub: tokenData.keycloakSub },
update: expect.objectContaining({
email: tokenData.email,
name: tokenData.name,
username: tokenData.username,
picture: tokenData.picture,
roles: tokenData.roles,
lastLoginAt: expect.any(Date),
}),
create: expect.objectContaining({
keycloakSub: tokenData.keycloakSub, keycloakSub: tokenData.keycloakSub,
email: tokenData.email, email: tokenData.email,
name: tokenData.name, name: tokenData.name,
username: tokenData.username, username: tokenData.username,
picture: tokenData.picture, picture: tokenData.picture,
roles: tokenData.roles, roles: tokenData.roles,
lastLoginAt: expect.any(Date),
}), }),
}); });
}); });
it('should return existing user if keycloakSub already exists', async () => { it('should update all fields when profile data changed', async () => {
const tokenData = { const tokenData = {
keycloakSub: 'f:realm:user123', keycloakSub: 'f:realm:user123',
email: 'john@example.com', email: 'newemail@example.com', // Changed
name: 'John Doe', name: 'New Name', // Changed
username: 'testuser',
picture: 'https://example.com/avatar.jpg',
roles: ['user', 'premium'],
}; };
mockPrismaService.user.findUnique.mockResolvedValue(mockUser); const existingUser = { ...mockUser };
const updatedUser = {
...mockUser,
email: tokenData.email,
name: tokenData.name,
lastLoginAt: new Date(),
};
mockPrismaService.user.findUnique.mockResolvedValue(existingUser);
mockPrismaService.user.update.mockResolvedValue(updatedUser);
const user = await service.createFromToken(tokenData); const user = await service.createFromToken(tokenData);
expect(user).toEqual(mockUser); expect(user).toEqual(updatedUser);
expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({ expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({
where: { keycloakSub: tokenData.keycloakSub }, where: { keycloakSub: tokenData.keycloakSub },
}); });
expect(mockPrismaService.user.create).not.toHaveBeenCalled(); expect(mockPrismaService.user.update).toHaveBeenCalledWith({
where: { keycloakSub: tokenData.keycloakSub },
data: {
email: tokenData.email,
name: tokenData.name,
username: tokenData.username,
picture: tokenData.picture,
roles: tokenData.roles,
lastLoginAt: expect.any(Date),
},
});
}); });
it('should handle optional fields', async () => { it('should only update lastLoginAt when profile unchanged', async () => {
const tokenData = {
keycloakSub: 'f:realm:user123',
email: 'test@example.com', // Same
name: 'Test User', // Same
username: 'testuser', // Same
picture: 'https://example.com/avatar.jpg', // Same
roles: ['user', 'premium'], // Same
};
const existingUser = { ...mockUser };
const updatedUser = {
...mockUser,
lastLoginAt: new Date('2024-02-01T10:00:00.000Z'),
};
mockPrismaService.user.findUnique.mockResolvedValue(existingUser);
mockPrismaService.user.update.mockResolvedValue(updatedUser);
const user = await service.createFromToken(tokenData);
expect(user).toEqual(updatedUser);
expect(mockPrismaService.user.update).toHaveBeenCalledWith({
where: { keycloakSub: tokenData.keycloakSub },
data: {
lastLoginAt: expect.any(Date),
},
});
});
it('should detect role changes and update profile', async () => {
const tokenData = {
keycloakSub: 'f:realm:user123',
email: 'test@example.com',
name: 'Test User',
username: 'testuser',
picture: 'https://example.com/avatar.jpg',
roles: ['user', 'premium', 'admin'], // Changed: added admin
};
const existingUser = { ...mockUser };
const updatedUser = {
...mockUser,
roles: tokenData.roles,
lastLoginAt: new Date(),
};
mockPrismaService.user.findUnique.mockResolvedValue(existingUser);
mockPrismaService.user.update.mockResolvedValue(updatedUser);
const user = await service.createFromToken(tokenData);
expect(user).toEqual(updatedUser);
expect(mockPrismaService.user.update).toHaveBeenCalledWith({
where: { keycloakSub: tokenData.keycloakSub },
data: {
email: tokenData.email,
name: tokenData.name,
username: tokenData.username,
picture: tokenData.picture,
roles: tokenData.roles,
lastLoginAt: expect.any(Date),
},
});
});
it('should handle optional fields when creating new user', async () => {
const tokenData = { const tokenData = {
keycloakSub: 'f:realm:user456', keycloakSub: 'f:realm:user456',
email: 'jane@example.com', email: 'jane@example.com',
@@ -120,13 +213,22 @@ describe('UsersService', () => {
const newUser = { ...mockUser, username: null, picture: null, roles: [] }; const newUser = { ...mockUser, username: null, picture: null, roles: [] };
mockPrismaService.user.findUnique.mockResolvedValue(null); mockPrismaService.user.findUnique.mockResolvedValue(null);
mockPrismaService.user.create.mockResolvedValue(newUser); mockPrismaService.user.upsert.mockResolvedValue(newUser);
const user = await service.createFromToken(tokenData); const user = await service.createFromToken(tokenData);
expect(user).toBeDefined(); expect(user).toBeDefined();
expect(mockPrismaService.user.create).toHaveBeenCalledWith({ expect(mockPrismaService.user.upsert).toHaveBeenCalledWith({
data: expect.objectContaining({ where: { keycloakSub: tokenData.keycloakSub },
update: expect.objectContaining({
email: tokenData.email,
name: tokenData.name,
username: undefined,
picture: undefined,
roles: [],
lastLoginAt: expect.any(Date),
}),
create: expect.objectContaining({
keycloakSub: tokenData.keycloakSub, keycloakSub: tokenData.keycloakSub,
email: tokenData.email, email: tokenData.email,
name: tokenData.name, name: tokenData.name,
@@ -136,10 +238,89 @@ describe('UsersService', () => {
}), }),
}); });
}); });
it('should normalize empty roles array', async () => {
const tokenData = {
keycloakSub: 'f:realm:user123',
email: 'test@example.com',
name: 'Test User',
roles: undefined,
};
const existingUser = { ...mockUser, roles: ['user'] };
const updatedUser = { ...mockUser, roles: [] };
mockPrismaService.user.findUnique.mockResolvedValue(existingUser);
mockPrismaService.user.update.mockResolvedValue(updatedUser);
await service.createFromToken(tokenData);
expect(mockPrismaService.user.update).toHaveBeenCalledWith({
where: { keycloakSub: tokenData.keycloakSub },
data: expect.objectContaining({
roles: [],
}),
});
});
it('should detect username change', async () => {
const tokenData = {
keycloakSub: 'f:realm:user123',
email: 'test@example.com',
name: 'Test User',
username: 'newusername', // Changed
picture: 'https://example.com/avatar.jpg',
roles: ['user', 'premium'],
};
const existingUser = { ...mockUser };
const updatedUser = { ...mockUser, username: 'newusername' };
mockPrismaService.user.findUnique.mockResolvedValue(existingUser);
mockPrismaService.user.update.mockResolvedValue(updatedUser);
await service.createFromToken(tokenData);
expect(mockPrismaService.user.update).toHaveBeenCalledWith({
where: { keycloakSub: tokenData.keycloakSub },
data: expect.objectContaining({
username: 'newusername',
}),
});
});
it('should detect picture change', async () => {
const tokenData = {
keycloakSub: 'f:realm:user123',
email: 'test@example.com',
name: 'Test User',
username: 'testuser',
picture: 'https://example.com/new-avatar.jpg', // Changed
roles: ['user', 'premium'],
};
const existingUser = { ...mockUser };
const updatedUser = {
...mockUser,
picture: 'https://example.com/new-avatar.jpg',
};
mockPrismaService.user.findUnique.mockResolvedValue(existingUser);
mockPrismaService.user.update.mockResolvedValue(updatedUser);
await service.createFromToken(tokenData);
expect(mockPrismaService.user.update).toHaveBeenCalledWith({
where: { keycloakSub: tokenData.keycloakSub },
data: expect.objectContaining({
picture: 'https://example.com/new-avatar.jpg',
}),
});
});
}); });
describe('findByKeycloakSub', () => { describe('findByKeycloakSub', () => {
it('should return a user by keycloakSub', async () => { it('should find a user by keycloakSub', async () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser); mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
const user = await service.findByKeycloakSub('f:realm:user123'); const user = await service.findByKeycloakSub('f:realm:user123');
@@ -156,14 +337,11 @@ describe('UsersService', () => {
const user = await service.findByKeycloakSub('nonexistent'); const user = await service.findByKeycloakSub('nonexistent');
expect(user).toBeNull(); expect(user).toBeNull();
expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({
where: { keycloakSub: 'nonexistent' },
});
}); });
}); });
describe('findOne', () => { describe('findOne', () => {
it('should return a user by ID', async () => { it('should find a user by ID', async () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser); mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
const user = await service.findOne(mockUser.id); const user = await service.findOne(mockUser.id);
@@ -175,7 +353,7 @@ describe('UsersService', () => {
}); });
it('should throw NotFoundException if user not found', async () => { it('should throw NotFoundException if user not found', async () => {
const nonexistentId = 'nonexistent-id'; const nonexistentId = '550e8400-0000-0000-0000-000000000000';
mockPrismaService.user.findUnique.mockResolvedValue(null); mockPrismaService.user.findUnique.mockResolvedValue(null);
await expect(service.findOne(nonexistentId)).rejects.toThrow( await expect(service.findOne(nonexistentId)).rejects.toThrow(
@@ -187,66 +365,12 @@ describe('UsersService', () => {
}); });
}); });
describe('updateFromToken', () => {
it('should update user from token data', async () => {
const updateData = {
email: 'updated@example.com',
name: 'Updated Name',
lastLoginAt: new Date('2024-01-20T14:45:00.000Z'),
};
const updatedUser = { ...mockUser, ...updateData };
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
mockPrismaService.user.update.mockResolvedValue(updatedUser);
const user = await service.updateFromToken('f:realm:user123', updateData);
expect(user).toEqual(updatedUser);
expect(mockPrismaService.user.update).toHaveBeenCalledWith({
where: { keycloakSub: 'f:realm:user123' },
data: expect.objectContaining({
email: updateData.email,
name: updateData.name,
lastLoginAt: updateData.lastLoginAt,
}),
});
});
it('should throw NotFoundException if user not found', async () => {
mockPrismaService.user.findUnique.mockResolvedValue(null);
await expect(
service.updateFromToken('nonexistent', { name: 'Test' }),
).rejects.toThrow(NotFoundException);
});
it('should only update provided fields', async () => {
const updateData = {
name: 'New Name',
};
const updatedUser = { ...mockUser, ...updateData };
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
mockPrismaService.user.update.mockResolvedValue(updatedUser);
await service.updateFromToken('f:realm:user123', updateData);
expect(mockPrismaService.user.update).toHaveBeenCalledWith({
where: { keycloakSub: 'f:realm:user123' },
data: { name: 'New Name' },
});
});
});
describe('update', () => { describe('update', () => {
it('should update user profile', async () => { it('should allow update but currently no fields are updatable', async () => {
const updateDto: UpdateUserDto = { const updateDto: UpdateUserDto = {};
name: 'Updated Name',
};
const updatedUser = { ...mockUser, name: updateDto.name };
mockPrismaService.user.findUnique.mockResolvedValue(mockUser); mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
mockPrismaService.user.update.mockResolvedValue(updatedUser); mockPrismaService.user.update.mockResolvedValue(mockUser);
const user = await service.update( const user = await service.update(
mockUser.id, mockUser.id,
@@ -254,17 +378,15 @@ describe('UsersService', () => {
mockUser.keycloakSub, mockUser.keycloakSub,
); );
expect(user.name).toBe(updateDto.name); expect(user).toEqual(mockUser);
expect(mockPrismaService.user.update).toHaveBeenCalledWith({ expect(mockPrismaService.user.update).toHaveBeenCalledWith({
where: { id: mockUser.id }, where: { id: mockUser.id },
data: { name: updateDto.name }, data: {},
}); });
}); });
it('should throw ForbiddenException if user tries to update someone else', async () => { it('should throw ForbiddenException if user tries to update someone else', async () => {
const updateDto: UpdateUserDto = { const updateDto: UpdateUserDto = {};
name: 'Hacker Name',
};
mockPrismaService.user.findUnique.mockResolvedValue(mockUser); mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
@@ -276,9 +398,7 @@ describe('UsersService', () => {
}); });
it('should throw NotFoundException if user not found', async () => { it('should throw NotFoundException if user not found', async () => {
const updateDto: UpdateUserDto = { const updateDto: UpdateUserDto = {};
name: 'Test',
};
mockPrismaService.user.findUnique.mockResolvedValue(null); mockPrismaService.user.findUnique.mockResolvedValue(null);
@@ -318,4 +438,153 @@ describe('UsersService', () => {
).rejects.toThrow(NotFoundException); ).rejects.toThrow(NotFoundException);
}); });
}); });
describe('syncProfileFromToken', () => {
it('should sync profile data from Keycloak token for existing user', async () => {
const profileData = {
email: 'updated@example.com',
name: 'Updated Name',
username: 'updateduser',
picture: 'https://example.com/new-avatar.jpg',
roles: ['user', 'admin'],
};
const updatedUser = { ...mockUser, ...profileData };
mockPrismaService.user.update.mockResolvedValue(updatedUser);
const user = await service.syncProfileFromToken(
'f:realm:user123',
profileData,
);
expect(user).toEqual(updatedUser);
expect(mockPrismaService.user.update).toHaveBeenCalledWith({
where: { keycloakSub: 'f:realm:user123' },
data: {
email: profileData.email,
name: profileData.name,
username: profileData.username,
picture: profileData.picture,
roles: profileData.roles,
},
});
});
it('should handle profile data with missing optional fields', async () => {
const profileData = {
email: 'minimal@example.com',
name: 'Minimal User',
};
const updatedUser = {
...mockUser,
...profileData,
username: null,
picture: null,
roles: [],
};
mockPrismaService.user.update.mockResolvedValue(updatedUser);
const user = await service.syncProfileFromToken(
'f:realm:user123',
profileData,
);
expect(user).toBeDefined();
expect(mockPrismaService.user.update).toHaveBeenCalledWith({
where: { keycloakSub: 'f:realm:user123' },
data: {
email: profileData.email,
name: profileData.name,
username: undefined,
picture: undefined,
roles: [],
},
});
});
it('should throw NotFoundException if user not found', async () => {
const profileData = {
email: 'test@example.com',
name: 'Test User',
};
mockPrismaService.user.update.mockRejectedValue({
code: 'P2025',
message: 'Record not found',
});
await expect(
service.syncProfileFromToken('nonexistent', profileData),
).rejects.toThrow(NotFoundException);
await expect(
service.syncProfileFromToken('nonexistent', profileData),
).rejects.toThrow('User with keycloakSub nonexistent not found');
});
it('should normalize empty roles array', async () => {
const profileData = {
email: 'test@example.com',
name: 'Test User',
roles: undefined,
};
const updatedUser = { ...mockUser, roles: [] };
mockPrismaService.user.update.mockResolvedValue(updatedUser);
await service.syncProfileFromToken('f:realm:user123', profileData);
expect(mockPrismaService.user.update).toHaveBeenCalledWith({
where: { keycloakSub: 'f:realm:user123' },
data: expect.objectContaining({
roles: [],
}),
});
});
it('should overwrite all profile fields from Keycloak', async () => {
const profileData = {
email: 'keycloak@example.com',
name: 'Keycloak Name',
username: 'keycloakuser',
picture: 'https://keycloak.example.com/avatar.jpg',
roles: ['external-role'],
};
const updatedUser = { ...mockUser, ...profileData };
mockPrismaService.user.update.mockResolvedValue(updatedUser);
const user = await service.syncProfileFromToken(
'f:realm:user123',
profileData,
);
expect(user.name).toBe('Keycloak Name');
expect(user.email).toBe('keycloak@example.com');
expect(mockPrismaService.user.update).toHaveBeenCalledWith({
where: { keycloakSub: 'f:realm:user123' },
data: {
email: profileData.email,
name: profileData.name,
username: profileData.username,
picture: profileData.picture,
roles: profileData.roles,
},
});
});
it('should rethrow non-P2025 errors', async () => {
const profileData = {
email: 'test@example.com',
name: 'Test User',
};
const dbError = new Error('Database connection failed');
mockPrismaService.user.update.mockRejectedValue(dbError);
await expect(
service.syncProfileFromToken('f:realm:user123', profileData),
).rejects.toThrow('Database connection failed');
});
});
}); });

View File

@@ -7,6 +7,7 @@ import {
import { PrismaService } from '../database/prisma.service'; import { PrismaService } from '../database/prisma.service';
import { User } from '@prisma/client'; import { User } from '@prisma/client';
import type { UpdateUserDto } from './dto/update-user.dto'; import type { UpdateUserDto } from './dto/update-user.dto';
import { PrismaClientKnownRequestError } from '@prisma/client/runtime/client';
/** /**
* Interface for creating a user from Keycloak token * Interface for creating a user from Keycloak token
@@ -20,24 +21,36 @@ export interface CreateUserFromTokenDto {
roles?: string[]; roles?: string[];
} }
/**
* Interface for updating a user from Keycloak token
*/
export interface UpdateUserFromTokenDto {
email?: string;
name?: string;
username?: string;
picture?: string;
roles?: string[];
lastLoginAt?: Date;
}
/** /**
* Users Service * Users Service
* *
* Manages user data synchronized from Keycloak OIDC using Prisma ORM. * Manages user data synchronized from Keycloak OIDC using Prisma ORM.
* Users are created automatically when they first authenticate via Keycloak. * Users are created automatically when they first authenticate via Keycloak.
* Direct user creation is not allowed - users must authenticate via Keycloak first. * Direct user creation is not allowed - users must authenticate via Keycloak first.
*
* ## Profile Data Ownership
*
* Keycloak is the single source of truth for authentication and profile data:
* - `keycloakSub`: Managed by Keycloak (immutable identifier)
* - `email`: Managed by Keycloak
* - `name`: Managed by Keycloak
* - `username`: Managed by Keycloak
* - `picture`: Managed by Keycloak
* - `roles`: Managed by Keycloak
*
* Local application data:
* - `lastLoginAt`: Tracked locally for analytics
*
* ## Sync Strategy
*
* - On every login: Compares Keycloak data with local data
* - If profile changed: Updates all fields (name, email, picture, roles)
* - If profile unchanged: Only updates `lastLoginAt`
* - Read operations are cheaper than writes in PostgreSQL
* - Explicit sync: Call `syncProfileFromToken()` for force sync (webhooks, manual refresh)
*
* This optimizes performance by avoiding unnecessary writes while keeping
* profile data in sync with Keycloak on every authentication.
*/ */
@Injectable() @Injectable()
export class UsersService { export class UsersService {
@@ -46,38 +59,146 @@ export class UsersService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
/** /**
* Creates a new user from Keycloak token data. * Creates a new user or syncs/tracks login for existing users.
* This method is called automatically during authentication flow. * This method is called automatically during authentication flow.
* *
* For new users: Creates the user with full profile data from token.
* For existing users: Compares Keycloak data with local data and only updates if changed.
* This optimizes performance since reads are cheaper than writes in PostgreSQL.
*
* @param createDto - User data extracted from Keycloak JWT token * @param createDto - User data extracted from Keycloak JWT token
* @returns The newly created user * @returns The user entity
*/ */
async createFromToken(createDto: CreateUserFromTokenDto): Promise<User> { async createFromToken(createDto: CreateUserFromTokenDto): Promise<User> {
// Check if user already exists // Normalize roles once to avoid duplication
const existingUser = await this.findByKeycloakSub(createDto.keycloakSub); const roles = createDto.roles || [];
const now = new Date();
// Check if user exists first (read is cheaper than write)
const existingUser = await this.prisma.user.findUnique({
where: { keycloakSub: createDto.keycloakSub },
});
if (existingUser) { if (existingUser) {
this.logger.warn( // Compare profile data to detect changes
`Attempted to create duplicate user with keycloakSub: ${createDto.keycloakSub}`, const profileChanged =
); existingUser.email !== createDto.email ||
return existingUser; existingUser.name !== createDto.name ||
existingUser.username !== createDto.username ||
existingUser.picture !== createDto.picture ||
JSON.stringify(existingUser.roles) !== JSON.stringify(roles);
if (profileChanged) {
// Profile data changed - update everything
this.logger.debug(
`Profile changed for user: ${existingUser.id} (${createDto.keycloakSub})`,
);
return await this.prisma.user.update({
where: { keycloakSub: createDto.keycloakSub },
data: {
email: createDto.email,
name: createDto.name,
username: createDto.username,
picture: createDto.picture,
roles,
lastLoginAt: now,
},
});
} else {
// Profile unchanged - only update lastLoginAt
this.logger.debug(
`Login tracked for user: ${existingUser.id} (${createDto.keycloakSub})`,
);
return await this.prisma.user.update({
where: { keycloakSub: createDto.keycloakSub },
data: {
lastLoginAt: now,
},
});
}
} }
const newUser = await this.prisma.user.create({ // New user - create with all profile data
data: { // Use upsert to handle race condition if user was created between findUnique and here
this.logger.log(`Creating new user from token: ${createDto.keycloakSub}`);
const user = await this.prisma.user.upsert({
where: { keycloakSub: createDto.keycloakSub },
update: {
// If created by concurrent request, update with current data
email: createDto.email,
name: createDto.name,
username: createDto.username,
picture: createDto.picture,
roles,
lastLoginAt: now,
},
create: {
keycloakSub: createDto.keycloakSub, keycloakSub: createDto.keycloakSub,
email: createDto.email, email: createDto.email,
name: createDto.name, name: createDto.name,
username: createDto.username, username: createDto.username,
picture: createDto.picture, picture: createDto.picture,
roles: createDto.roles || [], roles,
lastLoginAt: new Date(), lastLoginAt: now,
}, },
}); });
this.logger.log(`Created new user: ${newUser.id} (${newUser.keycloakSub})`); return user;
}
return newUser; /**
* Force syncs user profile data from Keycloak token.
* This should be called when explicit profile sync is needed.
*
* Use cases:
* - Keycloak webhook notification of profile change
* - Manual profile refresh request
* - Administrative profile sync operations
*
* Note: createFromToken() already handles profile sync on login automatically.
* This method is for explicit, out-of-band sync operations.
*
* @param keycloakSub - The Keycloak subject identifier
* @param profileData - Profile data from Keycloak token
* @returns The updated user
* @throws NotFoundException if the user is not found
*/
async syncProfileFromToken(
keycloakSub: string,
profileData: Omit<CreateUserFromTokenDto, 'keycloakSub'>,
): Promise<User> {
// Normalize roles once
const roles = profileData.roles || [];
try {
const updatedUser = await this.prisma.user.update({
where: { keycloakSub },
data: {
email: profileData.email,
name: profileData.name,
username: profileData.username,
picture: profileData.picture,
roles,
},
});
this.logger.log(
`Profile synced from Keycloak for user: ${updatedUser.id} (${keycloakSub})`,
);
return updatedUser;
} catch (error) {
// Prisma throws P2025 when record is not found
if (
error instanceof PrismaClientKnownRequestError &&
error.code === 'P2025'
) {
throw new NotFoundException(
`User with keycloakSub ${keycloakSub} not found`,
);
}
throw error;
}
} }
/** /**
@@ -113,64 +234,13 @@ export class UsersService {
return user; return user;
} }
/**
* Updates a user's profile from Keycloak token data.
* This syncs the user's data from Keycloak during authentication.
*
* @param keycloakSub - The Keycloak subject identifier
* @param updateDto - Updated user data from token
* @returns The updated user
* @throws NotFoundException if the user is not found
*/
async updateFromToken(
keycloakSub: string,
updateDto: UpdateUserFromTokenDto,
): Promise<User> {
const user = await this.findByKeycloakSub(keycloakSub);
if (!user) {
throw new NotFoundException(
`User with keycloakSub ${keycloakSub} not found`,
);
}
// Prepare update data - only include defined fields
const updateData: {
email?: string;
name?: string;
username?: string;
picture?: string;
roles?: string[];
lastLoginAt?: Date;
} = {};
if (updateDto.email !== undefined) updateData.email = updateDto.email;
if (updateDto.name !== undefined) updateData.name = updateDto.name;
if (updateDto.username !== undefined)
updateData.username = updateDto.username;
if (updateDto.picture !== undefined) updateData.picture = updateDto.picture;
if (updateDto.roles !== undefined) updateData.roles = updateDto.roles;
if (updateDto.lastLoginAt !== undefined)
updateData.lastLoginAt = updateDto.lastLoginAt;
const updatedUser = await this.prisma.user.update({
where: { keycloakSub },
data: updateData,
});
this.logger.debug(
`Synced user from token: ${updatedUser.id} (${keycloakSub})`,
);
return updatedUser;
}
/** /**
* Updates a user's profile. * Updates a user's profile.
* Users can only update their own profile (enforced by controller). * Currently, all profile fields are managed by Keycloak and cannot be updated locally.
* This method exists for future extensibility if local profile fields are added.
* *
* @param id - The user's internal ID * @param id - The user's internal ID
* @param updateUserDto - The fields to update * @param updateUserDto - The fields to update (currently none supported)
* @param requestingUserKeycloakSub - The Keycloak sub of the requesting user * @param requestingUserKeycloakSub - The Keycloak sub of the requesting user
* @returns The updated user * @returns The updated user
* @throws NotFoundException if the user is not found * @throws NotFoundException if the user is not found
@@ -191,22 +261,16 @@ export class UsersService {
throw new ForbiddenException('You can only update your own profile'); throw new ForbiddenException('You can only update your own profile');
} }
// Only allow updating specific fields via the public API // Currently no fields are updatable locally - all managed by Keycloak
// Security-sensitive fields (keycloakSub, roles, etc.) cannot be updated // This structure allows for future extensibility if local fields are added
const updateData: { const updateData: Record<string, never> = {};
name?: string;
} = {};
if (updateUserDto.name !== undefined) {
updateData.name = updateUserDto.name;
}
const updatedUser = await this.prisma.user.update({ const updatedUser = await this.prisma.user.update({
where: { id }, where: { id },
data: updateData, data: updateData,
}); });
this.logger.log(`User ${id} updated their profile`); this.logger.log(`User ${id} profile update requested`);
return updatedUser; return updatedUser;
} }