import { Injectable, Logger } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { randomBytes, createHash } from 'crypto'; @Injectable() export class RefreshTokenService { private readonly logger = new Logger(RefreshTokenService.name); constructor(private dataSource: DataSource) {} /** * Create a new refresh token for a user. * Returns the raw (unhashed) token to be sent as an httpOnly cookie. */ async createRefreshToken(userId: string): Promise { const rawToken = randomBytes(64).toString('base64url'); const tokenHash = this.hashToken(rawToken); const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days await this.dataSource.query( `INSERT INTO shared.refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`, [userId, tokenHash, expiresAt], ); return rawToken; } /** * Validate a refresh token. Returns the user_id if valid, null otherwise. */ async validateRefreshToken(rawToken: string): Promise { const tokenHash = this.hashToken(rawToken); const rows = await this.dataSource.query( `SELECT user_id, expires_at, revoked_at FROM shared.refresh_tokens WHERE token_hash = $1`, [tokenHash], ); if (rows.length === 0) return null; const { user_id, expires_at, revoked_at } = rows[0]; // Check if revoked if (revoked_at) return null; // Check if expired if (new Date(expires_at) < new Date()) return null; return user_id; } /** * Revoke a single refresh token. */ async revokeToken(rawToken: string): Promise { const tokenHash = this.hashToken(rawToken); await this.dataSource.query( `UPDATE shared.refresh_tokens SET revoked_at = NOW() WHERE token_hash = $1`, [tokenHash], ); } /** * Revoke all refresh tokens for a user ("log out everywhere"). */ async revokeAllUserTokens(userId: string): Promise { await this.dataSource.query( `UPDATE shared.refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL`, [userId], ); } /** * Remove expired / revoked tokens older than 7 days. * Called periodically to keep the table clean. */ async cleanupExpired(): Promise { const result = await this.dataSource.query( `DELETE FROM shared.refresh_tokens WHERE (expires_at < NOW() - INTERVAL '7 days') OR (revoked_at IS NOT NULL AND revoked_at < NOW() - INTERVAL '7 days')`, ); const deleted = result?.[1] ?? 0; if (deleted > 0) { this.logger.log(`Cleaned up ${deleted} expired/revoked refresh tokens`); } return deleted; } private hashToken(rawToken: string): string { return createHash('sha256').update(rawToken).digest('hex'); } }