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
40 changes: 25 additions & 15 deletions src/controllers/authController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,13 @@ export class AuthController {
}

/**
* Refresh access token using a valid refresh token
* Refresh access token using a valid refresh token.
*
* On success the consumed refresh token is revoked and a fresh token pair
* (access + refresh) is returned. Single-use enforcement means a reused
* token is an unambiguous theft signal: all tokens for the user are
* immediately revoked and the request is rejected with 401.
*
* POST /auth/refresh
*/
async refreshToken(req: Request, res: Response, next: NextFunction): Promise<void> {
Expand Down Expand Up @@ -53,12 +59,15 @@ export class AuthController {
return;
}

// Check if token is revoked or expired
// Reuse detection — token is already revoked, which means it was
// previously rotated. Presenting it again is a theft signal.
// Revoke all user tokens and reject the request.
if (storedToken.isRevoked) {
await this.refreshTokenService.handleReuse(storedToken, this.refreshTokenRepository);
logger.warn('[AuthController] Attempted to use revoked refresh token', {
logger.warn('[AuthController] Theft signal: revoked token presented again — all user tokens revoked', {
tokenId: tokenPayload.tokenId,
userId: tokenPayload.userId
userId: tokenPayload.userId,
familyId: storedToken.familyId
});
next(new UnauthorizedError('Refresh token has been revoked', 'REVOKED_TOKEN'));
return;
Expand All @@ -83,22 +92,24 @@ export class AuthController {
return;
}

// Update last used timestamp
await this.refreshTokenRepository.updateLastUsed(storedToken.id, storedToken.userId);

// Generate new access token
const newAccessToken = this.refreshTokenService.refreshAccessToken(
// Rotate: revoke consumed token, issue fresh access + refresh token pair
// in the same family so the entire lineage is covered by theft detection.
const newTokenPair = await this.refreshTokenService.rotateRefreshToken(
storedToken,
storedToken.userId,
undefined // walletAddress not available in refresh flow
undefined, // walletAddress not available in refresh flow
this.refreshTokenRepository
);

logger.info('[AuthController] Access token refreshed successfully', {
logger.info('[AuthController] Token pair rotated successfully', {
userId: storedToken.userId,
tokenId: storedToken.id
consumedTokenId: storedToken.id,
familyId: storedToken.familyId
});

res.json({
accessToken: newAccessToken,
accessToken: newTokenPair.accessToken,
refreshToken: newTokenPair.refreshToken,
tokenType: 'Bearer'
});

Expand Down Expand Up @@ -174,7 +185,6 @@ export class AuthController {
*/
async revokeAllTokens(req: Request, res: Response, next: NextFunction): Promise<void> {
try {
// This endpoint should be protected by requireAuth middleware
const userId = (req as any).developerId || res.locals.authenticatedUser?.id;

if (!userId) {
Expand Down Expand Up @@ -211,7 +221,7 @@ export class AuthController {

res.json({
activeRefreshTokens: activeTokenCount,
maxAllowedTokens: 5 // Configurable limit
maxAllowedTokens: 5
});

} catch (error) {
Expand Down
82 changes: 68 additions & 14 deletions src/services/refreshTokenService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,13 @@ export class RefreshTokenService {
}

/**
* Create access and refresh token pair
* Create access and refresh token pair.
* Both tokens share the same tokenId so the refresh token record
* can be located by ID during the rotation flow.
*/
createTokenPair(userId: string, walletAddress?: string): TokenPair {
const tokenId = this.generateTokenId();

// Create access token (short-lived)
const accessTokenPayload: AccessTokenPayload = {
userId,
walletAddress,
Expand All @@ -58,7 +59,6 @@ export class RefreshTokenService {
algorithm: 'HS256'
});

// Create refresh token (long-lived)
const refreshTokenPayload: RefreshTokenPayload = {
userId,
tokenId,
Expand All @@ -70,10 +70,7 @@ export class RefreshTokenService {
algorithm: 'HS256'
});

return {
accessToken,
refreshToken
};
return { accessToken, refreshToken };
}

/**
Expand Down Expand Up @@ -194,14 +191,71 @@ export class RefreshTokenService {
}

/**
* Handle refresh token reuse: revoke the entire family atomically and log audit event
* Rotate a refresh token — revoke the consumed token and issue a new one
* in the same family. This is called on every successful refresh so that
* each refresh token can only be used once. Single-use enforcement makes
* theft detectable: if the old token is presented again after rotation,
* `isRevoked` will be true and `handleReuse` will fire.
*
* @param consumedToken - The refresh token record that was just validated
* @param userId - Owner of the token
* @param walletAddress - Optional wallet address to embed in the new access token
* @param repository - Token repository for persistence
* @returns A fresh { accessToken, refreshToken } pair in the same family
*/
async handleReuse(storedToken: RefreshToken, repository: RefreshTokenRepository): Promise<void> {
logger.warn('[RefreshTokenService] Confirmed refresh token reuse detected. Revoking entire family.', {
familyId: storedToken.familyId,
userId: storedToken.userId,
tokenId: storedToken.id
async rotateRefreshToken(
consumedToken: RefreshToken,
userId: string,
walletAddress: string | undefined,
repository: RefreshTokenRepository
): Promise<TokenPair> {
// 1. Revoke the consumed token so it cannot be reused
await repository.revokeRefreshToken(consumedToken.id, userId);

// 2. Issue a new token pair — carry the familyId forward so theft
// detection covers the entire lineage
const newPair = this.createTokenPair(userId, walletAddress);
const newRecord = this.createRefreshTokenRecord(userId, newPair.refreshToken, consumedToken.familyId);
await repository.createRefreshToken(newRecord);

logger.info('[RefreshTokenService] Refresh token rotated', {
userId,
consumedTokenId: consumedToken.id,
newTokenId: newRecord.id,
familyId: consumedToken.familyId
});
await repository.revokeFamily(storedToken.familyId, storedToken.userId);

return newPair;
}

/**
* Handle refresh token reuse (theft signal).
*
* When a token that is already revoked is presented again it means one of:
* a) The legitimate user's token was stolen and the attacker rotated it,
* leaving the victim holding a now-revoked token.
* b) The attacker's rotated token was stolen back by the legitimate user.
*
* In either case we cannot tell who is legitimate, so the safest response
* is to revoke ALL tokens for the user, forcing a full re-authentication.
* Revoking only the family is insufficient because an attacker who has
* already rotated the token may have started a new family.
*
* @param storedToken - The revoked token record that was presented again
* @param repository - Token repository for persistence
*/
async handleReuse(storedToken: RefreshToken, repository: RefreshTokenRepository): Promise<void> {
logger.warn(
'[RefreshTokenService] Refresh token reuse detected — revoking ALL user tokens as theft countermeasure.',
{
userId: storedToken.userId,
familyId: storedToken.familyId,
tokenId: storedToken.id
}
);

// Revoke every token for this user, not just the family, because the
// attacker may have rotated into a new family after the initial theft.
await repository.revokeAllUserTokens(storedToken.userId);
}
}
Loading