diff --git a/services/authentication-service/.env.defaults b/services/authentication-service/.env.defaults index 9be445332f..456f16cd4e 100644 --- a/services/authentication-service/.env.defaults +++ b/services/authentication-service/.env.defaults @@ -64,3 +64,4 @@ AZURE_AUTH_COOKIE_KEY= AZURE_AUTH_COOKIE_IV= MAX_JWT_KEYS=2 +REVOKED_TOKEN_MAX_TTL=3600 diff --git a/services/authentication-service/.env.example b/services/authentication-service/.env.example index 0d3d9092ec..50f55118a8 100644 --- a/services/authentication-service/.env.example +++ b/services/authentication-service/.env.example @@ -83,5 +83,6 @@ AUTH0_CLIENT_SECRET= AUTH0_CALLBACK_URL= MAX_JWT_KEYS= +REVOKED_TOKEN_MAX_TTL= JWT_PRIVATE_KEY_PASSPHRASE= API_BASE_URL= diff --git a/services/authentication-service/README.md b/services/authentication-service/README.md index e9812c0db8..edfcbd985d 100644 --- a/services/authentication-service/README.md +++ b/services/authentication-service/README.md @@ -419,6 +419,12 @@ Authenttication service can be used as a identity server. Following endpoints ha Maximum number of jwt keys generated in the database + + REVOKED_TOKEN_MAX_TTL + N + Maximum TTL for revoked access tokens kept in Redis, in seconds + 3600 + USER_TEMP_PASSWORD N diff --git a/services/authentication-service/src/modules/auth/controllers/login.controller.ts b/services/authentication-service/src/modules/auth/controllers/login.controller.ts index b04476931b..f2e1dd5ef0 100644 --- a/services/authentication-service/src/modules/auth/controllers/login.controller.ts +++ b/services/authentication-service/src/modules/auth/controllers/login.controller.ts @@ -36,6 +36,7 @@ import { authenticateClient, } from 'loopback4-authentication'; import {AuthorizeErrorKeys, authorize} from 'loopback4-authorization'; +import * as jwt from 'jsonwebtoken'; import {LoginType} from '../../../enums'; import {AuthClient, RefreshToken, User} from '../../../models'; import { @@ -69,6 +70,11 @@ import { import {CodeResponse} from '../types'; export class LoginController { + /** + * Default maximum TTL for a revoked access token retained in Redis (1 hour). + */ + private static readonly DEFAULT_REVOKED_TOKEN_TTL_SECONDS = 60 * 60; + constructor( @inject(AuthenticationBindings.CURRENT_CLIENT) private readonly client: AuthClient | undefined, @@ -318,7 +324,13 @@ export class LoginController { await this._verifyUserTenant(changePassword.id, currentUser.tenantId); } - await this.revokedTokensRepo.set(token, {token}); + await this.revokedTokensRepo.set( + token, + {token}, + { + ttl: this.revokedTokenTtlMs(token), + }, + ); await this.refreshTokenRepo.delete(req.refreshToken); return new SuccessResponse({ success: true, @@ -437,6 +449,63 @@ export class LoginController { } } + /** + * Resolves the maximum TTL (in ms) for a revoked access token from config. + * + * Reads `REVOKED_TOKEN_MAX_TTL` (in seconds, consistent with other expiry + * env vars such as `FORGOT_PASSWORD_LINK_EXPIRY`) and falls back to a 1-hour + * default. Invalid or non-positive values fall back to the default as well. + * The result is always at least 1 second. + */ + private get revokedTokenMaxTtlMs(): number { + const configured = Number.parseInt( + process.env.REVOKED_TOKEN_MAX_TTL ?? + `${LoginController.DEFAULT_REVOKED_TOKEN_TTL_SECONDS}`, + 10, + ); + const ttlSeconds = + Number.isFinite(configured) && configured > 0 + ? configured + : LoginController.DEFAULT_REVOKED_TOKEN_TTL_SECONDS; + return ttlSeconds * 1000; + } + + /** + * Calculates the TTL for a revoked token based on its remaining validity. + * + * The TTL is set to the token's remaining time until expiration plus a 60-second + * grace period for clock skew. If the token's exp claim is missing or invalid, + * the configured maximum TTL (default 1 hour) is used. + * + * @param token - The JWT token to calculate TTL for + * @returns TTL in milliseconds + */ + private revokedTokenTtlMs(token: string): number { + const maxTtlMs = this.revokedTokenMaxTtlMs; + try { + const decoded = jwt.decode(token) as {exp?: number} | null; + + if (decoded?.exp) { + const nowInSeconds = Math.floor(Date.now() / 1000); + const remainingSeconds = decoded.exp - nowInSeconds; + + // Add 60-second grace period for clock skew + const ttlSeconds = remainingSeconds + 60; + + // Ensure minimum of 1 second and maximum of the configured cap + return Math.max(1000, Math.min(ttlSeconds * 1000, maxTtlMs)); + } + } catch (error) { + this.logger.warn( + '[AUTH] Failed to decode JWT for TTL calculation, using default', + error, + ); + } + + // Fallback to the configured cap if exp is missing or decode fails + return maxTtlMs; + } + async getPasswordResponse( userName: string, password: string, @@ -553,9 +622,15 @@ export class LoginController { if (!accessToken || refreshPayload.accessToken !== accessToken) { throw new HttpErrors.Unauthorized(AuthErrorKeys.TokenInvalid); } - await this.revokedTokensRepo.set(refreshPayload.accessToken, { - token: refreshPayload.accessToken, - }); + await this.revokedTokensRepo.set( + refreshPayload.accessToken, + { + token: refreshPayload.accessToken, + }, + { + ttl: this.revokedTokenTtlMs(refreshPayload.accessToken), + }, + ); await this.refreshTokenRepo.delete(req.refreshToken); return { refreshPayload: refreshPayload,