import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; import { errors as joseErrors, jwtVerify, SignJWT } from 'jose'; import type { AuthTokenResponse, Edition } from '@openvidu-dashboard/typings'; import type { AuthContext } from '../api/route-registry.js'; import { ApiError } from '../http/api-error.js'; import type { Logger } from '../logging/logger.js'; import type { SessionRepository } from './session-repository.js'; export const ACCESS_TOKEN_TTL_SECONDS = 900; /** A rotated refresh token presented again within this window is a concurrent refresh, not a replay. */ export const REUSE_GRACE_MS = 10_000; export interface ClientInfo { ip?: string; userAgent?: string; } export interface IssuedSession { tokens: AuthTokenResponse; /** Opaque value of the refresh cookie. */ refreshToken: string; } export interface AuthServiceOptions { username: string; password: string; edition: Edition; key: Uint8Array; refreshTokenTtlSeconds: number; sessions: SessionRepository; logger: Logger; now?: () => Date; } function sha256(value: string): Buffer { return createHash('sha256').update(value).digest(); } function unauthorized(reason: 'missing' | 'invalid' | 'expired', message = 'Session expired or invalid'): ApiError { return new ApiError('UNAUTHORIZED', message, { reason }); } /** Credentials, access tokens and the rotating refresh sessions of the single administrator. */ export class AuthService { private readonly usernameDigest: Buffer; private readonly passwordDigest: Buffer; private readonly now: () => Date; constructor(private readonly options: AuthServiceOptions) { this.usernameDigest = sha256(options.username); this.passwordDigest = sha256(options.password); this.now = options.now ?? (() => new Date()); } /** Compares digests of both values in constant time, so timing reveals neither which one nor their length. */ checkCredentials(username: string, password: string): boolean { const usernameMatches = timingSafeEqual(sha256(username), this.usernameDigest); const passwordMatches = timingSafeEqual(sha256(password), this.passwordDigest); return usernameMatches && passwordMatches; } async issueAccessToken(): Promise { const issuedAt = Math.floor(this.now().getTime() / 1000); const expiresAt = issuedAt + ACCESS_TOKEN_TTL_SECONDS; const accessToken = await new SignJWT({ edition: this.options.edition }) .setProtectedHeader({ alg: 'HS256', typ: 'JWT' }) .setSubject(this.options.username) .setIssuedAt(issuedAt) .setExpirationTime(expiresAt) .sign(this.options.key); return { accessToken, expiresAt: new Date(expiresAt * 1000).toISOString() }; } /** Verifies signature, algorithm (HS256 only) and expiry; throws `UNAUTHORIZED` with the reason. */ async verifyAccessToken(token: string): Promise { try { const { payload } = await jwtVerify(token, this.options.key, { algorithms: ['HS256'], currentDate: this.now(), requiredClaims: ['sub', 'exp', 'iat'] }); if (payload.sub !== this.options.username || typeof payload.exp !== 'number') { throw unauthorized('invalid', 'Invalid access token'); } return { username: payload.sub, edition: payload.edition === 'pro' ? 'pro' : 'ce', expiresAt: new Date(payload.exp * 1000) }; } catch (error) { if (error instanceof ApiError) { throw error; } if (error instanceof joseErrors.JWTExpired) { throw unauthorized('expired', 'Access token expired'); } throw unauthorized('invalid', 'Invalid access token'); } } /** Starts a session family; null when the credentials do not match. */ async login(username: string, password: string, client: ClientInfo): Promise { if (!this.checkCredentials(username, password)) { return null; } return this.startSession(randomUUID(), client); } /** * Rotates a refresh token: a live token gets a successor of the same family; a token rotated more than 10 s ago * revokes its family; a missing, unknown, expired or revoked one is `UNAUTHORIZED`. */ async refresh(refreshToken: string | undefined, client: ClientInfo): Promise { if (refreshToken === undefined || refreshToken === '') { throw unauthorized('missing', 'No session cookie'); } const now = this.now(); const session = await this.options.sessions.rotate(hashToken(refreshToken), now); if (session === null || session.revokedAt !== undefined) { throw unauthorized('invalid'); } if (session.expiresAt.getTime() <= now.getTime()) { throw unauthorized('expired'); } if (session.rotatedAt !== undefined && now.getTime() - session.rotatedAt.getTime() > REUSE_GRACE_MS) { await this.options.sessions.revokeFamily(session.familyId, now); this.options.logger.warn(`Refresh token reused after rotation: session family ${session.familyId} revoked`); throw unauthorized('invalid'); } return this.startSession(session.familyId, client); } /** Revokes the family of the presented refresh token, if any. */ async logout(refreshToken: string | undefined): Promise { if (refreshToken === undefined || refreshToken === '') { return; } const session = await this.options.sessions.findByHash(hashToken(refreshToken)); if (session) { await this.options.sessions.revokeFamily(session.familyId, this.now()); } } private async startSession(familyId: string, client: ClientInfo): Promise { const refreshToken = randomBytes(32).toString('base64url'); const createdAt = this.now(); await this.options.sessions.insert({ familyId, tokenHash: hashToken(refreshToken), createdAt, expiresAt: new Date(createdAt.getTime() + this.options.refreshTokenTtlSeconds * 1000), ...(client.ip === undefined ? {} : { ip: client.ip }), ...(client.userAgent === undefined ? {} : { userAgent: client.userAgent.slice(0, 512) }) }); return { tokens: await this.issueAccessToken(), refreshToken }; } } export function hashToken(token: string): string { return createHash('sha256').update(token).digest('hex'); }