Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions context/progress-tracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -62,6 +63,7 @@ import { AuditInterceptor } from './common/interceptors/audit.interceptor';
LoanPaymentReminderModule,
TransactionStatusCheckerModule,
NonceCleanupModule,
SessionCleanupModule,
SupabaseKeepAliveModule,
StateReconciliationModule,
CreditScoringModule,
Expand Down
8 changes: 8 additions & 0 deletions src/jobs/session-cleanup/session-cleanup.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
37 changes: 37 additions & 0 deletions src/jobs/session-cleanup/session-cleanup.service.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
}
}
7 changes: 5 additions & 2 deletions src/modules/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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 {}
75 changes: 69 additions & 6 deletions src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand All @@ -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<RegisterResponse> {
Expand Down Expand Up @@ -167,7 +178,7 @@ export class AuthService {
return { id: user.id, role: user.role ?? null };
}

async generateTokens(wallet: string): Promise<AuthResponseDto> {
async generateTokens(wallet: string, familyId?: string): Promise<AuthResponseDto> {
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,
Expand All @@ -176,15 +187,19 @@ export class AuthService {
{ wallet, type: 'access', role },
{ secret: this.configService.get<string>('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<string>('JWT_REFRESH_SECRET'), expiresIn: REFRESH_TOKEN_EXPIRATION },
);
const refreshTokenHash = createHash('sha256').update(refreshToken).digest('hex');
const refreshExpiresAt = new Date(Date.now() + REFRESH_TOKEN_EXPIRATION_MS);
const { error: sessionError } = await client.from('sessions').insert({
user_id: userId,
refresh_token_hash: refreshTokenHash,
family_id: sessionFamilyId,
expires_at: refreshExpiresAt.toISOString(),
});
if (sessionError) {
Expand All @@ -194,7 +209,7 @@ export class AuthService {
}

async refreshTokens(refreshToken: string): Promise<AuthResponseDto> {
let payload: { type?: string; wallet?: string };
let payload: RefreshTokenPayload;
try {
payload = this.jwtService.verify(refreshToken, {
secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
Expand All @@ -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<void> {
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);
}
}
}
15 changes: 13 additions & 2 deletions src/modules/auth/jwt.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -37,14 +46,16 @@ 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',
message: 'Invalid or missing access token.',
});
}

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 };
Expand Down
80 changes: 80 additions & 0 deletions src/modules/auth/user-status.service.ts
Original file line number Diff line number Diff line change
@@ -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<string, CachedStatus>();

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<string> {
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<void> {
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);
}
}
Loading
Loading