From 7d8931526acd5e81a4b79424215e2efd6b6f85e2 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 6 Jul 2026 20:31:57 -0400 Subject: [PATCH] refactor(refresh): remove the legacy refresh-token fallback scan findRefreshSessionByToken now resolves sessions solely by their indexed refreshTokenLookup fingerprint and returns Session | null. The legacy compatibility path that scanned every pre-fingerprint session and bcrypt-compared each hash on a lookup miss is removed, along with the RefreshSessionLookupResult wrapper and its per-request Session.findAll scan. The refresh controller and tests are updated accordingly. Sessions created before the refreshTokenLookup migration are no longer refreshable and must re-authenticate; those are well past the refresh token TTL, so no active sessions are affected. Closes #18 --- .changeset/refresh-fallback-telemetry.md | 12 +++++ src/controllers/authentication.ts | 14 +---- src/services/sessionService.ts | 53 +++---------------- tests/e2e/authFlow.spec.ts | 6 +-- .../authentication/authentication.spec.ts | 12 +---- tests/unit/controllers/authentication.spec.ts | 13 +---- tests/unit/services/sessionService.spec.ts | 50 ++--------------- 7 files changed, 29 insertions(+), 131 deletions(-) create mode 100644 .changeset/refresh-fallback-telemetry.md diff --git a/.changeset/refresh-fallback-telemetry.md b/.changeset/refresh-fallback-telemetry.md new file mode 100644 index 0000000..02f504e --- /dev/null +++ b/.changeset/refresh-fallback-telemetry.md @@ -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. diff --git a/src/controllers/authentication.ts b/src/controllers/authentication.ts index 6a45a95..d455526 100644 --- a/src/controllers/authentication.ts +++ b/src/controllers/authentication.ts @@ -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) { @@ -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( diff --git a/src/services/sessionService.ts b/src/services/sessionService.ts index 3fe978c..999bc38 100644 --- a/src/services/sessionService.ts +++ b/src/services/sessionService.ts @@ -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'; @@ -116,60 +115,20 @@ export async function validateAccessToken(token: string): Promise { - const activeWhere = { - revokedAt: null, - expiresAt: { [Op.gt]: now }, - idleExpiresAt: { [Op.gt]: now }, - }; - +): Promise { 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) { diff --git a/tests/e2e/authFlow.spec.ts b/tests/e2e/authFlow.spec.ts index 139cdbe..654c675 100644 --- a/tests/e2e/authFlow.spec.ts +++ b/tests/e2e/authFlow.spec.ts @@ -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', diff --git a/tests/integration/authentication/authentication.spec.ts b/tests/integration/authentication/authentication.spec.ts index 1b9f7c9..9bd360d 100644 --- a/tests/integration/authentication/authentication.spec.ts +++ b/tests/integration/authentication/authentication.spec.ts @@ -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'); @@ -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()); diff --git a/tests/unit/controllers/authentication.spec.ts b/tests/unit/controllers/authentication.spec.ts index 57b8199..dac9647 100644 --- a/tests/unit/controllers/authentication.spec.ts +++ b/tests/unit/controllers/authentication.spec.ts @@ -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); @@ -99,7 +95,6 @@ describe('refreshSession', () => { req, expect.objectContaining({ reason: 'No refresh session found for refresh token', - legacyFallbackCandidates: 2, tokenFormat: 'opaque', }), ); @@ -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'); diff --git a/tests/unit/services/sessionService.spec.ts b/tests/unit/services/sessionService.spec.ts index c69055a..6386cec 100644 --- a/tests/unit/services/sessionService.spec.ts +++ b/tests/unit/services/sessionService.spec.ts @@ -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'); @@ -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 () => {