diff --git a/.env.example b/.env.example index 7ca792a..3522382 100644 --- a/.env.example +++ b/.env.example @@ -11,12 +11,17 @@ REDIS_HOST=localhost REDIS_PORT=6379 # JWT Configuration -# The expected issuer of the JWT token (usually {KEYCLOAK_AUTH_SERVER_URL}/realms/{KEYCLOAK_REALM}) +# Keycloak realm URL (no trailing slash). Example: https://keycloak.example.com/realms/friendolls JWT_ISSUER=https://your-keycloak-instance.com/auth/realms/your-realm-name -# The expected audience in the JWT token (usually the client ID) +# The expected audience in the JWT token (usually the client ID for this API) JWT_AUDIENCE=friendolls-api +# Keycloak client used for access tokens +KEYCLOAK_CLIENT_ID=friendolls-api +# Optional: client secret for revoking refresh tokens (omit for public clients) +KEYCLOAK_CLIENT_SECRET= + # JWKS URI for fetching public keys to verify JWT signatures # Format: {KEYCLOAK_AUTH_SERVER_URL}/realms/{KEYCLOAK_REALM}/protocol/openid-connect/certs JWKS_URI=https://your-keycloak-instance.com/auth/realms/your-realm-name/protocol/openid-connect/certs diff --git a/package.json b/package.json index 0c71023..8251fa2 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "class-validator": "^0.14.2", "dotenv": "^17.2.3", "ioredis": "^5.8.2", + "axios": "^1.7.9", "jsonwebtoken": "^9.0.2", "jwks-rsa": "^3.2.0", "passport": "^0.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7d2108..26d1e6b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: '@socket.io/redis-adapter': specifier: ^8.3.0 version: 8.3.0(socket.io-adapter@2.5.5) + axios: + specifier: ^1.7.9 + version: 1.13.2 class-transformer: specifier: ^0.5.1 version: 0.5.1 @@ -1494,6 +1497,9 @@ packages: resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} engines: {node: '>= 6.0.0'} + axios@1.13.2: + resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} + babel-jest@30.2.0: resolution: {integrity: sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -2112,6 +2118,15 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -3108,6 +3123,9 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -5337,6 +5355,14 @@ snapshots: aws-ssl-profiles@1.1.2: {} + axios@1.13.2: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + babel-jest@30.2.0(@babel/core@7.28.5): dependencies: '@babel/core': 7.28.5 @@ -6000,6 +6026,8 @@ snapshots: flatted@3.3.3: {} + follow-redirects@1.15.11: {} + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -7141,6 +7169,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-from-env@1.1.0: {} + punycode@2.3.1: {} pure-rand@6.1.0: {} diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts index ba5eb6f..5d1b9a4 100644 --- a/src/auth/auth.service.spec.ts +++ b/src/auth/auth.service.spec.ts @@ -3,6 +3,9 @@ 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'; +import { ConfigService } from '@nestjs/config'; +import axios from 'axios'; +import { URLSearchParams } from 'url'; describe('AuthService', () => { let service: AuthService; @@ -48,12 +51,22 @@ describe('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); - // Reset mocks jest.clearAllMocks(); }); @@ -61,6 +74,57 @@ describe('AuthService', () => { 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 any, + missingConfigService, + ); + const warnSpy = jest.spyOn(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(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(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 () => { mockCreateFromToken.mockReturnValue(mockUser); @@ -170,7 +234,7 @@ describe('AuthService', () => { name: 'Empty Sub User', }); - const result = await service.syncUserFromToken(authUserEmptySub); + await service.syncUserFromToken(authUserEmptySub); expect(mockCreateFromToken).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 6842884..cb3651d 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -1,4 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import axios, { AxiosError } from 'axios'; +import { URLSearchParams } from 'url'; import { UsersService } from '../users/users.service'; import type { AuthenticatedUser } from './decorators/current-user.decorator'; import { User } from '../users/users.entity'; @@ -34,7 +37,63 @@ import { User } from '../users/users.entity'; export class AuthService { private readonly logger = new Logger(AuthService.name); - constructor(private readonly usersService: UsersService) {} + 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 { + const issuer = this.configService.get('JWT_ISSUER'); + const clientId = this.configService.get('KEYCLOAK_CLIENT_ID'); + const clientSecret = this.configService.get( + 'KEYCLOAK_CLIENT_SECRET', + ); + + 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. @@ -52,8 +111,6 @@ export class AuthService { const { keycloakSub, email, name, username, picture, roles } = authenticatedUser; - // Use createFromToken which handles upsert atomically - // This prevents race conditions when multiple requests arrive simultaneously const user = await this.usersService.createFromToken({ keycloakSub, email: email || '', @@ -70,23 +127,11 @@ export class AuthService { * 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. - * - * - For new users: Creates with full profile data - * - For existing users: Returns existing record WITHOUT updating (read-only) - * - * Use this for most API endpoints. Only use syncUserFromToken() for actual - * login events (WebSocket connections, /users/me endpoint). - * - * @param authenticatedUser - User data extracted from JWT token - * @returns The user entity */ async ensureUserExists(authenticatedUser: AuthenticatedUser): Promise { const { keycloakSub, email, name, username, picture, roles } = authenticatedUser; - // Use optimized findOrCreate method - // This returns existing users immediately (1 read) - // And creates new users if needed (1 read + 1 write) return await this.usersService.findOrCreate({ keycloakSub, email: email || '', @@ -97,24 +142,10 @@ export class AuthService { }); } - /** - * Validates if a user has a specific role. - * - * @param user - The authenticated user - * @param requiredRole - The role to check for - * @returns True if the user has the role, false otherwise - */ hasRole(user: AuthenticatedUser, requiredRole: string): boolean { return user.roles?.includes(requiredRole) ?? false; } - /** - * Validates if a user has any of the specified roles. - * - * @param user - The authenticated user - * @param requiredRoles - Array of roles to check for - * @returns True if the user has at least one of the roles, false otherwise - */ hasAnyRole(user: AuthenticatedUser, requiredRoles: string[]): boolean { if (!user.roles || user.roles.length === 0) { return false; @@ -122,13 +153,6 @@ export class AuthService { return requiredRoles.some((role) => user.roles!.includes(role)); } - /** - * Validates if a user has all of the specified roles. - * - * @param user - The authenticated user - * @param requiredRoles - Array of roles to check for - * @returns True if the user has all of the roles, false otherwise - */ hasAllRoles(user: AuthenticatedUser, requiredRoles: string[]): boolean { if (!user.roles || user.roles.length === 0) { return false; diff --git a/src/auth/decorators/current-user.decorator.ts b/src/auth/decorators/current-user.decorator.ts index e572977..8b00f54 100644 --- a/src/auth/decorators/current-user.decorator.ts +++ b/src/auth/decorators/current-user.decorator.ts @@ -11,6 +11,7 @@ export interface AuthenticatedUser { username?: string; picture?: string; roles?: string[]; + sessionState?: string; } /** diff --git a/src/auth/strategies/jwt.strategy.ts b/src/auth/strategies/jwt.strategy.ts index 5b9caae..d6344f9 100644 --- a/src/auth/strategies/jwt.strategy.ts +++ b/src/auth/strategies/jwt.strategy.ts @@ -22,6 +22,7 @@ export interface JwtPayload { roles: string[]; }; }; + session_state?: string; iss: string; // Issuer aud: string | string[]; // Audience exp: number; // Expiration time @@ -96,6 +97,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { username?: string; picture?: string; roles?: string[]; + sessionState?: string; }> { this.logger.debug(`Validating JWT token payload`); this.logger.debug(` Issuer: ${payload.iss}`); @@ -132,6 +134,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { username: payload.preferred_username, picture: payload.picture, roles: roles.length > 0 ? roles : undefined, + sessionState: payload.session_state, }; this.logger.log( diff --git a/src/users/dto/logout-request.dto.ts b/src/users/dto/logout-request.dto.ts new file mode 100644 index 0000000..b1e92cd --- /dev/null +++ b/src/users/dto/logout-request.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class LogoutRequestDto { + @ApiProperty({ description: 'Refresh token to revoke' }) + @IsString() + @IsNotEmpty() + refreshToken!: string; + + @ApiPropertyOptional({ description: 'Session state identifier' }) + @IsOptional() + @IsString() + sessionState?: string; +} diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 6c524c5..22409ca 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -8,6 +8,7 @@ import { HttpCode, UseGuards, Logger, + Post, } from '@nestjs/common'; import { ApiTags, @@ -17,6 +18,7 @@ import { ApiBearerAuth, ApiUnauthorizedResponse, ApiForbiddenResponse, + ApiNoContentResponse, } from '@nestjs/swagger'; import { UsersService } from './users.service'; import { User, UserResponseDto } from './users.entity'; @@ -27,6 +29,7 @@ import { type AuthenticatedUser, } from '../auth/decorators/current-user.decorator'; import { AuthService } from '../auth/auth.service'; +import { LogoutRequestDto } from './dto/logout-request.dto'; /** * Users Controller @@ -49,6 +52,28 @@ export class UsersController { private readonly authService: AuthService, ) {} + /** + * Logout current authenticated session by revoking the refresh token. + * Falls back to local cleanup only if revocation fails. + */ + @Post('logout') + @HttpCode(204) + @ApiOperation({ summary: 'Logout current session' }) + @ApiNoContentResponse({ description: 'Session logged out' }) + @ApiUnauthorizedResponse({ description: 'Invalid or missing JWT token' }) + async logout( + @CurrentUser() authUser: AuthenticatedUser, + @Body() body: LogoutRequestDto, + ): Promise { + this.logger.log(`Logout requested for ${authUser.keycloakSub}`); + const refreshToken = body.refreshToken; + + const revoked = await this.authService.revokeToken(refreshToken); + if (!revoked) { + this.logger.warn('Token revocation failed or was skipped'); + } + } + /** * Get current authenticated user's profile. * This endpoint syncs the user from Keycloak token to ensure profile data