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
22 changes: 22 additions & 0 deletions packages/devframe/src/node/auth/__tests__/state-import.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
21 changes: 16 additions & 5 deletions packages/devframe/src/node/auth/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

/**
Expand All @@ -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)
Expand Down Expand Up @@ -125,13 +134,15 @@ export function exchangeTempAuthCode(
info: { ua: string, origin: string },
storage: SharedState<InternalAnonymousAuthStorage>,
): 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)
Expand Down
Loading