diff --git a/docs/authentication.md b/docs/authentication.md index d4054b87f..b0dcb4bbd 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -19,7 +19,7 @@ sequenceDiagram CLI->>CLI: Open browser Note over Web: User completes OAuth Web->>DB: Resolve opaque token to signed payload - Web->>DB: Delete opaque token + Web->>DB: Mark opaque token consumed Web->>DB: Check fingerprint ownership Web->>DB: Create/update session loop Every 5s @@ -74,7 +74,7 @@ sequenceDiagram - Signed auth payloads expire after 1 hour - Browser login URLs use opaque 43-character tokens instead of exposing the signed auth payload -- Opaque browser tokens are stored in `verificationToken` under `cli-login:` and consumed with `DELETE ... RETURNING` when onboarding resolves them +- Opaque browser tokens are stored in `verificationToken` under `cli-login:` and atomically moved to `cli-login-consumed:` when onboarding resolves them; consumed markers scrub the signed auth payload from the `token` column - Fingerprint uniqueness: hardware info + 8 random bytes - Ownership conflicts blocked and logged - Sessions linked to fingerprint_id in database diff --git a/freebuff/web/src/app/api/auth/cli/code/route.ts b/freebuff/web/src/app/api/auth/cli/code/route.ts index 36ca660e4..734d5e4e0 100644 --- a/freebuff/web/src/app/api/auth/cli/code/route.ts +++ b/freebuff/web/src/app/api/auth/cli/code/route.ts @@ -11,6 +11,7 @@ import { z } from 'zod/v4' import { buildCliAuthCode, getCliAuthCodeHashPrefix, + getCliAuthCodeTokenIdentifier, } from '@/app/onboard/_helpers' import { logger } from '@/util/logger' @@ -69,7 +70,7 @@ export async function POST(req: Request) { const loginToken = randomBytes(32).toString('base64url') await db.insert(schema.verificationToken).values({ - identifier: `cli-login:${loginToken}`, + identifier: getCliAuthCodeTokenIdentifier(loginToken), token: authCode, expires: new Date(expiresAt), }) diff --git a/freebuff/web/src/app/onboard/__tests__/helpers.test.ts b/freebuff/web/src/app/onboard/__tests__/helpers.test.ts index a1b6462b5..812360443 100644 --- a/freebuff/web/src/app/onboard/__tests__/helpers.test.ts +++ b/freebuff/web/src/app/onboard/__tests__/helpers.test.ts @@ -4,6 +4,9 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { buildCliAuthCode, getCliAuthCodeHashPrefix, + getCliAuthCodeTokenIdentifier, + getConsumedCliAuthCodeTokenIdentifier, + getConsumedCliAuthCodeTokenValue, isAuthCodeExpired, isOpaqueCliAuthCodeToken, parseAuthCode, @@ -118,6 +121,16 @@ describe('freebuff onboard/_helpers', () => { ) }) + test('builds active and consumed token identifiers', () => { + expect(getCliAuthCodeTokenIdentifier('token-123')).toBe( + 'cli-login:token-123', + ) + expect(getConsumedCliAuthCodeTokenIdentifier('token-123')).toBe( + 'cli-login-consumed:034192845dc489deca291f9f5ae0bb8e5472c991020bf64b3ebc6dec5a1d7e47', + ) + expect(getConsumedCliAuthCodeTokenValue()).toBe('consumed') + }) + test('resolves an opaque browser token before validation', async () => { const expiresAt = '4102444800000' const fingerprintHash = genAuthCode( @@ -134,10 +147,11 @@ describe('freebuff onboard/_helpers', () => { const result = await resolveCliAuthCode(opaqueToken, async (token) => { expect(token).toBe(opaqueToken) - return signedAuthCode + return { status: 'resolved', authCode: signedAuthCode } }) expect(result).toEqual({ + status: 'ready', authCode: signedAuthCode, resolvedOpaqueToken: true, }) @@ -163,16 +177,47 @@ describe('freebuff onboard/_helpers', () => { const result = await resolveCliAuthCode(signedAuthCode, async () => { lookedUp = true - return null + return { status: 'missing' } }) expect(lookedUp).toBe(false) expect(result).toEqual({ + status: 'ready', authCode: signedAuthCode, resolvedOpaqueToken: false, }) }) + test('classifies reused opaque browser tokens as already consumed', async () => { + const opaqueToken = 'c'.repeat(43) + + const result = await resolveCliAuthCode(opaqueToken, async (token) => { + expect(token).toBe(opaqueToken) + return { status: 'already_consumed' } + }) + + expect(result).toEqual({ + status: 'already_consumed', + authCode: opaqueToken, + resolvedOpaqueToken: false, + }) + }) + + test('keeps never-issued opaque browser tokens invalid', async () => { + const opaqueToken = 'd'.repeat(43) + + const result = await resolveCliAuthCode(opaqueToken, async (token) => { + expect(token).toBe(opaqueToken) + return { status: 'missing' } + }) + + expect(result).toEqual({ + status: 'missing', + authCode: opaqueToken, + resolvedOpaqueToken: false, + }) + }) + test('resolves expired stored payloads so callers can show expired', async () => { const expiresAt = '0' const fingerprintHash = genAuthCode( @@ -186,10 +231,10 @@ describe('freebuff onboard/_helpers', () => { fingerprintHash, ) - const result = await resolveCliAuthCode( - 'b'.repeat(43), - async () => signedAuthCode, - ) + const result = await resolveCliAuthCode('b'.repeat(43), async () => ({ + status: 'resolved', + authCode: signedAuthCode, + })) const parsed = parseAuthCode(result.authCode) expect(isAuthCodeExpired(parsed.expiresAt)).toBe(true) diff --git a/freebuff/web/src/app/onboard/_db.ts b/freebuff/web/src/app/onboard/_db.ts index cf9724b16..50b0a9844 100644 --- a/freebuff/web/src/app/onboard/_db.ts +++ b/freebuff/web/src/app/onboard/_db.ts @@ -6,6 +6,13 @@ import { cookies } from 'next/headers' import { logger } from '@/util/logger' +import { + getCliAuthCodeTokenIdentifier, + getConsumedCliAuthCodeTokenIdentifier, + getConsumedCliAuthCodeTokenValue, + type CliAuthCodeTokenConsumeResult, +} from './_helpers' + type DbTransaction = Parameters[0] extends ( tx: infer T, ) => any @@ -34,15 +41,53 @@ export async function hasCliSessionForAuthHash( export async function consumeCliAuthCodeToken( authCodeToken: string, -): Promise { - const deleted = await db - .delete(schema.verificationToken) +): Promise { + const activeIdentifier = getCliAuthCodeTokenIdentifier(authCodeToken) + const consumedIdentifier = + getConsumedCliAuthCodeTokenIdentifier(authCodeToken) + const getConsumedTokenStatus = + async (): Promise => { + const existingConsumed = await db + .select({ id: schema.verificationToken.identifier }) + .from(schema.verificationToken) + .where(eq(schema.verificationToken.identifier, consumedIdentifier)) + .limit(1) + + return existingConsumed[0] + ? { status: 'already_consumed' } + : { status: 'missing' } + } + + const active = await db + .select({ authCode: schema.verificationToken.token }) + .from(schema.verificationToken) + .where(eq(schema.verificationToken.identifier, activeIdentifier)) + .limit(1) + const authCode = active[0]?.authCode + + if (!authCode) { + return getConsumedTokenStatus() + } + + const consumed = await db + .update(schema.verificationToken) + .set({ + identifier: consumedIdentifier, + token: getConsumedCliAuthCodeTokenValue(), + }) .where( - eq(schema.verificationToken.identifier, `cli-login:${authCodeToken}`), + and( + eq(schema.verificationToken.identifier, activeIdentifier), + eq(schema.verificationToken.token, authCode), + ), ) - .returning({ authCode: schema.verificationToken.token }) + .returning({ id: schema.verificationToken.identifier }) + + if (consumed[0]) { + return { status: 'resolved', authCode } + } - return deleted[0]?.authCode ?? null + return getConsumedTokenStatus() } export async function checkFingerprintConflict( diff --git a/freebuff/web/src/app/onboard/_helpers.ts b/freebuff/web/src/app/onboard/_helpers.ts index 54979932a..58d5204a5 100644 --- a/freebuff/web/src/app/onboard/_helpers.ts +++ b/freebuff/web/src/app/onboard/_helpers.ts @@ -3,6 +3,13 @@ import { createHash } from 'node:crypto' import { genAuthCode } from '@codebuff/common/util/credentials' const OPAQUE_CLI_AUTH_CODE_TOKEN_RE = /^[A-Za-z0-9_-]{43}$/ +const CLI_AUTH_CODE_TOKEN_IDENTIFIER_PREFIX = 'cli-login:' +const CONSUMED_CLI_AUTH_CODE_TOKEN_IDENTIFIER_PREFIX = 'cli-login-consumed:' +const CONSUMED_CLI_AUTH_CODE_TOKEN_VALUE = 'consumed' + +function getCliAuthCodeHash(authCode: string): string { + return createHash('sha256').update(authCode.trim()).digest('hex') +} export function buildCliAuthCode( fingerprintId: string, @@ -17,26 +24,83 @@ export function isOpaqueCliAuthCodeToken(authCode: string): boolean { } export function getCliAuthCodeHashPrefix(authCode: string): string { - return createHash('sha256').update(authCode.trim()).digest('hex').slice(0, 12) + return getCliAuthCodeHash(authCode).slice(0, 12) +} + +export function getCliAuthCodeTokenIdentifier(authCodeToken: string): string { + return `${CLI_AUTH_CODE_TOKEN_IDENTIFIER_PREFIX}${authCodeToken}` +} + +export function getConsumedCliAuthCodeTokenIdentifier( + authCodeToken: string, +): string { + return `${CONSUMED_CLI_AUTH_CODE_TOKEN_IDENTIFIER_PREFIX}${getCliAuthCodeHash( + authCodeToken, + )}` } +export function getConsumedCliAuthCodeTokenValue(): string { + return CONSUMED_CLI_AUTH_CODE_TOKEN_VALUE +} + +export type CliAuthCodeTokenConsumeResult = + | { status: 'resolved'; authCode: string } + | { status: 'already_consumed' } + | { status: 'missing' } + +export type CliAuthCodeResolution = + | { + status: 'ready' + authCode: string + resolvedOpaqueToken: boolean + } + | { + status: 'already_consumed' + authCode: string + resolvedOpaqueToken: false + } + | { + status: 'missing' + authCode: string + resolvedOpaqueToken: false + } + export async function resolveCliAuthCode( authCode: string, - consumeCliAuthCodeToken: (authCodeToken: string) => Promise, -): Promise<{ authCode: string; resolvedOpaqueToken: boolean }> { + consumeCliAuthCodeToken: ( + authCodeToken: string, + ) => Promise, +): Promise { const normalizedAuthCode = authCode.trim() if (!isOpaqueCliAuthCodeToken(normalizedAuthCode)) { - return { authCode: normalizedAuthCode, resolvedOpaqueToken: false } + return { + status: 'ready', + authCode: normalizedAuthCode, + resolvedOpaqueToken: false, + } } - const signedAuthCode = await consumeCliAuthCodeToken(normalizedAuthCode) - if (!signedAuthCode) { - return { authCode: normalizedAuthCode, resolvedOpaqueToken: false } + const tokenResult = await consumeCliAuthCodeToken(normalizedAuthCode) + if (tokenResult.status === 'resolved') { + return { + status: 'ready', + authCode: tokenResult.authCode, + resolvedOpaqueToken: true, + } + } + + if (tokenResult.status === 'already_consumed') { + return { + status: 'already_consumed', + authCode: normalizedAuthCode, + resolvedOpaqueToken: false, + } } return { - authCode: signedAuthCode, - resolvedOpaqueToken: true, + status: 'missing', + authCode: normalizedAuthCode, + resolvedOpaqueToken: false, } } diff --git a/freebuff/web/src/app/onboard/page.tsx b/freebuff/web/src/app/onboard/page.tsx index 63cb7c31d..74ba63ee9 100644 --- a/freebuff/web/src/app/onboard/page.tsx +++ b/freebuff/web/src/app/onboard/page.tsx @@ -99,8 +99,37 @@ const Onboard = async ({ searchParams }: PageProps) => { ) } - const { authCode: resolvedAuthCode, resolvedOpaqueToken } = - await resolveCliAuthCode(authCode, consumeCliAuthCodeToken) + const authCodeResolution = await resolveCliAuthCode( + authCode, + consumeCliAuthCodeToken, + ) + + if (authCodeResolution.status === 'already_consumed') { + logger.info( + { + authCodeLength: authCode.length, + authCodeTrimmedLength: authCode.trim().length, + authCodeHashPrefix: getCliAuthCodeHashPrefix(authCode), + isOpaqueAuthCodeToken: isOpaqueCliAuthCodeToken(authCode), + userId: user.id, + }, + 'Reused Freebuff CLI auth code token', + ) + + return ( + + ) + } + + const { + authCode: resolvedAuthCode, + resolvedOpaqueToken, + status: authCodeResolutionStatus, + } = authCodeResolution const { fingerprintId, expiresAt, receivedHash } = parseAuthCode(resolvedAuthCode) const { valid, expectedHash: fingerprintHash } = validateAuthCode( @@ -117,6 +146,7 @@ const Onboard = async ({ searchParams }: PageProps) => { authCodeTrimmedLength: authCode.trim().length, authCodeHashPrefix: getCliAuthCodeHashPrefix(authCode), isOpaqueAuthCodeToken: isOpaqueCliAuthCodeToken(authCode), + authCodeResolutionStatus, resolvedAuthCode: resolvedOpaqueToken, resolvedAuthCodeLength: resolvedAuthCode.length, userId: user.id, diff --git a/web/src/app/api/auth/cli/code/route.ts b/web/src/app/api/auth/cli/code/route.ts index a9a82a835..a677e9f09 100644 --- a/web/src/app/api/auth/cli/code/route.ts +++ b/web/src/app/api/auth/cli/code/route.ts @@ -11,6 +11,7 @@ import { z } from 'zod/v4' import { buildCliAuthCode, getCliAuthCodeHashPrefix, + getCliAuthCodeTokenIdentifier, } from '@/app/onboard/_helpers' import { logger } from '@/util/logger' @@ -71,7 +72,7 @@ export async function POST(req: Request) { const loginToken = randomBytes(32).toString('base64url') await db.insert(schema.verificationToken).values({ - identifier: `cli-login:${loginToken}`, + identifier: getCliAuthCodeTokenIdentifier(loginToken), token: authCode, expires: new Date(expiresAt), }) diff --git a/web/src/app/onboard/__tests__/helpers.test.ts b/web/src/app/onboard/__tests__/helpers.test.ts index 767bd4684..d3c0b4a9f 100644 --- a/web/src/app/onboard/__tests__/helpers.test.ts +++ b/web/src/app/onboard/__tests__/helpers.test.ts @@ -4,6 +4,9 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { buildCliAuthCode, getCliAuthCodeHashPrefix, + getCliAuthCodeTokenIdentifier, + getConsumedCliAuthCodeTokenIdentifier, + getConsumedCliAuthCodeTokenValue, isAuthCodeExpired, isOpaqueCliAuthCodeToken, parseAuthCode, @@ -246,6 +249,16 @@ describe('onboard/_helpers', () => { ) }) + test('builds active and consumed token identifiers', () => { + expect(getCliAuthCodeTokenIdentifier('token-123')).toBe( + 'cli-login:token-123', + ) + expect(getConsumedCliAuthCodeTokenIdentifier('token-123')).toBe( + 'cli-login-consumed:034192845dc489deca291f9f5ae0bb8e5472c991020bf64b3ebc6dec5a1d7e47', + ) + expect(getConsumedCliAuthCodeTokenValue()).toBe('consumed') + }) + test('resolves an opaque browser token before validation', async () => { const expiresAt = '4102444800000' const fingerprintHash = genAuthCode( @@ -262,10 +275,11 @@ describe('onboard/_helpers', () => { const result = await resolveCliAuthCode(opaqueToken, async (token) => { expect(token).toBe(opaqueToken) - return signedAuthCode + return { status: 'resolved', authCode: signedAuthCode } }) expect(result).toEqual({ + status: 'ready', authCode: signedAuthCode, resolvedOpaqueToken: true, }) @@ -291,16 +305,47 @@ describe('onboard/_helpers', () => { const result = await resolveCliAuthCode(signedAuthCode, async () => { lookedUp = true - return null + return { status: 'missing' } }) expect(lookedUp).toBe(false) expect(result).toEqual({ + status: 'ready', authCode: signedAuthCode, resolvedOpaqueToken: false, }) }) + test('classifies reused opaque browser tokens as already consumed', async () => { + const opaqueToken = 'c'.repeat(43) + + const result = await resolveCliAuthCode(opaqueToken, async (token) => { + expect(token).toBe(opaqueToken) + return { status: 'already_consumed' } + }) + + expect(result).toEqual({ + status: 'already_consumed', + authCode: opaqueToken, + resolvedOpaqueToken: false, + }) + }) + + test('keeps never-issued opaque browser tokens invalid', async () => { + const opaqueToken = 'd'.repeat(43) + + const result = await resolveCliAuthCode(opaqueToken, async (token) => { + expect(token).toBe(opaqueToken) + return { status: 'missing' } + }) + + expect(result).toEqual({ + status: 'missing', + authCode: opaqueToken, + resolvedOpaqueToken: false, + }) + }) + test('resolves expired stored payloads so callers can show expired', async () => { const expiresAt = '0' const fingerprintHash = genAuthCode( @@ -314,10 +359,10 @@ describe('onboard/_helpers', () => { fingerprintHash, ) - const result = await resolveCliAuthCode( - 'b'.repeat(43), - async () => signedAuthCode, - ) + const result = await resolveCliAuthCode('b'.repeat(43), async () => ({ + status: 'resolved', + authCode: signedAuthCode, + })) const parsed = parseAuthCode(result.authCode) expect(isAuthCodeExpired(parsed.expiresAt)).toBe(true) diff --git a/web/src/app/onboard/_db.ts b/web/src/app/onboard/_db.ts index cf9724b16..50b0a9844 100644 --- a/web/src/app/onboard/_db.ts +++ b/web/src/app/onboard/_db.ts @@ -6,6 +6,13 @@ import { cookies } from 'next/headers' import { logger } from '@/util/logger' +import { + getCliAuthCodeTokenIdentifier, + getConsumedCliAuthCodeTokenIdentifier, + getConsumedCliAuthCodeTokenValue, + type CliAuthCodeTokenConsumeResult, +} from './_helpers' + type DbTransaction = Parameters[0] extends ( tx: infer T, ) => any @@ -34,15 +41,53 @@ export async function hasCliSessionForAuthHash( export async function consumeCliAuthCodeToken( authCodeToken: string, -): Promise { - const deleted = await db - .delete(schema.verificationToken) +): Promise { + const activeIdentifier = getCliAuthCodeTokenIdentifier(authCodeToken) + const consumedIdentifier = + getConsumedCliAuthCodeTokenIdentifier(authCodeToken) + const getConsumedTokenStatus = + async (): Promise => { + const existingConsumed = await db + .select({ id: schema.verificationToken.identifier }) + .from(schema.verificationToken) + .where(eq(schema.verificationToken.identifier, consumedIdentifier)) + .limit(1) + + return existingConsumed[0] + ? { status: 'already_consumed' } + : { status: 'missing' } + } + + const active = await db + .select({ authCode: schema.verificationToken.token }) + .from(schema.verificationToken) + .where(eq(schema.verificationToken.identifier, activeIdentifier)) + .limit(1) + const authCode = active[0]?.authCode + + if (!authCode) { + return getConsumedTokenStatus() + } + + const consumed = await db + .update(schema.verificationToken) + .set({ + identifier: consumedIdentifier, + token: getConsumedCliAuthCodeTokenValue(), + }) .where( - eq(schema.verificationToken.identifier, `cli-login:${authCodeToken}`), + and( + eq(schema.verificationToken.identifier, activeIdentifier), + eq(schema.verificationToken.token, authCode), + ), ) - .returning({ authCode: schema.verificationToken.token }) + .returning({ id: schema.verificationToken.identifier }) + + if (consumed[0]) { + return { status: 'resolved', authCode } + } - return deleted[0]?.authCode ?? null + return getConsumedTokenStatus() } export async function checkFingerprintConflict( diff --git a/web/src/app/onboard/_helpers.ts b/web/src/app/onboard/_helpers.ts index 54979932a..58d5204a5 100644 --- a/web/src/app/onboard/_helpers.ts +++ b/web/src/app/onboard/_helpers.ts @@ -3,6 +3,13 @@ import { createHash } from 'node:crypto' import { genAuthCode } from '@codebuff/common/util/credentials' const OPAQUE_CLI_AUTH_CODE_TOKEN_RE = /^[A-Za-z0-9_-]{43}$/ +const CLI_AUTH_CODE_TOKEN_IDENTIFIER_PREFIX = 'cli-login:' +const CONSUMED_CLI_AUTH_CODE_TOKEN_IDENTIFIER_PREFIX = 'cli-login-consumed:' +const CONSUMED_CLI_AUTH_CODE_TOKEN_VALUE = 'consumed' + +function getCliAuthCodeHash(authCode: string): string { + return createHash('sha256').update(authCode.trim()).digest('hex') +} export function buildCliAuthCode( fingerprintId: string, @@ -17,26 +24,83 @@ export function isOpaqueCliAuthCodeToken(authCode: string): boolean { } export function getCliAuthCodeHashPrefix(authCode: string): string { - return createHash('sha256').update(authCode.trim()).digest('hex').slice(0, 12) + return getCliAuthCodeHash(authCode).slice(0, 12) +} + +export function getCliAuthCodeTokenIdentifier(authCodeToken: string): string { + return `${CLI_AUTH_CODE_TOKEN_IDENTIFIER_PREFIX}${authCodeToken}` +} + +export function getConsumedCliAuthCodeTokenIdentifier( + authCodeToken: string, +): string { + return `${CONSUMED_CLI_AUTH_CODE_TOKEN_IDENTIFIER_PREFIX}${getCliAuthCodeHash( + authCodeToken, + )}` } +export function getConsumedCliAuthCodeTokenValue(): string { + return CONSUMED_CLI_AUTH_CODE_TOKEN_VALUE +} + +export type CliAuthCodeTokenConsumeResult = + | { status: 'resolved'; authCode: string } + | { status: 'already_consumed' } + | { status: 'missing' } + +export type CliAuthCodeResolution = + | { + status: 'ready' + authCode: string + resolvedOpaqueToken: boolean + } + | { + status: 'already_consumed' + authCode: string + resolvedOpaqueToken: false + } + | { + status: 'missing' + authCode: string + resolvedOpaqueToken: false + } + export async function resolveCliAuthCode( authCode: string, - consumeCliAuthCodeToken: (authCodeToken: string) => Promise, -): Promise<{ authCode: string; resolvedOpaqueToken: boolean }> { + consumeCliAuthCodeToken: ( + authCodeToken: string, + ) => Promise, +): Promise { const normalizedAuthCode = authCode.trim() if (!isOpaqueCliAuthCodeToken(normalizedAuthCode)) { - return { authCode: normalizedAuthCode, resolvedOpaqueToken: false } + return { + status: 'ready', + authCode: normalizedAuthCode, + resolvedOpaqueToken: false, + } } - const signedAuthCode = await consumeCliAuthCodeToken(normalizedAuthCode) - if (!signedAuthCode) { - return { authCode: normalizedAuthCode, resolvedOpaqueToken: false } + const tokenResult = await consumeCliAuthCodeToken(normalizedAuthCode) + if (tokenResult.status === 'resolved') { + return { + status: 'ready', + authCode: tokenResult.authCode, + resolvedOpaqueToken: true, + } + } + + if (tokenResult.status === 'already_consumed') { + return { + status: 'already_consumed', + authCode: normalizedAuthCode, + resolvedOpaqueToken: false, + } } return { - authCode: signedAuthCode, - resolvedOpaqueToken: true, + status: 'missing', + authCode: normalizedAuthCode, + resolvedOpaqueToken: false, } } diff --git a/web/src/app/onboard/page.tsx b/web/src/app/onboard/page.tsx index d751222e0..d89ff7943 100644 --- a/web/src/app/onboard/page.tsx +++ b/web/src/app/onboard/page.tsx @@ -54,10 +54,22 @@ const Onboard = async ({ searchParams }: PageProps) => { ) } - const { authCode: resolvedAuthCode } = await resolveCliAuthCode( + const authCodeResolution = await resolveCliAuthCode( authCode, consumeCliAuthCodeToken, ) + + if (authCodeResolution.status === 'already_consumed') { + return ( + You can close this browser window.

} + /> + ) + } + + const { authCode: resolvedAuthCode } = authCodeResolution const { fingerprintId, expiresAt, receivedHash } = parseAuthCode(resolvedAuthCode) const { valid, expectedHash: fingerprintHash } = validateAuthCode(