Skip to content

Commit b407ad9

Browse files
committed
fixed: refresh-token
1 parent 7b0492f commit b407ad9

12 files changed

Lines changed: 620 additions & 13 deletions

File tree

context/progress-tracker.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,27 @@ pure chore/docs commits). Direct pushes to main must also be logged here.
66

77
---
88

9+
## 2026-08-24
10+
11+
- **Session families + refresh-token replay detection** (`sessions.family_id`
12+
migration, `fam` claim in refresh JWTs). Replaying an already-rotated
13+
refresh token now revokes every session in the family and writes a
14+
`auth.refresh_token_reuse` audit log entry — previously the first
15+
presenter of a stolen token won silently. Legacy tokens without a `fam`
16+
claim keep the old `AUTH_SESSION_NOT_FOUND` response.
17+
- **Blocked-user enforcement on every request**: new
18+
`UserStatusService` (in-memory TTL cache) consulted by `JwtStrategy`.
19+
Documented staleness bound: **30 seconds** — a blocked wallet loses API
20+
access within ~30s of being blocked instead of retaining access until its
21+
access token expires (up to 15 minutes). Cache is per-instance and fails
22+
open on DB errors to avoid locking out all users during a DB blip.
23+
- **Session cleanup cron** (`src/jobs/session-cleanup/`, hourly,
24+
mirrors nonce-cleanup): deletes only rows with `expires_at` older than
25+
1 hour; sessions no longer accumulate forever.
26+
- Tests: refresh-family rotation, replay → family-wide revocation + audit
27+
event, blocked-user denial within TTL bound, cache expiry re-query,
28+
cleanup job deletes-only-expired.
29+
930
## 2026-07-23
1031

1132
- 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.

src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { IndexerModule } from './indexer/indexer.module';
2121
import { LoanPaymentReminderModule } from './jobs/loan-payment-reminder/loan-payment-reminder.module';
2222
import { TransactionStatusCheckerModule } from './jobs/transaction-status-checker/transaction-status-checker.module';
2323
import { NonceCleanupModule } from './jobs/nonce-cleanup/nonce-cleanup.module';
24+
import { SessionCleanupModule } from './jobs/session-cleanup/session-cleanup.module';
2425
import { SupabaseKeepAliveModule } from './jobs/supabase-keepalive/supabase-keepalive.module';
2526
import { StellarModule } from './stellar/stellar.module';
2627
import { LoggerModule } from './common/logger/logger.module';
@@ -62,6 +63,7 @@ import { AuditInterceptor } from './common/interceptors/audit.interceptor';
6263
LoanPaymentReminderModule,
6364
TransactionStatusCheckerModule,
6465
NonceCleanupModule,
66+
SessionCleanupModule,
6567
SupabaseKeepAliveModule,
6668
StateReconciliationModule,
6769
CreditScoringModule,
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { Module } from '@nestjs/common';
2+
import { SessionCleanupService } from './session-cleanup.service';
3+
import { SupabaseService } from '../../database/supabase.client';
4+
5+
@Module({
6+
providers: [SessionCleanupService, SupabaseService],
7+
})
8+
export class SessionCleanupModule {}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import { Cron, CronExpression } from '@nestjs/schedule';
3+
import { SupabaseService } from '../../database/supabase.client';
4+
5+
@Injectable()
6+
export class SessionCleanupService {
7+
private readonly logger = new Logger(SessionCleanupService.name);
8+
9+
constructor(private readonly supabaseService: SupabaseService) {}
10+
11+
@Cron(CronExpression.EVERY_HOUR)
12+
async cleanupExpiredSessions(): Promise<void> {
13+
try {
14+
const client = this.supabaseService.getServiceRoleClient();
15+
16+
// Delete only rows already past their expiry; the 1h grace window
17+
// mirrors the nonce-cleanup pattern and keeps rows around long enough
18+
// that an "expired" response (instead of "not found") is still
19+
// possible for borderline requests.
20+
const cutoff = new Date(Date.now() - 60 * 60 * 1000).toISOString();
21+
22+
const { error, count } = await client
23+
.from('sessions')
24+
.delete({ count: 'exact' })
25+
.lt('expires_at', cutoff);
26+
27+
if (error) {
28+
this.logger.error(`Failed to delete expired sessions: ${error.message}`);
29+
throw error;
30+
}
31+
32+
this.logger.log(`Deleted ${count ?? 0} expired sessions`);
33+
} catch (error) {
34+
this.logger.error('Session cleanup failed', error);
35+
}
36+
}
37+
}

