diff --git a/context/progress-tracker.md b/context/progress-tracker.md index eac5d06..654d28e 100644 --- a/context/progress-tracker.md +++ b/context/progress-tracker.md @@ -6,6 +6,27 @@ pure chore/docs commits). Direct pushes to main must also be logged here. --- +## 2026-08-24 + +- **Session families + refresh-token replay detection** (`sessions.family_id` + migration, `fam` claim in refresh JWTs). Replaying an already-rotated + refresh token now revokes every session in the family and writes a + `auth.refresh_token_reuse` audit log entry — previously the first + presenter of a stolen token won silently. Legacy tokens without a `fam` + claim keep the old `AUTH_SESSION_NOT_FOUND` response. +- **Blocked-user enforcement on every request**: new + `UserStatusService` (in-memory TTL cache) consulted by `JwtStrategy`. + Documented staleness bound: **30 seconds** — a blocked wallet loses API + access within ~30s of being blocked instead of retaining access until its + access token expires (up to 15 minutes). Cache is per-instance and fails + open on DB errors to avoid locking out all users during a DB blip. +- **Session cleanup cron** (`src/jobs/session-cleanup/`, hourly, + mirrors nonce-cleanup): deletes only rows with `expires_at` older than + 1 hour; sessions no longer accumulate forever. +- Tests: refresh-family rotation, replay → family-wide revocation + audit + event, blocked-user denial within TTL bound, cache expiry re-query, + cleanup job deletes-only-expired. + ## 2026-07-23 - Added GitHub Actions health check workflow (`health-check.yml`) to ping the Render API every 6 hours to prevent the free tier instance from sleeping. Auto-creates or comments on issues with the `incident` label if the ping fails, preventing silent outages. diff --git a/src/app.module.ts b/src/app.module.ts index 87e084f..ef812c6 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -21,6 +21,7 @@ import { IndexerModule } from './indexer/indexer.module'; import { LoanPaymentReminderModule } from './jobs/loan-payment-reminder/loan-payment-reminder.module'; import { TransactionStatusCheckerModule } from './jobs/transaction-status-checker/transaction-status-checker.module'; import { NonceCleanupModule } from './jobs/nonce-cleanup/nonce-cleanup.module'; +import { SessionCleanupModule } from './jobs/session-cleanup/session-cleanup.module'; import { SupabaseKeepAliveModule } from './jobs/supabase-keepalive/supabase-keepalive.module'; import { StellarModule } from './stellar/stellar.module'; import { LoggerModule } from './common/logger/logger.module'; @@ -62,6 +63,7 @@ import { AuditInterceptor } from './common/interceptors/audit.interceptor'; LoanPaymentReminderModule, TransactionStatusCheckerModule, NonceCleanupModule, + SessionCleanupModule, SupabaseKeepAliveModule, StateReconciliationModule, CreditScoringModule, diff --git a/src/jobs/session-cleanup/session-cleanup.module.ts b/src/jobs/session-cleanup/session-cleanup.module.ts new file mode 100644 index 0000000..93578d3 --- /dev/null +++ b/src/jobs/session-cleanup/session-cleanup.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { SessionCleanupService } from './session-cleanup.service'; +import { SupabaseService } from '../../database/supabase.client'; + +@Module({ + providers: [SessionCleanupService, SupabaseService], +}) +export class SessionCleanupModule {} diff --git a/src/jobs/session-cleanup/session-cleanup.service.ts b/src/jobs/session-cleanup/session-cleanup.service.ts new file mode 100644 index 0000000..8ce9350 --- /dev/null +++ b/src/jobs/session-cleanup/session-cleanup.service.ts @@ -0,0 +1,37 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { SupabaseService } from '../../database/supabase.client'; + +@Injectable() +export class SessionCleanupService { + private readonly logger = new Logger(SessionCleanupService.name); + + constructor(private readonly supabaseService: SupabaseService) {} + + @Cron(CronExpression.EVERY_HOUR) + async cleanupExpiredSessions(): Promise { + try { + const client = this.supabaseService.getServiceRoleClient(); + + // Delete only rows already past their expiry; the 1h grace window + // mirrors the nonce-cleanup pattern and keeps rows around long enough + // that an "expired" response (instead of "not found") is still + // possible for borderline requests. + const cutoff = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + + const { error, count } = await client + .from('sessions') + .delete({ count: 'exact' }) + .lt('expires_at', cutoff); + + if (error) { + this.logger.error(`Failed to delete expired sessions: ${error.message}`); + throw error; + } + + this.logger.log(`Deleted ${count ?? 0} expired sessions`); + } catch (error) { + this.logger.error('Session cleanup failed', error); + } + } +} diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 77ddd3b..2b9bb42 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -5,10 +5,12 @@ import { PassportModule } from '@nestjs/passport'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { JwtStrategy } from './jwt.strategy'; +import { UserStatusService } from './user-status.service'; import { ApiKeyGuard } from '../../auth/guards/api-key.guard'; import { SupabaseService } from '../../database/supabase.client'; import { UsersRepository } from '../../database/repositories/users.repository'; import { getJwtConfig } from '../../config/jwt.config'; +import { AdminModule } from '../admin/admin.module'; @Module({ imports: [ @@ -18,9 +20,10 @@ import { getJwtConfig } from '../../config/jwt.config'; inject: [ConfigService], useFactory: getJwtConfig, }), + AdminModule, ], controllers: [AuthController], - providers: [AuthService, JwtStrategy, ApiKeyGuard, SupabaseService, ConfigService, UsersRepository], - exports: [AuthService, JwtStrategy, ApiKeyGuard, PassportModule], + providers: [AuthService, JwtStrategy, UserStatusService, ApiKeyGuard, SupabaseService, ConfigService, UsersRepository], + exports: [AuthService, JwtStrategy, UserStatusService, ApiKeyGuard, PassportModule], }) export class AuthModule {} diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts index 55d5419..98a36c6 100644 --- a/src/modules/auth/auth.service.ts +++ b/src/modules/auth/auth.service.ts @@ -3,10 +3,11 @@ import { InternalServerErrorException, UnauthorizedException, ConflictException, + Logger, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; -import { createHash, randomBytes } from 'crypto'; +import { createHash, randomBytes, randomUUID } from 'crypto'; import { Keypair, StrKey } from 'stellar-sdk'; import { SupabaseService } from '../../database/supabase.client'; import { UsersRepository, UploadedAvatarFile } from '../../database/repositories/users.repository'; @@ -20,9 +21,16 @@ import { REFRESH_TOKEN_EXPIRATION, REFRESH_TOKEN_EXPIRATION_MS, } from '../../config/jwt.config'; +import { AuditService } from '../admin/audit.service'; const NONCE_EXPIRATION_SECONDS = 300; +interface RefreshTokenPayload { + type?: string; + wallet?: string; + fam?: string; +} + export interface RegisterResponse extends AuthResponseDto { user: { id: string; @@ -36,11 +44,14 @@ export interface RegisterResponse extends AuthResponseDto { @Injectable() export class AuthService { + private readonly logger = new Logger(AuthService.name); + constructor( private readonly supabaseService: SupabaseService, private readonly jwtService: JwtService, private readonly configService: ConfigService, private readonly usersRepository: UsersRepository, + private readonly auditService: AuditService, ) {} async register(dto: RegisterRequestDto, profileImage?: UploadedAvatarFile): Promise { @@ -167,7 +178,7 @@ export class AuthService { return { id: user.id, role: user.role ?? null }; } - async generateTokens(wallet: string): Promise { + async generateTokens(wallet: string, familyId?: string): Promise { const { id: userId, role } = await this.findOrCreateUser(wallet); const client = this.supabaseService.getServiceRoleClient(); // Role is read fresh from the users table on every token generation, @@ -176,8 +187,11 @@ export class AuthService { { wallet, type: 'access', role }, { secret: this.configService.get('JWT_SECRET'), expiresIn: ACCESS_TOKEN_EXPIRATION }, ); + // All tokens minted from one login (or any of its refreshes) share a + // family id, enabling theft containment when a rotated token is replayed. + const sessionFamilyId = familyId ?? randomUUID(); const refreshToken = this.jwtService.sign( - { wallet, type: 'refresh' }, + { wallet, type: 'refresh', fam: sessionFamilyId }, { secret: this.configService.get('JWT_REFRESH_SECRET'), expiresIn: REFRESH_TOKEN_EXPIRATION }, ); const refreshTokenHash = createHash('sha256').update(refreshToken).digest('hex'); @@ -185,6 +199,7 @@ export class AuthService { const { error: sessionError } = await client.from('sessions').insert({ user_id: userId, refresh_token_hash: refreshTokenHash, + family_id: sessionFamilyId, expires_at: refreshExpiresAt.toISOString(), }); if (sessionError) { @@ -194,7 +209,7 @@ export class AuthService { } async refreshTokens(refreshToken: string): Promise { - let payload: { type?: string; wallet?: string }; + let payload: RefreshTokenPayload; try { payload = this.jwtService.verify(refreshToken, { secret: this.configService.get('JWT_REFRESH_SECRET'), @@ -209,16 +224,64 @@ export class AuthService { const tokenHash = createHash('sha256').update(refreshToken).digest('hex'); const { data: session, error } = await client .from('sessions') - .select('id, expires_at') + .select('id, family_id, expires_at') .eq('refresh_token_hash', tokenHash) .single(); if (error || !session) { + await this.handleRefreshReplay(payload); + // Tokens minted before session families existed fall back to the + // original error so legacy clients see a stable response shape. + if (payload.fam) { + throw new UnauthorizedException({ + code: 'AUTH_REFRESH_TOKEN_REUSED', + message: 'Refresh token reuse detected. All sessions have been revoked. Please sign in again.', + }); + } throw new UnauthorizedException({ code: 'AUTH_SESSION_NOT_FOUND', message: 'Session not found. Please sign in again.' }); } if (new Date(session.expires_at) < new Date()) { throw new UnauthorizedException({ code: 'AUTH_SESSION_EXPIRED', message: 'Session expired. Please sign in again.' }); } await client.from('sessions').delete().eq('id', session.id); - return this.generateTokens(payload.wallet); + return this.generateTokens(payload.wallet as string, session.family_id); + } + + /** + * A validly-signed refresh token whose session row no longer exists means + * the token was already rotated — i.e. it is being replayed, most likely + * by an attacker who stole it. Contain the compromise by revoking every + * session in the family and recording a security audit event. + */ + private async handleRefreshReplay(payload: RefreshTokenPayload): Promise { + const familyId = payload.fam; + const wallet = payload.wallet ?? 'unknown'; + this.logger.error(`Refresh token replay detected for wallet ${wallet}${familyId ? ` (family ${familyId})` : ''}`); + if (!familyId) { + // Legacy token minted before families existed — nothing to revoke. + return; + } + const client = this.supabaseService.getServiceRoleClient(); + const { error: revokeError, count } = await client + .from('sessions') + .delete({ count: 'exact' }) + .eq('family_id', familyId); + if (revokeError) { + this.logger.error(`Failed to revoke session family ${familyId}: ${revokeError.message}`); + } else { + this.logger.error(`Revoked ${count ?? 0} session(s) in family ${familyId} after refresh-token replay`); + } + try { + await this.auditService.logWithBeforeAfter({ + actorWallet: wallet, + action: 'auth.refresh_token_reuse', + resource: 'session', + resourceId: null, + beforeState: null, + afterState: { revoked_sessions: count ?? 0 }, + metadata: { family_id: familyId }, + }); + } catch (auditError) { + this.logger.error('Failed to write refresh-token-reuse audit log', auditError); + } } } diff --git a/src/modules/auth/jwt.strategy.ts b/src/modules/auth/jwt.strategy.ts index ba7557b..e559142 100644 --- a/src/modules/auth/jwt.strategy.ts +++ b/src/modules/auth/jwt.strategy.ts @@ -2,6 +2,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { ConfigService } from '@nestjs/config'; +import { UserStatusService } from './user-status.service'; interface JwtPayload { wallet: string; @@ -19,10 +20,18 @@ interface JwtPayload { * * Only tokens with type === 'access' are accepted to prevent refresh tokens * from being used to authenticate API requests. + * + * On every request the user's account status is checked through + * UserStatusService (short-TTL cache; staleness bound documented there), so + * blocked wallets are denied access within that bound instead of retaining + * access until their token naturally expires. */ @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { - constructor(configService: ConfigService) { + constructor( + configService: ConfigService, + private readonly userStatusService: UserStatusService, + ) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, @@ -37,7 +46,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { * @param payload - Decoded JWT payload * @returns User object containing the wallet address */ - validate(payload: JwtPayload): { wallet: string; role: string | null } { + async validate(payload: JwtPayload): Promise<{ wallet: string; role: string | null }> { if (payload.type !== 'access') { throw new UnauthorizedException({ code: 'AUTH_TOKEN_INVALID', @@ -45,6 +54,8 @@ export class JwtStrategy extends PassportStrategy(Strategy) { }); } + await this.userStatusService.ensureNotBlocked(payload.wallet); + // Tokens issued before the role claim existed simply carry role: null; // RolesGuard will deny role-gated routes until the client refreshes. return { wallet: payload.wallet, role: payload.role ?? null }; diff --git a/src/modules/auth/user-status.service.ts b/src/modules/auth/user-status.service.ts new file mode 100644 index 0000000..a97b9f6 --- /dev/null +++ b/src/modules/auth/user-status.service.ts @@ -0,0 +1,80 @@ +import { Injectable, Logger, UnauthorizedException } from '@nestjs/common'; +import { SupabaseService } from '../../database/supabase.client'; + +/** + * How long a user's status may be served from cache before re-checking the + * database. This is the documented staleness bound for blocking enforcement: + * a blocked wallet can keep using valid access tokens for AT MOST this many + * seconds (plus the remaining lifetime of its current access token is NOT + * granted — requests within this window are the only grace period). + */ +export const USER_STATUS_CACHE_TTL_MS = 30_000; + +interface CachedStatus { + status: string; + expiresAt: number; +} + +/** + * Short-TTL in-memory cache of user account status, consulted on every + * authenticated request by JwtStrategy so that blocked wallets lose API + * access within USER_STATUS_CACHE_TTL_MS instead of waiting for their + * access token to expire naturally. + * + * A local in-memory Map is used deliberately instead of Redis: the check + * runs on every request, one Redis round trip per request would double + * auth latency, and a 30s staleness bound does not justify shared state. + * On multi-instance deployments each instance maintains its own cache with + * the same bound. + */ +@Injectable() +export class UserStatusService { + private readonly logger = new Logger(UserStatusService.name); + private readonly cache = new Map(); + + constructor(private readonly supabaseService: SupabaseService) {} + + /** + * Returns the user's status ('active', 'blocked', ...), serving from the + * cache when fresh. Never throws for DB errors — fails open so a database + * blip cannot lock out every authenticated user; the failure is logged. + */ + async getStatus(wallet: string): Promise { + const cached = this.cache.get(wallet); + if (cached && cached.expiresAt > Date.now()) { + return cached.status; + } + let status = 'active'; + try { + const client = this.supabaseService.getServiceRoleClient(); + const { data, error } = await client + .from('users') + .select('status') + .eq('wallet_address', wallet) + .maybeSingle(); + if (!error && data?.status) { + status = data.status; + } + if (error) { + this.logger.error(`Failed to read status for ${wallet}: ${error.message}`); + } + } catch (err) { + this.logger.error(`User status lookup failed for ${wallet}`, err); + } + this.cache.set(wallet, { status, expiresAt: Date.now() + USER_STATUS_CACHE_TTL_MS }); + return status; + } + + /** Throws AUTH_USER_BLOCKED when the wallet's account is suspended. */ + async ensureNotBlocked(wallet: string): Promise { + const status = await this.getStatus(wallet); + if (status === 'blocked') { + throw new UnauthorizedException({ code: 'AUTH_USER_BLOCKED', message: 'This account has been suspended.' }); + } + } + + /** Test/admin helper: drops cached status so the next check hits the DB. */ + invalidate(wallet: string): void { + this.cache.delete(wallet); + } +} diff --git a/supabase/migrations/20260824000001_add_session_family_id.sql b/supabase/migrations/20260824000001_add_session_family_id.sql new file mode 100644 index 0000000..8e064cd --- /dev/null +++ b/supabase/migrations/20260824000001_add_session_family_id.sql @@ -0,0 +1,9 @@ +-- Session families for refresh-token rotation. +-- All tokens minted from a chain of refreshes share one family_id, so that +-- replay of an already-rotated refresh token can revoke the entire family +-- (theft containment). + +ALTER TABLE public.sessions + ADD COLUMN family_id UUID NOT NULL DEFAULT gen_random_uuid(); + +CREATE INDEX idx_sessions_family_id ON public.sessions (family_id); diff --git a/test/unit/jobs/session-cleanup/session-cleanup.service.spec.ts b/test/unit/jobs/session-cleanup/session-cleanup.service.spec.ts new file mode 100644 index 0000000..f9cbe28 --- /dev/null +++ b/test/unit/jobs/session-cleanup/session-cleanup.service.spec.ts @@ -0,0 +1,80 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SessionCleanupService } from '../../../../src/jobs/session-cleanup/session-cleanup.service'; +import { SupabaseService } from '../../../../src/database/supabase.client'; + +describe('SessionCleanupService', () => { + let service: SessionCleanupService; + let loggerErrorSpy: jest.SpyInstance; + + const deleteLt = jest.fn(); + const mockDelete = jest.fn().mockReturnValue({ lt: deleteLt }); + const mockFrom = jest.fn().mockReturnValue({ delete: mockDelete }); + + const mockSupabaseService = { + getServiceRoleClient: jest.fn(() => ({ from: mockFrom })), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SessionCleanupService, + { provide: SupabaseService, useValue: mockSupabaseService }, + ], + }).compile(); + + service = module.get(SessionCleanupService); + + loggerErrorSpy = jest.spyOn((service as unknown as { logger: { error: jest.Mock } }).logger, 'error').mockImplementation(() => {}); + + jest.clearAllMocks(); + loggerErrorSpy.mockImplementation(() => {}); + mockDelete.mockReturnValue({ lt: deleteLt }); + deleteLt.mockResolvedValue({ error: null, count: 3 }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should delete only rows whose expiry is more than an hour in the past', async () => { + const before = Date.now(); + + await service.cleanupExpiredSessions(); + + expect(mockFrom).toHaveBeenCalledWith('sessions'); + expect(deleteLt).toHaveBeenCalledTimes(1); + + const [column, cutoffIso] = deleteLt.mock.calls[0]; + expect(column).toBe('expires_at'); + const cutoff = new Date(cutoffIso as string).getTime(); + expect(cutoff).toBeGreaterThanOrEqual(before - 60 * 60 * 1000 - 2000); + expect(cutoff).toBeLessThanOrEqual(before - 60 * 60 * 1000 + 2000); + }); + + it('should log the number of deleted sessions', async () => { + const logSpy = jest.spyOn((service as unknown as { logger: { log: jest.Mock } }).logger, 'log').mockImplementation(() => {}); + + await service.cleanupExpiredSessions(); + + expect(logSpy).toHaveBeenCalledWith('Deleted 3 expired sessions'); + }); + + it('should not throw when the delete fails — only log the error', async () => { + deleteLt.mockResolvedValue({ error: { message: 'connection reset' }, count: null }); + + await expect(service.cleanupExpiredSessions()).resolves.toBeUndefined(); + expect(loggerErrorSpy).toHaveBeenCalled(); + }); + + it('should swallow unexpected exceptions so the cron never throws unhandled', async () => { + deleteLt.mockRejectedValue(new Error('network failure')); + + await expect(service.cleanupExpiredSessions()).resolves.toBeUndefined(); + expect(loggerErrorSpy).toHaveBeenCalled(); + }); +}); diff --git a/test/unit/modules/auth/auth.service.spec.ts b/test/unit/modules/auth/auth.service.spec.ts index 074188b..f3fa14b 100644 --- a/test/unit/modules/auth/auth.service.spec.ts +++ b/test/unit/modules/auth/auth.service.spec.ts @@ -2,9 +2,11 @@ import { Test, TestingModule } from '@nestjs/testing'; import { InternalServerErrorException, ConflictException, UnauthorizedException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; +import { createHash } from 'crypto'; import { AuthService } from '../../../../src/modules/auth/auth.service'; import { SupabaseService } from '../../../../src/database/supabase.client'; import { UsersRepository } from '../../../../src/database/repositories/users.repository'; +import { AuditService } from '../../../../src/modules/admin/audit.service'; // Mock Stellar SDK to avoid real crypto operations in unit tests jest.mock('stellar-sdk', () => ({ @@ -26,8 +28,9 @@ describe('AuthService', () => { getServiceRoleClient: jest.fn(() => mockSupabaseClient), }; - const mockJwtService = { + const mockJwtService: { sign: jest.Mock; verify: jest.Mock } = { sign: jest.fn().mockReturnValue('mock.jwt.token'), + verify: jest.fn(), }; const mockConfigService = { @@ -41,6 +44,11 @@ describe('AuthService', () => { createProfile: jest.fn(), }; + const mockAuditService = { + log: jest.fn().mockResolvedValue(undefined), + logWithBeforeAfter: jest.fn().mockResolvedValue(undefined), + }; + const validWallet = 'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW'; beforeEach(async () => { @@ -51,6 +59,7 @@ describe('AuthService', () => { { provide: JwtService, useValue: mockJwtService }, { provide: ConfigService, useValue: mockConfigService }, { provide: UsersRepository, useValue: mockUsersRepository }, + { provide: AuditService, useValue: mockAuditService }, ], }).compile(); @@ -351,16 +360,54 @@ describe('AuthService', () => { ); }); - it('should sign refresh token with payload { wallet, type: refresh } and 7d expiration', async () => { + it('should sign refresh token with payload { wallet, type: refresh, fam } and 7d expiration', async () => { setupMocks(); await service.generateTokens(validWallet); expect(mockJwtService.sign).toHaveBeenCalledWith( - { wallet: validWallet, type: 'refresh' }, + { wallet: validWallet, type: 'refresh', fam: expect.any(String) }, expect.objectContaining({ expiresIn: '7d' }), ); }); + it('should store session row with the same family_id embedded in the refresh token', async () => { + const sessionInsert = jest.fn().mockResolvedValue({ error: null }); + mockFrom.mockImplementation((table: string) => { + if (table === 'users') { + const chain: Record = { + upsert: jest.fn(), + select: jest.fn(), + single: jest.fn().mockResolvedValue({ data: { id: 'user-uuid', status: 'active' }, error: null }), + }; + chain.upsert.mockReturnValue(chain); + chain.select.mockReturnValue(chain); + return chain; + } + if (table === 'learner_profiles') { + const chain: Record = { + select: jest.fn(), + eq: jest.fn(), + maybeSingle: jest.fn().mockResolvedValue({ data: null, error: null }), + insert: jest.fn().mockResolvedValue({ error: null }), + }; + chain.select.mockReturnValue(chain); + chain.eq.mockReturnValue(chain); + return chain; + } + if (table === 'sessions') { + return { insert: sessionInsert }; + } + return { insert: mockInsert }; + }); + + await service.generateTokens(validWallet); + + const refreshCall = mockJwtService.sign.mock.calls.find((c) => c[0].type === 'refresh'); + expect(sessionInsert).toHaveBeenCalledWith( + expect.objectContaining({ family_id: refreshCall?.[0].fam }), + ); + }); + it('should throw UnauthorizedException (AUTH_USER_BLOCKED) when user account is blocked', async () => { setupMocks({ userResult: { data: { id: 'user-uuid', status: 'blocked' }, error: null } }); @@ -488,4 +535,147 @@ describe('AuthService', () => { }); }); }); + + // --------------------------------------------------------------------------- + // refreshTokens — rotation within session family + replay detection + // --------------------------------------------------------------------------- + describe('refreshTokens', () => { + const refreshToken = 'valid.refresh.token'; + const tokenHash = createHash('sha256').update(refreshToken).digest('hex'); + const familyId = '11111111-2222-3333-4444-555555555555'; + const futureExpiry = new Date(Date.now() + 60 * 1000).toISOString(); + + function setupSessionMocks({ + payload = { wallet: validWallet, type: 'refresh', fam: familyId } as Record, + sessionLookup = { data: { id: 'session-uuid', family_id: familyId, expires_at: futureExpiry }, error: null }, + userStatus = 'active', + } = {}) { + const deleteEq = jest.fn().mockResolvedValue({ error: null, count: 1 }); + const deleteFn = jest.fn().mockReturnValue({ eq: deleteEq }); + mockJwtService.verify.mockReturnValue(payload); + mockFrom.mockImplementation((table: string) => { + if (table === 'users') { + const chain: Record = { + upsert: jest.fn(), + select: jest.fn(), + single: jest.fn().mockResolvedValue({ data: { id: 'user-uuid', status: userStatus }, error: null }), + }; + chain.upsert.mockReturnValue(chain); + chain.select.mockReturnValue(chain); + return chain; + } + if (table === 'learner_profiles') { + const chain: Record = { + select: jest.fn(), + eq: jest.fn(), + maybeSingle: jest.fn().mockResolvedValue({ data: null, error: null }), + insert: jest.fn().mockResolvedValue({ error: null }), + }; + chain.select.mockReturnValue(chain); + chain.eq.mockReturnValue(chain); + return chain; + } + if (table === 'sessions') { + return { + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + single: jest.fn().mockResolvedValue(sessionLookup), + insert: jest.fn().mockResolvedValue({ error: null }), + delete: deleteFn, + }; + } + return { insert: mockInsert }; + }); + return { deleteFn, deleteEq }; + } + + beforeEach(() => { + mockConfigService.get.mockImplementation((key: string) => + key === 'JWT_REFRESH_SECRET' ? 'refresh-secret' : 'mock-secret', + ); + mockJwtService.verify.mockReturnValue({ wallet: validWallet, type: 'refresh', fam: familyId }); + }); + + it('should rotate tokens into the same session family', async () => { + setupSessionMocks(); + + await service.refreshTokens(refreshToken); + + const refreshCall = mockJwtService.sign.mock.calls.find((c) => c[0].type === 'refresh'); + expect(refreshCall?.[0]).toMatchObject({ wallet: validWallet, fam: familyId }); + }); + + it('should delete the presented session row on successful rotation', async () => { + const { deleteEq } = setupSessionMocks(); + + await service.refreshTokens(refreshToken); + + expect(deleteEq).toHaveBeenCalledWith('id', 'session-uuid'); + }); + + it('should revoke the entire family and write an audit event when a rotated token is replayed', async () => { + // Session row is gone — the token was already rotated. + const { deleteFn } = setupSessionMocks({ + sessionLookup: { data: null, error: { message: 'No rows found' } }, + }); + + await expect(service.refreshTokens(refreshToken)).rejects.toMatchObject({ + response: { code: 'AUTH_REFRESH_TOKEN_REUSED' }, + }); + + expect(deleteFn).toHaveBeenCalledWith({ count: 'exact' }); + expect(mockAuditService.logWithBeforeAfter).toHaveBeenCalledWith( + expect.objectContaining({ + actorWallet: validWallet, + action: 'auth.refresh_token_reuse', + resource: 'session', + metadata: { family_id: familyId }, + }), + ); + }); + + it('should throw AUTH_SESSION_NOT_FOUND for an unknown legacy token without a family claim', async () => { + setupSessionMocks({ + payload: { wallet: validWallet, type: 'refresh' }, + sessionLookup: { data: null, error: { message: 'No rows found' } }, + }); + + await expect(service.refreshTokens(refreshToken)).rejects.toMatchObject({ + response: { code: 'AUTH_SESSION_NOT_FOUND' }, + }); + // No family claim → nothing to revoke, no audit event for the family. + expect(mockAuditService.logWithBeforeAfter).not.toHaveBeenCalled(); + }); + + it('should throw AUTH_SESSION_EXPIRED when the session row exists but has expired', async () => { + setupSessionMocks({ + sessionLookup: { + data: { id: 'session-uuid', family_id: familyId, expires_at: new Date(Date.now() - 1000).toISOString() }, + error: null, + }, + }); + + await expect(service.refreshTokens(refreshToken)).rejects.toMatchObject({ + response: { code: 'AUTH_SESSION_EXPIRED' }, + }); + }); + + it('should throw AUTH_USER_BLOCKED when refreshing for a blocked user', async () => { + setupSessionMocks({ userStatus: 'blocked' }); + + await expect(service.refreshTokens(refreshToken)).rejects.toMatchObject({ + response: { code: 'AUTH_USER_BLOCKED' }, + }); + }); + + it('should throw AUTH_REFRESH_TOKEN_INVALID when JWT verification fails', async () => { + mockJwtService.verify.mockImplementation(() => { + throw new Error('jwt expired'); + }); + + await expect(service.refreshTokens('garbage')).rejects.toMatchObject({ + response: { code: 'AUTH_REFRESH_TOKEN_INVALID' }, + }); + }); + }); }); diff --git a/test/unit/modules/auth/user-status.service.spec.ts b/test/unit/modules/auth/user-status.service.spec.ts new file mode 100644 index 0000000..bc297e4 --- /dev/null +++ b/test/unit/modules/auth/user-status.service.spec.ts @@ -0,0 +1,103 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { UnauthorizedException } from '@nestjs/common'; +import { UserStatusService, USER_STATUS_CACHE_TTL_MS } from '../../../../src/modules/auth/user-status.service'; +import { SupabaseService } from '../../../../src/database/supabase.client'; + +describe('UserStatusService', () => { + let service: UserStatusService; + + const selectSingle = jest.fn(); + const mockSupabaseClient = { + from: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + maybeSingle: selectSingle, + }), + }; + + const mockSupabaseService = { + getServiceRoleClient: jest.fn(() => mockSupabaseClient), + }; + + const validWallet = 'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW'; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UserStatusService, + { provide: SupabaseService, useValue: mockSupabaseService }, + ], + }).compile(); + + service = module.get(UserStatusService); + + jest.clearAllMocks(); + selectSingle.mockResolvedValue({ data: { status: 'active' }, error: null }); + mockSupabaseClient.from.mockReturnValue({ + select: jest.fn().mockReturnThis(), + eq: jest.fn().mockReturnThis(), + maybeSingle: selectSingle, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should return status from the database on first lookup', async () => { + await expect(service.getStatus(validWallet)).resolves.toBe('active'); + expect(selectSingle).toHaveBeenCalledTimes(1); + }); + + it('should serve subsequent lookups from cache within the TTL window', async () => { + await service.getStatus(validWallet); + await service.getStatus(validWallet); + await service.ensureNotBlocked(validWallet); + + expect(selectSingle).toHaveBeenCalledTimes(1); + }); + + it('should re-query the database once the cache entry expires', async () => { + await service.getStatus(validWallet); + + const now = Date.now(); + const dateSpy = jest.spyOn(Date, 'now'); + dateSpy.mockReturnValue(now + USER_STATUS_CACHE_TTL_MS + 1); + + await service.getStatus(validWallet); + + expect(selectSingle).toHaveBeenCalledTimes(2); + dateSpy.mockRestore(); + }); + + it('should throw AUTH_USER_BLOCKED when the cached/queried status is blocked', async () => { + selectSingle.mockResolvedValue({ data: { status: 'blocked' }, error: null }); + + await expect(service.ensureNotBlocked(validWallet)).rejects.toThrow(UnauthorizedException); + await expect(service.ensureNotBlocked(validWallet)).rejects.toMatchObject({ + response: { code: 'AUTH_USER_BLOCKED' }, + }); + }); + + it('should stop throwing after invalidate() forces a fresh DB check', async () => { + selectSingle.mockResolvedValue({ data: { status: 'blocked' }, error: null }); + await expect(service.ensureNotBlocked(validWallet)).rejects.toThrow(UnauthorizedException); + + selectSingle.mockResolvedValue({ data: { status: 'active' }, error: null }); + // Still blocked — served from cache. + await expect(service.ensureNotBlocked(validWallet)).rejects.toThrow(UnauthorizedException); + + service.invalidate(validWallet); + await expect(service.ensureNotBlocked(validWallet)).resolves.toBeUndefined(); + }); + + it('should fail open (treat as active) when the status query errors', async () => { + selectSingle.mockResolvedValue({ data: null, error: { message: 'DB down' } }); + + await expect(service.ensureNotBlocked(validWallet)).resolves.toBeUndefined(); + }); +});