sign out
This commit is contained in:
@@ -11,12 +11,17 @@ REDIS_HOST=localhost
|
|||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
|
||||||
# JWT Configuration
|
# 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
|
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
|
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
|
# JWKS URI for fetching public keys to verify JWT signatures
|
||||||
# Format: {KEYCLOAK_AUTH_SERVER_URL}/realms/{KEYCLOAK_REALM}/protocol/openid-connect/certs
|
# 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
|
JWKS_URI=https://your-keycloak-instance.com/auth/realms/your-realm-name/protocol/openid-connect/certs
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
"class-validator": "^0.14.2",
|
"class-validator": "^0.14.2",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"ioredis": "^5.8.2",
|
"ioredis": "^5.8.2",
|
||||||
|
"axios": "^1.7.9",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"jwks-rsa": "^3.2.0",
|
"jwks-rsa": "^3.2.0",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
|
|||||||
30
pnpm-lock.yaml
generated
30
pnpm-lock.yaml
generated
@@ -47,6 +47,9 @@ importers:
|
|||||||
'@socket.io/redis-adapter':
|
'@socket.io/redis-adapter':
|
||||||
specifier: ^8.3.0
|
specifier: ^8.3.0
|
||||||
version: 8.3.0(socket.io-adapter@2.5.5)
|
version: 8.3.0(socket.io-adapter@2.5.5)
|
||||||
|
axios:
|
||||||
|
specifier: ^1.7.9
|
||||||
|
version: 1.13.2
|
||||||
class-transformer:
|
class-transformer:
|
||||||
specifier: ^0.5.1
|
specifier: ^0.5.1
|
||||||
version: 0.5.1
|
version: 0.5.1
|
||||||
@@ -1494,6 +1497,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==}
|
resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==}
|
||||||
engines: {node: '>= 6.0.0'}
|
engines: {node: '>= 6.0.0'}
|
||||||
|
|
||||||
|
axios@1.13.2:
|
||||||
|
resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==}
|
||||||
|
|
||||||
babel-jest@30.2.0:
|
babel-jest@30.2.0:
|
||||||
resolution: {integrity: sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==}
|
resolution: {integrity: sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==}
|
||||||
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
|
engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||||
@@ -2112,6 +2118,15 @@ packages:
|
|||||||
flatted@3.3.3:
|
flatted@3.3.3:
|
||||||
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
|
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:
|
foreground-child@3.3.1:
|
||||||
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -3108,6 +3123,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
|
|
||||||
|
proxy-from-env@1.1.0:
|
||||||
|
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
|
||||||
|
|
||||||
punycode@2.3.1:
|
punycode@2.3.1:
|
||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -5337,6 +5355,14 @@ snapshots:
|
|||||||
|
|
||||||
aws-ssl-profiles@1.1.2: {}
|
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):
|
babel-jest@30.2.0(@babel/core@7.28.5):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.5
|
'@babel/core': 7.28.5
|
||||||
@@ -6000,6 +6026,8 @@ snapshots:
|
|||||||
|
|
||||||
flatted@3.3.3: {}
|
flatted@3.3.3: {}
|
||||||
|
|
||||||
|
follow-redirects@1.15.11: {}
|
||||||
|
|
||||||
foreground-child@3.3.1:
|
foreground-child@3.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
cross-spawn: 7.0.6
|
cross-spawn: 7.0.6
|
||||||
@@ -7141,6 +7169,8 @@ snapshots:
|
|||||||
forwarded: 0.2.0
|
forwarded: 0.2.0
|
||||||
ipaddr.js: 1.9.1
|
ipaddr.js: 1.9.1
|
||||||
|
|
||||||
|
proxy-from-env@1.1.0: {}
|
||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
pure-rand@6.1.0: {}
|
pure-rand@6.1.0: {}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { AuthService } from './auth.service';
|
|||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
import type { AuthenticatedUser } from './decorators/current-user.decorator';
|
import type { AuthenticatedUser } from './decorators/current-user.decorator';
|
||||||
import { User } from '../users/users.entity';
|
import { User } from '../users/users.entity';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { URLSearchParams } from 'url';
|
||||||
|
|
||||||
describe('AuthService', () => {
|
describe('AuthService', () => {
|
||||||
let service: AuthService;
|
let service: AuthService;
|
||||||
@@ -48,12 +51,22 @@ describe('AuthService', () => {
|
|||||||
provide: UsersService,
|
provide: UsersService,
|
||||||
useValue: mockUsersService,
|
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();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<AuthService>(AuthService);
|
service = module.get<AuthService>(AuthService);
|
||||||
|
|
||||||
// Reset mocks
|
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,6 +74,57 @@ describe('AuthService', () => {
|
|||||||
expect(service).toBeDefined();
|
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<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', () => {
|
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 () => {
|
||||||
mockCreateFromToken.mockReturnValue(mockUser);
|
mockCreateFromToken.mockReturnValue(mockUser);
|
||||||
@@ -170,7 +234,7 @@ describe('AuthService', () => {
|
|||||||
name: 'Empty Sub User',
|
name: 'Empty Sub User',
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await service.syncUserFromToken(authUserEmptySub);
|
await service.syncUserFromToken(authUserEmptySub);
|
||||||
|
|
||||||
expect(mockCreateFromToken).toHaveBeenCalledWith(
|
expect(mockCreateFromToken).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
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 { UsersService } from '../users/users.service';
|
||||||
import type { AuthenticatedUser } from './decorators/current-user.decorator';
|
import type { AuthenticatedUser } from './decorators/current-user.decorator';
|
||||||
import { User } from '../users/users.entity';
|
import { User } from '../users/users.entity';
|
||||||
@@ -34,7 +37,63 @@ import { User } from '../users/users.entity';
|
|||||||
export class AuthService {
|
export class AuthService {
|
||||||
private readonly logger = new Logger(AuthService.name);
|
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<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',
|
||||||
|
);
|
||||||
|
|
||||||
|
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.
|
* Handles user login and profile synchronization from Keycloak token.
|
||||||
@@ -52,8 +111,6 @@ export class AuthService {
|
|||||||
const { keycloakSub, email, name, username, picture, roles } =
|
const { keycloakSub, email, name, username, picture, roles } =
|
||||||
authenticatedUser;
|
authenticatedUser;
|
||||||
|
|
||||||
// Use createFromToken which handles upsert atomically
|
|
||||||
// This prevents race conditions when multiple requests arrive simultaneously
|
|
||||||
const user = await this.usersService.createFromToken({
|
const user = await this.usersService.createFromToken({
|
||||||
keycloakSub,
|
keycloakSub,
|
||||||
email: email || '',
|
email: email || '',
|
||||||
@@ -70,23 +127,11 @@ export class AuthService {
|
|||||||
* Ensures a user exists in the local database without updating profile data.
|
* 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
|
* 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.
|
* 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<User> {
|
async ensureUserExists(authenticatedUser: AuthenticatedUser): Promise<User> {
|
||||||
const { keycloakSub, email, name, username, picture, roles } =
|
const { keycloakSub, email, name, username, picture, roles } =
|
||||||
authenticatedUser;
|
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({
|
return await this.usersService.findOrCreate({
|
||||||
keycloakSub,
|
keycloakSub,
|
||||||
email: email || '',
|
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 {
|
hasRole(user: AuthenticatedUser, requiredRole: string): boolean {
|
||||||
return user.roles?.includes(requiredRole) ?? false;
|
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 {
|
hasAnyRole(user: AuthenticatedUser, requiredRoles: string[]): boolean {
|
||||||
if (!user.roles || user.roles.length === 0) {
|
if (!user.roles || user.roles.length === 0) {
|
||||||
return false;
|
return false;
|
||||||
@@ -122,13 +153,6 @@ export class AuthService {
|
|||||||
return requiredRoles.some((role) => user.roles!.includes(role));
|
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 {
|
hasAllRoles(user: AuthenticatedUser, requiredRoles: string[]): boolean {
|
||||||
if (!user.roles || user.roles.length === 0) {
|
if (!user.roles || user.roles.length === 0) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface AuthenticatedUser {
|
|||||||
username?: string;
|
username?: string;
|
||||||
picture?: string;
|
picture?: string;
|
||||||
roles?: string[];
|
roles?: string[];
|
||||||
|
sessionState?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export interface JwtPayload {
|
|||||||
roles: string[];
|
roles: string[];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
session_state?: string;
|
||||||
iss: string; // Issuer
|
iss: string; // Issuer
|
||||||
aud: string | string[]; // Audience
|
aud: string | string[]; // Audience
|
||||||
exp: number; // Expiration time
|
exp: number; // Expiration time
|
||||||
@@ -96,6 +97,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
username?: string;
|
username?: string;
|
||||||
picture?: string;
|
picture?: string;
|
||||||
roles?: string[];
|
roles?: string[];
|
||||||
|
sessionState?: string;
|
||||||
}> {
|
}> {
|
||||||
this.logger.debug(`Validating JWT token payload`);
|
this.logger.debug(`Validating JWT token payload`);
|
||||||
this.logger.debug(` Issuer: ${payload.iss}`);
|
this.logger.debug(` Issuer: ${payload.iss}`);
|
||||||
@@ -132,6 +134,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
username: payload.preferred_username,
|
username: payload.preferred_username,
|
||||||
picture: payload.picture,
|
picture: payload.picture,
|
||||||
roles: roles.length > 0 ? roles : undefined,
|
roles: roles.length > 0 ? roles : undefined,
|
||||||
|
sessionState: payload.session_state,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
|
|||||||
14
src/users/dto/logout-request.dto.ts
Normal file
14
src/users/dto/logout-request.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
HttpCode,
|
HttpCode,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
Logger,
|
Logger,
|
||||||
|
Post,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiUnauthorizedResponse,
|
ApiUnauthorizedResponse,
|
||||||
ApiForbiddenResponse,
|
ApiForbiddenResponse,
|
||||||
|
ApiNoContentResponse,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { UsersService } from './users.service';
|
import { UsersService } from './users.service';
|
||||||
import { User, UserResponseDto } from './users.entity';
|
import { User, UserResponseDto } from './users.entity';
|
||||||
@@ -27,6 +29,7 @@ import {
|
|||||||
type AuthenticatedUser,
|
type AuthenticatedUser,
|
||||||
} from '../auth/decorators/current-user.decorator';
|
} from '../auth/decorators/current-user.decorator';
|
||||||
import { AuthService } from '../auth/auth.service';
|
import { AuthService } from '../auth/auth.service';
|
||||||
|
import { LogoutRequestDto } from './dto/logout-request.dto';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Users Controller
|
* Users Controller
|
||||||
@@ -49,6 +52,28 @@ export class UsersController {
|
|||||||
private readonly authService: AuthService,
|
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<void> {
|
||||||
|
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.
|
* Get current authenticated user's profile.
|
||||||
* This endpoint syncs the user from Keycloak token to ensure profile data
|
* This endpoint syncs the user from Keycloak token to ensure profile data
|
||||||
|
|||||||
Reference in New Issue
Block a user