src/modules/auth/auth.module.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ import { PassportModule } from '@nestjs/passport';
55
import { AuthController } from './auth.controller';
66
import { AuthService } from './auth.service';
77
import { JwtStrategy } from './jwt.strategy';
8+
import { UserStatusService } from './user-status.service';
89
import { ApiKeyGuard } from '../../auth/guards/api-key.guard';
910
import { SupabaseService } from '../../database/supabase.client';
1011
import { UsersRepository } from '../../database/repositories/users.repository';
1112
import { getJwtConfig } from '../../config/jwt.config';
13+
import { AdminModule } from '../admin/admin.module';
1214

1315
@Module({
1416
imports: [
@@ -18,9 +20,10 @@ import { getJwtConfig } from '../../config/jwt.config';
1820
inject: [ConfigService],
1921
useFactory: getJwtConfig,
2022
}),
23+
AdminModule,
2124
],
2225
controllers: [AuthController],
23-
providers: [AuthService, JwtStrategy, ApiKeyGuard, SupabaseService, ConfigService, UsersRepository],
24-
exports: [AuthService, JwtStrategy, ApiKeyGuard, PassportModule],
26+
providers: [AuthService, JwtStrategy, UserStatusService, ApiKeyGuard, SupabaseService, ConfigService, UsersRepository],
27+
exports: [AuthService, JwtStrategy, UserStatusService, ApiKeyGuard, PassportModule],
2528
})
2629
export class AuthModule {}

src/modules/auth/auth.service.ts

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@ import {
33
InternalServerErrorException,
44
UnauthorizedException,
55
ConflictException,
6+
Logger,
67
} from '@nestjs/common';
78
import { JwtService } from '@nestjs/jwt';
89
import { ConfigService } from '@nestjs/config';
9-
import { createHash, randomBytes } from 'crypto';
10+
import { createHash, randomBytes, randomUUID } from 'crypto';
1011
import { Keypair, StrKey } from 'stellar-sdk';
1112
import { SupabaseService } from '../../database/supabase.client';
1213
import { UsersRepository, UploadedAvatarFile } from '../../database/repositories/users.repository';
@@ -20,9 +21,16 @@ import {
2021
REFRESH_TOKEN_EXPIRATION,
2122
REFRESH_TOKEN_EXPIRATION_MS,
2223
} from '../../config/jwt.config';
24+
import { AuditService } from '../admin/audit.service';
2325

2426
const NONCE_EXPIRATION_SECONDS = 300;
2527

