@@ -3,10 +3,11 @@ import {
33 InternalServerErrorException ,
44 UnauthorizedException ,
55 ConflictException ,
6+ Logger ,
67} from '@nestjs/common' ;
78import { JwtService } from '@nestjs/jwt' ;
89import { ConfigService } from '@nestjs/config' ;
9- import { createHash , randomBytes } from 'crypto' ;
10+ import { createHash , randomBytes , randomUUID } from 'crypto' ;
1011import { Keypair , StrKey } from 'stellar-sdk' ;
1112import { SupabaseService } from '../../database/supabase.client' ;
1213import { 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
2426const NONCE_EXPIRATION_SECONDS = 300 ;
2527
28+ interface RefreshTokenPayload {
29+ type ?: string ;
30+ wallet ?: string ;
31+ fam ?: string ;
32+ }
33+
2634export interface RegisterResponse extends AuthResponseDto {
2735 user : {
2836 id : string ;
@@ -36,11 +44,14 @@ export interface RegisterResponse extends AuthResponseDto {
3644
3745@Injectable ( )
3846export 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}
0 commit comments