diff --git a/packages/devframe/src/node/auth/__tests__/state-import.test.ts b/packages/devframe/src/node/auth/__tests__/state-import.test.ts new file mode 100644 index 000000000..aede32765 --- /dev/null +++ b/packages/devframe/src/node/auth/__tests__/state-import.test.ts @@ -0,0 +1,22 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +describe('auth state import', () => { + afterEach(() => { + vi.doUnmock('devframe/utils/crypto-token') + vi.resetModules() + }) + + it('does not generate an authentication code at module evaluation', async () => { + const randomDigits = vi.fn(() => '123456') + + vi.doMock('devframe/utils/crypto-token', () => ({ + randomDigits, + randomToken: vi.fn(), + timingSafeEqual: vi.fn(), + })) + + await import('../state') + + expect(randomDigits).not.toHaveBeenCalled() + }) +}) diff --git a/packages/devframe/src/node/auth/state.ts b/packages/devframe/src/node/auth/state.ts index 623c90059..f0135c232 100644 --- a/packages/devframe/src/node/auth/state.ts +++ b/packages/devframe/src/node/auth/state.ts @@ -38,20 +38,29 @@ const TEMP_AUTH_CODE_TTL = 5 * 60_000 /** Failed attempts allowed against a single code before it is rotated. */ const TEMP_AUTH_MAX_ATTEMPTS = 5 -let tempAuthCode: string = generateTempCode() -let tempAuthCodeExpiresAt: number = Date.now() + TEMP_AUTH_CODE_TTL +let tempAuthCode: string | undefined +let tempAuthCodeExpiresAt = 0 let tempAuthFailedAttempts = 0 function generateTempCode(): string { return randomDigits(TEMP_AUTH_CODE_LENGTH) } +function ensureTempAuthCode(): string { + if (tempAuthCode === undefined) { + tempAuthCode = generateTempCode() + tempAuthCodeExpiresAt = Date.now() + TEMP_AUTH_CODE_TTL + } + + return tempAuthCode +} + /** * The current one-time authentication code. Display this to the user (e.g. in * the dev-server terminal) so they can type it into the browser to authenticate. */ export function getTempAuthCode(): string { - return tempAuthCode + return ensureTempAuthCode() } /** @@ -78,7 +87,7 @@ export function refreshTempAuthCode(): string { * `Referer` header; the browser client reads it locally (see * `consumeOtpFromUrl`). Any existing fragment parameters are preserved. */ -export function buildOtpAuthUrl(baseUrl: string, code: string = tempAuthCode): string { +export function buildOtpAuthUrl(baseUrl: string, code: string = getTempAuthCode()): string { const url = new URL(baseUrl) const fragment = new URLSearchParams(url.hash.replace(/^#/, '')) fragment.set(DEVFRAME_OTP_URL_PARAM, code) @@ -125,13 +134,15 @@ export function exchangeTempAuthCode( info: { ua: string, origin: string }, storage: SharedState, ): string | null { + const currentTempAuthCode = ensureTempAuthCode() + // Expired code: rotate so a stale code can never be redeemed. if (Date.now() > tempAuthCodeExpiresAt) { refreshTempAuthCode() return null } - if (!timingSafeEqual(code, tempAuthCode)) { + if (!timingSafeEqual(code, currentTempAuthCode)) { tempAuthFailedAttempts += 1 // Too many wrong guesses, so invalidate this code entirely. if (tempAuthFailedAttempts >= TEMP_AUTH_MAX_ATTEMPTS)