28+
interface RefreshTokenPayload {
29+
type?: string;
30+
wallet?: string;
31+
fam?: string;
32+
}
33+
2634
export interface RegisterResponse extends AuthResponseDto {
2735
user: {
2836
id: string;
@@ -36,11 +44,14 @@ export interface RegisterResponse extends AuthResponseDto {
3644

3745
@Injectable()
3846
export class AuthService {
47+
private readonly logger = new Logger(AuthService.name);
48+
3949
constructor(
4050
private readonly supabaseService: SupabaseService,
4151
private readonly jwtService: JwtService,
4252
private readonly configService: ConfigService,
4353
private readonly usersRepository: UsersRepository,
54+
private readonly auditService: AuditService,
4455
) {}
4556

4657
async register(dto: RegisterRequestDto, profileImage?: UploadedAvatarFile): Promise<RegisterResponse> {
@@ -167,7 +178,7 @@ export class AuthService {
167178
return { id: user.id, role: user.role ?? null };
168179
}
169180

170-
async generateTokens(wallet: string): Promise<AuthResponseDto> {
181+
async generateTokens(wallet: string, familyId?: string): Promise<AuthResponseDto> {
171182
const { id: userId, role } = await this.findOrCreateUser(wallet);
172183
const client = this.supabaseService.getServiceRoleClient();
173184
// Role is read fresh from the users table on every token generation,
@@ -176,15 +187,19 @@ export class AuthService {
176187
{ wallet, type: 'access', role },
177188
{ secret: this.configService.get<string>('JWT_SECRET'), expiresIn: ACCESS_TOKEN_EXPIRATION },
178189
);
190+
// All tokens minted from one login (or any of its refreshes) share a
191+
// family id, enabling theft containment when a rotated token is replayed.
192+
const sessionFamilyId = familyId ?? randomUUID();
179193
const refreshToken = this.jwtService.sign(
180-
{ wallet, type: 'refresh' },
194+
{ wallet, type: 'refresh', fam: sessionFamilyId },
181195
{ secret: this.configService.get<string>('JWT_REFRESH_SECRET'), expiresIn: REFRESH_TOKEN_EXPIRATION },
182196
);
183197
const refreshTokenHash = createHash('sha256').update(refreshToken).digest('hex');
184198
const refreshExpiresAt = new Date(Date.now() + REFRESH_TOKEN_EXPIRATION_MS);
185199
const { error: sessionError } = await client.from('sessions').insert({
186200
user_id: userId,
187201
refresh_token_hash: refreshTokenHash,
202+
family_id: sessionFamilyId,
188203
expires_at: refreshExpiresAt.toISOString(),
189204
});
190205
if (sessionError) {
@@ -194,7 +209,7 @@ export class AuthService {
194209
}
195210

196211
async refreshTokens(refreshToken: string): Promise<AuthResponseDto> {
197-
let payload: { type?: string; wallet?: string };
212+
let payload: RefreshTokenPayload;
198213
try {
199214
payload = this.jwtService.verify(refreshToken, {
200215
secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
@@ -209,16 +224,64 @@ export class AuthService {
209224
const tokenHash = createHash('sha256').update(refreshToken).digest('hex');
210225
const { data: session, error } = await client
211226
.from('sessions')
212-
.select('id, expires_at')
227+
.select('id, family_id, expires_at')
213228
.eq('refresh_token_hash', tokenHash)
214229
.single();
215230
if (error || !session) {
231+
await this.handleRefreshReplay(payload);
232+
// Tokens minted before session families existed fall back to the
233+
// original error so legacy clients see a stable response shape.
234+
if (payload.fam) {
235+
throw new UnauthorizedException({
236+
code: 'AUTH_REFRESH_TOKEN_REUSED',
237+
message: 'Refresh token reuse detected. All sessions have been revoked. Please sign in again.',
238+
});
239+
}
216240
throw new UnauthorizedException({ code: 'AUTH_SESSION_NOT_FOUND', message: 'Session not found. Please sign in again.' });
217241
}
218242
if (new Date(session.expires_at) < new Date()) {
219243
throw new UnauthorizedException({ code: 'AUTH_SESSION_EXPIRED', message: 'Session expired. Please sign in again.' });
220244
}
221245
await client.from('sessions').delete().eq('id', session.id);
222-
return this.generateTokens(payload.wallet);
246+
return this.generateTokens(payload.wallet as string, session.family_id);
247+
}
248+
249+
/**
250+
* A validly-signed refresh token whose session row no longer exists means
251+
* the token was already rotated — i.e. it is being replayed, most likely
252+
* by an attacker who stole it. Contain the compromise by revoking every
253+
* session in the family and recording a security audit event.
254+
*/
255+
private async handleRefreshReplay(payload: RefreshTokenPayload): Promise<void> {
256+
const familyId = payload.fam;
257+
const wallet = payload.wallet ?? 'unknown';
258+
this.logger.error(`Refresh token replay detected for wallet ${wallet}${familyId ? ` (family ${familyId})` : ''}`);
259+
if (!familyId) {
260+
// Legacy token minted before families existed — nothing to revoke.
261+
return;
262+
}
263+
const client = this.supabaseService.getServiceRoleClient();
264+
const { error: revokeError, count } = await client
265+
.from('sessions')
266+
.delete({ count: 'exact' })
267+
.eq('family_id', familyId);
268+
if (revokeError) {
269+
this.logger.error(`Failed to revoke session family ${familyId}: ${revokeError.message}`);
270+
} else {
271+
this.logger.error(`Revoked ${count ?? 0} session(s) in family ${familyId} after refresh-token replay`);
272+
}
273+
try {
274+
await this.auditService.logWithBeforeAfter({
275+
actorWallet: wallet,
276+
action: 'auth.refresh_token_reuse',
277+
resource: 'session',
278+
resourceId: null,
279+
beforeState: null,
280+
afterState: { revoked_sessions: count ?? 0 },
281+
metadata: { family_id: familyId },
282+
});
283+
} catch (auditError) {
284+
this.logger.error('Failed to write refresh-token-reuse audit log', auditError);
285+
}
223286
}
224287
}

src/modules/auth/jwt.strategy.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
22
import { PassportStrategy } from '@nestjs/passport';
33
import { ExtractJwt, Strategy } from 'passport-jwt';
44
import { ConfigService } from '@nestjs/config';
5+
import { UserStatusService } from './user-status.service';
56

67
interface JwtPayload {
78
wallet: string;
@@ -19,10 +20,18 @@ interface JwtPayload {
1920
*
2021
* Only tokens with type === 'access' are accepted to prevent refresh tokens
2122
* from being used to authenticate API requests.
23+
*
24+
* On every request the user's account status is checked through
25+
* UserStatusService (short-TTL cache; staleness bound documented there), so
26+
* blocked wallets are denied access within that bound instead of retaining
27+
* access until their token naturally expires.
2228
*/
2329
@Injectable()
2430
export class JwtStrategy extends PassportStrategy(Strategy) {
25-
constructor(configService: ConfigService) {
31+
constructor(
32+
configService: ConfigService,
33+
private readonly userStatusService: UserStatusService,
34+
) {
2635
super({
2736
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
2837
ignoreExpiration: false,
@@ -37,14 +46,16 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
3746
* @param payload - Decoded JWT payload
3847
* @returns User object containing the wallet address
3948
*/
40-
validate(payload: JwtPayload): { wallet: string; role: string | null } {
49+
async validate(payload: JwtPayload): Promise<{ wallet: string; role: string | null }> {
4150
if (payload.type !== 'access') {
4251
throw new UnauthorizedException({
4352
code: 'AUTH_TOKEN_INVALID',
4453
message: 'Invalid or missing access token.',
4554
});
4655
}
4756

57+
await this.userStatusService.ensureNotBlocked(payload.wallet);
58+
4859
// Tokens issued before the role claim existed simply carry role: null;
4960
// RolesGuard will deny role-gated routes until the client refreshes.
5061
return { wallet: payload.wallet, role: payload.role ?? null };
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
2+
import { SupabaseService } from '../../database/supabase.client';
3+
4+
/**
5+
* How long a user's status may be served from cache before re-checking the
6+
* database. This is the documented staleness bound for blocking enforcement:
7+
* a blocked wallet can keep using valid access tokens for AT MOST this many
8+
* seconds (plus the remaining lifetime of its current access token is NOT
9+
* granted — requests within this window are the only grace period).
10+
*/
11+
export const USER_STATUS_CACHE_TTL_MS = 30_000;
12+
13+
interface CachedStatus {
14+
status: string;
15+
expiresAt: number;
16+
}
17+
18+
/**
19+
* Short-TTL in-memory cache of user account status, consulted on every
20+
* authenticated request by JwtStrategy so that blocked wallets lose API
21+
* access within USER_STATUS_CACHE_TTL_MS instead of waiting for their
22+
* access token to expire naturally.
23+
*
24+
* A local in-memory Map is used deliberately instead of Redis: the check
25+
* runs on every request, one Redis round trip per request would double
26+
* auth latency, and a 30s staleness bound does not justify shared state.
27+
* On multi-instance deployments each instance maintains its own cache with
28+
* the same bound.
29+
*/
30+
@Injectable()
31+
export class UserStatusService {
32+
private readonly logger = new Logger(UserStatusService.name);
33+
private readonly cache = new Map<string, CachedStatus>();
34+
35+
constructor(private readonly supabaseService: SupabaseService) {}
36+
37+
/**
38+
* Returns the user's status ('active', 'blocked', ...), serving from the
39+
* cache when fresh. Never throws for DB errors — fails open so a database
40+
* blip cannot lock out every authenticated user; the failure is logged.
41+
*/
42+
async getStatus(wallet: string): Promise<string> {
43+
const cached = this.cache.get(wallet);
44+
if (cached && cached.expiresAt > Date.now()) {
45+
return cached.status;
46+
}
47+
let status = 'active';
48+
try {
49+
const client = this.supabaseService.getServiceRoleClient();
50+
const { data, error } = await client
51+
.from('users')
52+
.select('status')
53+
.eq('wallet_address', wallet)
54+
.maybeSingle();
55+
if (!error && data?.status) {
56+
status = data.status;
57+
}
58+
if (error) {
59+
this.logger.error(`Failed to read status for ${wallet}: ${error.message}`);
60+
}
61+
} catch (err) {
62+
this.logger.error(`User status lookup failed for ${wallet}`, err);
63+
}
64+
this.cache.set(wallet, { status, expiresAt: Date.now() + USER_STATUS_CACHE_TTL_MS });
65+
return status;
66+
}
67+
68+
/** Throws AUTH_USER_BLOCKED when the wallet's account is suspended. */
69+
async ensureNotBlocked(wallet: string): Promise<void> {
70+
const status = await this.getStatus(wallet);
71+
if (status === 'blocked') {
72+
throw new UnauthorizedException({ code: 'AUTH_USER_BLOCKED', message: 'This account has been suspended.' });
73+
}
74+
}
75+
76+
/** Test/admin helper: drops cached status so the next check hits the DB. */
77+
invalidate(wallet: string): void {
78+
this.cache.delete(wallet);
79+
}
80+
}

0 commit comments

Comments
 (0)