Skip to content
Merged
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
12 changes: 12 additions & 0 deletions .changeset/refresh-fallback-telemetry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'seamless-auth-api': patch
---

Remove the legacy refresh-token fallback scan.

Refresh-token lookup now resolves sessions solely by their indexed `refreshTokenLookup`
fingerprint. The compatibility path that scanned all pre-fingerprint sessions and
bcrypt-compared each hash on a lookup miss has been removed, along with its per-request
`Session.findAll` scan. Sessions created before the `refreshTokenLookup` migration are no
longer refreshable and must re-authenticate; such sessions are long past the refresh-token
TTL, so no active sessions are affected.
14 changes: 2 additions & 12 deletions src/controllers/authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,16 +284,13 @@ export const refreshSession = async (req: Request, res: Response) => {
}

const now = new Date();
const { session, legacyFallbackCandidates, usedLegacyFallback } = await findRefreshSessionByToken(
refreshToken,
now,
);
const session = await findRefreshSessionByToken(refreshToken, now);

if (!session) {
const looksLikeJwt = refreshToken.split('.').length === 3;

logger.warn(
`No refresh session found for refresh token. legacyFallbackCandidates=${legacyFallbackCandidates} tokenFormat=${looksLikeJwt ? 'jwt_like' : 'opaque'}`,
`No refresh session found for refresh token. tokenFormat=${looksLikeJwt ? 'jwt_like' : 'opaque'}`,
);

if (looksLikeJwt) {
Expand All @@ -304,18 +301,11 @@ export const refreshSession = async (req: Request, res: Response) => {

await AuthEventService.refreshTokenFailed(req, {
reason: 'No refresh session found for refresh token',
legacyFallbackCandidates,
tokenFormat: looksLikeJwt ? 'jwt_like' : 'opaque',
});
return res.status(401).json({ error: 'invalid_refresh_token' });
}

if (usedLegacyFallback) {
logger.info(
`Refresh token matched a legacy session without refreshTokenLookup. sessionId=${session.id} fallbackCandidates=${legacyFallbackCandidates}`,
);
}

// Reuse detection: if this session was already rotated, it means we’ve seen this token before
if (session.replacedBySessionId || session.revokedAt) {
logger.warn(
Expand Down
53 changes: 6 additions & 47 deletions src/services/sessionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
* See LICENSE file in the project root for full license information
*/

import { compareSync } from 'bcrypt-ts';
import { importSPKI, jwtVerify } from 'jose';
import { Op } from 'sequelize';

Expand Down Expand Up @@ -116,60 +115,20 @@ export async function validateAccessToken(token: string): Promise<ValidatedAcces
};
}

export interface RefreshSessionLookupResult {
session: Session | null;
legacyFallbackCandidates: number;
usedLegacyFallback: boolean;
}

export async function findRefreshSessionByToken(
refreshToken: string,
now = new Date(),
): Promise<RefreshSessionLookupResult> {
const activeWhere = {
revokedAt: null,
expiresAt: { [Op.gt]: now },
idleExpiresAt: { [Op.gt]: now },
};

): Promise<Session | null> {
const refreshTokenLookup = createRefreshTokenLookup(refreshToken);
const session = await Session.findOne({
where: {
...activeWhere,
refreshTokenLookup,
},
});

if (session) {
return {
session,
legacyFallbackCandidates: 0,
usedLegacyFallback: false,
};
}

const legacySessions = await Session.findAll({
return Session.findOne({
where: {
...activeWhere,
refreshTokenLookup: null,
revokedAt: null,
expiresAt: { [Op.gt]: now },
idleExpiresAt: { [Op.gt]: now },
refreshTokenLookup,
},
});

for (const legacySession of legacySessions) {
if (compareSync(refreshToken, legacySession.refreshTokenHash)) {
return {
session: legacySession,
legacyFallbackCandidates: legacySessions.length,
usedLegacyFallback: true,
};
}
}

return {
session: null,
legacyFallbackCandidates: legacySessions.length,
usedLegacyFallback: legacySessions.length > 0,
};
}

export async function validateSessionRecord(sessionId: string) {
Expand Down
6 changes: 1 addition & 5 deletions tests/e2e/authFlow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,11 +126,7 @@ describe('E2E Auth Flow', () => {

(validateAccessToken as any).mockResolvedValue(null);
(validateBearerToken as any).mockResolvedValue(null);
(findRefreshSessionByToken as any).mockResolvedValue({
session: buildSession(),
legacyFallbackCandidates: 0,
usedLegacyFallback: false,
});
(findRefreshSessionByToken as any).mockResolvedValue(buildSession());

(User.findByPk as any).mockResolvedValue({
id: 'user-1',
Expand Down
12 changes: 2 additions & 10 deletions tests/integration/authentication/authentication.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,11 +204,7 @@ describe('POST /refresh', () => {
});

it('rejects invalid session', async () => {
(findRefreshSessionByToken as any).mockResolvedValue({
session: null,
legacyFallbackCandidates: 0,
usedLegacyFallback: false,
});
(findRefreshSessionByToken as any).mockResolvedValue(null);

const res = await request(app).post('/refresh').set('Authorization', 'Bearer refresh-token');

Expand All @@ -228,11 +224,7 @@ describe('POST /refresh', () => {
save: vi.fn(),
};

(findRefreshSessionByToken as any).mockResolvedValue({
session,
legacyFallbackCandidates: 0,
usedLegacyFallback: false,
});
(findRefreshSessionByToken as any).mockResolvedValue(session);

(User.findByPk as any).mockResolvedValue(buildUser());

Expand Down
13 changes: 2 additions & 11 deletions tests/unit/controllers/authentication.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,7 @@ describe('refreshSession', () => {
await loadAuthenticationModule();
const { req, res } = mockReqRes('Bearer raw-refresh-token');

(findRefreshSessionByToken as any).mockResolvedValue({
session: null,
legacyFallbackCandidates: 2,
usedLegacyFallback: true,
});
(findRefreshSessionByToken as any).mockResolvedValue(null);
(AuthEventService.refreshTokenFailed as any).mockResolvedValue(undefined);

await refreshSession(req, res);
Expand All @@ -99,7 +95,6 @@ describe('refreshSession', () => {
req,
expect.objectContaining({
reason: 'No refresh session found for refresh token',
legacyFallbackCandidates: 2,
tokenFormat: 'opaque',
}),
);
Expand Down Expand Up @@ -133,11 +128,7 @@ describe('refreshSession', () => {
save: vi.fn(),
};

(findRefreshSessionByToken as any).mockResolvedValue({
session,
legacyFallbackCandidates: 0,
usedLegacyFallback: false,
});
(findRefreshSessionByToken as any).mockResolvedValue(session);
(User.findByPk as any).mockResolvedValue(user);
(generateRefreshToken as any).mockReturnValue('new-raw-refresh-token');
(hashRefreshToken as any).mockResolvedValue('new-refresh-hash');
Expand Down
50 changes: 4 additions & 46 deletions tests/unit/services/sessionService.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ describe('sessionService', () => {
expect(result).toBe(session);
});

it('finds refresh sessions by indexed lookup before falling back to bcrypt comparison', async () => {
it('finds a refresh session by its indexed lookup fingerprint', async () => {
const { Session } = await import('../../../src/models/sessions');
const { createRefreshTokenLookup } = await import('../../../src/lib/token');

Expand All @@ -195,63 +195,21 @@ describe('sessionService', () => {
}),
}),
);
expect(result).toEqual({
session,
legacyFallbackCandidates: 0,
usedLegacyFallback: false,
});
});

it('falls back to legacy refresh-token hashes for sessions without lookup fingerprints', async () => {
const { Session } = await import('../../../src/models/sessions');
const { createRefreshTokenLookup } = await import('../../../src/lib/token');
const { compareSync } = await import('bcrypt-ts');

const legacySession = buildSession({ refreshTokenHash: 'legacy-hash' });

(createRefreshTokenLookup as any).mockReturnValue('lookup');
(Session.findOne as any).mockResolvedValue(null);
(Session.findAll as any).mockResolvedValue([legacySession]);
(compareSync as any).mockReturnValue(true);

const { findRefreshSessionByToken } = await import('../../../src/services/sessionService');

const result = await findRefreshSessionByToken('refresh-token');

expect(Session.findAll).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
refreshTokenLookup: null,
}),
}),
);
expect(compareSync).toHaveBeenCalledWith('refresh-token', 'legacy-hash');
expect(result).toEqual({
session: legacySession,
legacyFallbackCandidates: 1,
usedLegacyFallback: true,
});
expect(result).toBe(session);
});

it('returns a legacy fallback miss when no legacy session hash matches', async () => {
it('returns null when no session matches the lookup fingerprint', async () => {
const { Session } = await import('../../../src/models/sessions');
const { createRefreshTokenLookup } = await import('../../../src/lib/token');
const { compareSync } = await import('bcrypt-ts');

(createRefreshTokenLookup as any).mockReturnValue('lookup');
(Session.findOne as any).mockResolvedValue(null);
(Session.findAll as any).mockResolvedValue([buildSession({ refreshTokenHash: 'legacy-hash' })]);
(compareSync as any).mockReturnValue(false);

const { findRefreshSessionByToken } = await import('../../../src/services/sessionService');

const result = await findRefreshSessionByToken('refresh-token');

expect(result).toEqual({
session: null,
legacyFallbackCandidates: 1,
usedLegacyFallback: true,
});
expect(result).toBeNull();
});

it('revokes replaced sessions during validateSessionRecord', async () => {
Expand Down
Loading