From 0d53eca0a95132c51fcda60d92bd72357c6b7222 Mon Sep 17 00:00:00 2001 From: Bitcoin Error Log <18273620+BitcoinErrorLog@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:54:59 +0100 Subject: [PATCH 01/15] fix(auth): sign out and drop the keys of a grant session that cannot be saved A failed BrowserSessionStore save left the approved grant live on the homeserver and a partial record plus the flow key in IndexedDB. The session is now signed out, then every grant key is removed under the finalization lock, and the save error reaches the caller. --- src/core/controllers/auth/auth.test.ts | 21 +++++++++++++++++++++ src/core/controllers/auth/auth.ts | 17 ++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/core/controllers/auth/auth.test.ts b/src/core/controllers/auth/auth.test.ts index 8e3067659c..bfc2f591f1 100644 --- a/src/core/controllers/auth/auth.test.ts +++ b/src/core/controllers/auth/auth.test.ts @@ -2448,6 +2448,27 @@ describe('AuthController', () => { ); }); + it('a failed save signs the grant out, removes its keys and surfaces the failure', async () => { + const order: string[] = []; + const session = grantSession(); + const authStore = grantAuthStore(); + vi.spyOn(useAuthStore, 'getState').mockReturnValue(authStore); + const saveFailure = new Error('IndexedDB write failed'); + vi.spyOn(AuthApplication, 'saveGrantSession').mockRejectedValue(saveFailure); + vi.spyOn(AuthApplication, 'logout').mockImplementation(async () => { + order.push('signout'); + }); + vi.spyOn(AuthApplication, 'clearGrantSessions').mockImplementation(async () => { + order.push('clearAll'); + }); + + const approved = await approveGrantSignIn(session); + await expect(AuthController.initializeAuthenticatedSession({ session: approved })).rejects.toBe(saveFailure); + + expect(order).toEqual(['signout', 'clearAll']); + expect(authStore.init).not.toHaveBeenCalled(); + }); + it('save aborts after a sign-out since QR start', async () => { const session = grantSession(); const authStore = grantAuthStore(); diff --git a/src/core/controllers/auth/auth.ts b/src/core/controllers/auth/auth.ts index bfb2d1e3b3..35f6da1ca5 100644 --- a/src/core/controllers/auth/auth.ts +++ b/src/core/controllers/auth/auth.ts @@ -527,6 +527,7 @@ export class AuthController { const authStore = useAuthStore.getState(); let persistAborted = false; + let grantSaveError: unknown = null; try { this.cancelAllAuthFlows(); @@ -551,7 +552,12 @@ export class AuthController { if (epochAtStart === undefined || epochAtStart !== readAuthEpoch()) { return false; } - grantSessionRecordId = await AuthApplication.saveGrantSession(session); + try { + grantSessionRecordId = await AuthApplication.saveGrantSession(session); + } catch (error) { + grantSaveError = error; + return false; + } } authStore.init({ session, @@ -566,6 +572,15 @@ export class AuthController { await AuthApplication.logout({ session }).catch((logoutError) => { Logger.warn('Failed to sign out a session that lost the local-state race', { logoutError }); }); + if (grantSaveError !== null) { + // A failed save can leave a partial record and this flow's delegated + // key in IndexedDB. The grant is signed out above (that needs the + // key), so the key goes now. Sign-out uses the same order. + await withAuthFinalizationLock(() => AuthApplication.clearGrantSessions()).catch((clearError) => { + Logger.error('Grant keys left by a failed save could not be removed', { clearError }); + }); + throw grantSaveError; + } throw createCanceledError(); } From 2b3a7d0d74a1412fc1f7da3e377194e00b192f53 Mon Sep 17 00:00:00 2001 From: Bitcoin Error Log <18273620+BitcoinErrorLog@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:54:59 +0100 Subject: [PATCH 02/15] fix(auth): drop a pending #s= export after a grant session restore The grant leg returned before the discard that bounds the cookie leg, so a hand-off captured on the same load could survive into a later restore after the grant signed out. --- src/core/application/auth/auth.test.ts | 19 +++++++++++++++++++ src/core/application/auth/auth.ts | 3 +++ 2 files changed, 22 insertions(+) diff --git a/src/core/application/auth/auth.test.ts b/src/core/application/auth/auth.test.ts index f60ce8e23f..e1418b7fa1 100644 --- a/src/core/application/auth/auth.test.ts +++ b/src/core/application/auth/auth.test.ts @@ -292,6 +292,25 @@ describe('AuthApplication', () => { expect(cookieRestoreSpy).not.toHaveBeenCalled(); }); + it('grant restore drops a #s= export captured on the same load', async () => { + vi.spyOn(HomeserverService, 'restoreGrantSession').mockRejectedValue(createAuthError()); + vi.spyOn(HomeserverService, 'removeGrantSession').mockResolvedValue(undefined); + vibeSessionFragment.resetFragmentSessionExportCache(); + window.history.replaceState(null, '', '/marketplace#s=handoff-export'); + try { + vibeSessionFragment.consumeFragmentSessionExport(); + expect(vibeSessionFragment.hasPendingFragmentSessionExport()).toBe(true); + + await AuthApplication.restorePersistedSession({ authStore: grantStore() }); + + expect(vibeSessionFragment.hasPendingFragmentSessionExport()).toBe(false); + expect(vibeSessionFragment.takeFragmentSessionExport()).toBeNull(); + } finally { + vibeSessionFragment.resetFragmentSessionExportCache(); + window.history.replaceState(null, '', '/'); + } + }); + it('restore never calls save', async () => { vi.spyOn(HomeserverService, 'restoreGrantSession').mockResolvedValue(grantSession()); vi.spyOn(HomeserverService, 'assertUserHomeserverAllowed').mockResolvedValue(undefined); diff --git a/src/core/application/auth/auth.ts b/src/core/application/auth/auth.ts index e1a603e4a2..450c9579c7 100644 --- a/src/core/application/auth/auth.ts +++ b/src/core/application/auth/auth.ts @@ -95,6 +95,9 @@ export class AuthApplication { try { return await this.restoreGrantSession(grantRecordId); } finally { + // Same bound as the cookie leg: a `#s=` captured on this load must + // not survive into a later restore after this one signs out. + discardFragmentSessionExport(); this.restoreSessionPromise = null; } })(); From 7d8e29aeb4804a1072f465c118129aa7ae170193 Mon Sep 17 00:00:00 2001 From: Bitcoin Error Log <18273620+BitcoinErrorLog@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:55:00 +0100 Subject: [PATCH 03/15] fix(auth): keep the authorization URL out of the QR slot markup The URL carries the relay channel secret. It is drawn into the QR only; the data-auth-url attribute is gone, so a CSS attribute selector cannot read it. The launch-critical suite reads the URL through the copy button instead. --- .../QrCodeSlot/QrCodeSlot.dom.test.tsx | 29 +++++++++++++++++++ .../molecules/QrCodeSlot/QrCodeSlot.test.tsx | 3 +- .../QrCodeSlot/QrCodeSlot.test.tsx.snap | 3 -- .../molecules/QrCodeSlot/QrCodeSlot.tsx | 4 ++- .../organisms/Scan/Scan.test.tsx.snap | 1 - .../organisms/SignIn/SignIn.test.tsx.snap | 1 - 6 files changed, 34 insertions(+), 7 deletions(-) create mode 100644 src/components/molecules/QrCodeSlot/QrCodeSlot.dom.test.tsx diff --git a/src/components/molecules/QrCodeSlot/QrCodeSlot.dom.test.tsx b/src/components/molecules/QrCodeSlot/QrCodeSlot.dom.test.tsx new file mode 100644 index 0000000000..bf9880f0fa --- /dev/null +++ b/src/components/molecules/QrCodeSlot/QrCodeSlot.dom.test.tsx @@ -0,0 +1,29 @@ +import { render } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { QrCodeSlot } from './QrCodeSlot'; + +vi.mock('next/image', () => ({ + __esModule: true, + default: ({ src, alt }: { src: string; alt: string }) => {alt}, +})); + +const RELAY_SECRET = 'c2VjcmV0LWNoYW5uZWwta2V5LWZvci10aGlzLWZsb3c'; +const AUTH_URL = `pubkyauth://signin_grant?caps=%2Fpub%2Fpubky.app%2F%3Arw&relay=https%3A%2F%2Frelay.example%2Finbox&secret=${RELAY_SECRET}&cid=shop.pubky.app`; + +describe('QrCodeSlot with the real QR renderer', () => { + it('keeps the relay secret out of the DOM markup', () => { + const { container } = render( + , + ); + + expect(container.querySelector('svg')).not.toBeNull(); + expect(container.innerHTML).not.toContain(RELAY_SECRET); + expect(container.innerHTML).not.toContain('pubkyauth://'); + }); +}); diff --git a/src/components/molecules/QrCodeSlot/QrCodeSlot.test.tsx b/src/components/molecules/QrCodeSlot/QrCodeSlot.test.tsx index 0dfd5f8527..f2fc279d98 100644 --- a/src/components/molecules/QrCodeSlot/QrCodeSlot.test.tsx +++ b/src/components/molecules/QrCodeSlot/QrCodeSlot.test.tsx @@ -77,7 +77,8 @@ describe('QrCodeSlot', () => { const qr = screen.getByTestId('qrcode-svg'); expect(qr).toHaveAttribute('data-value', 'auth-url'); expect(qr).toHaveAttribute('width', '176'); - expect(screen.getByTestId('qr-auth-url')).toHaveAttribute('data-auth-url', 'auth-url'); + const slotAttributeValues = Array.from(screen.getByTestId('qr-auth-url').attributes, (attribute) => attribute.value); + expect(slotAttributeValues).not.toContain('auth-url'); const ringLogo = screen.getByAltText('Pubky Ring'); expect(ringLogo).toHaveAttribute('src', '/images/ring-logo.svg'); diff --git a/src/components/molecules/QrCodeSlot/QrCodeSlot.test.tsx.snap b/src/components/molecules/QrCodeSlot/QrCodeSlot.test.tsx.snap index 1e7fc491aa..27fd1b036f 100644 --- a/src/components/molecules/QrCodeSlot/QrCodeSlot.test.tsx.snap +++ b/src/components/molecules/QrCodeSlot/QrCodeSlot.test.tsx.snap @@ -4,7 +4,6 @@ exports[`QrCodeSlot - Snapshots > matches snapshot for a non-default size 1`] =
matches snapshot for active QR with hover effe
matches snapshot for active QR without hover e
+ {showRingLogo && ( ScanContent - Snapshots > matches snapsho > matches snapshot for the QR sign-in layout > Date: Thu, 24 Sep 2026 12:55:00 +0100 Subject: [PATCH 04/15] fix(marketplace): end a bootstrap flow whose claim lease lapsed A claimer that died between acquire and complete left the flow in claiming, and every poll answered claim_in_progress until the cleanup job ran. A poll now abandons a claim whose lease lapsed (database clock) and asks for a fresh approval. --- .../marketplace-grant/browser-bff.test.ts | 28 +++++++++++++++++++ src/server/marketplace-grant/browser-bff.ts | 8 +++++- src/server/marketplace-grant/db.ts | 14 ++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/server/marketplace-grant/browser-bff.test.ts b/src/server/marketplace-grant/browser-bff.test.ts index fca26a3ab1..6e3d7ac894 100644 --- a/src/server/marketplace-grant/browser-bff.test.ts +++ b/src/server/marketplace-grant/browser-bff.test.ts @@ -32,6 +32,7 @@ const getCliFlow = vi.fn(); const acquireCliClaim = vi.fn(); const completeCliClaim = vi.fn(); const abandonCliClaim = vi.fn(); +const abandonLapsedCliClaim = vi.fn(); const renewCliClaim = vi.fn(); const assertCliGrantSchema = vi.fn(); const fetchHomeserverProofDocument = vi.fn(); @@ -56,6 +57,7 @@ vi.mock('./db', async (importOriginal) => { acquireCliClaim: (...args: unknown[]) => acquireCliClaim(...args), completeCliClaim: (...args: unknown[]) => completeCliClaim(...args), abandonCliClaim: (...args: unknown[]) => abandonCliClaim(...args), + abandonLapsedCliClaim: (...args: unknown[]) => abandonLapsedCliClaim(...args), renewCliClaim: (...args: unknown[]) => renewCliClaim(...args), }; }); @@ -769,6 +771,32 @@ describe('browser purchase bootstrap BFF', () => { expect(completeCliClaim).not.toHaveBeenCalled(); }); + it('a claim whose lease lapsed ends the flow and asks for a fresh approval', async () => { + const config = await browserConfig(); + const { bound, row, stateId } = browserFlowRow(config, { status: 'claiming' }); + getCliFlow.mockResolvedValue({ ...row, lease_owner: randomUUID(), lease_until: new Date(Date.now() - 1_000) }); + abandonLapsedCliClaim.mockResolvedValue(true); + const { pollBrowserFlow } = await import('./browser-bff'); + + await expect(pollBrowserFlow(flowRequest(stateId), bound.value, stateId)).rejects.toEqual( + new BffError(422, 'fresh_approval_required'), + ); + expect(abandonLapsedCliClaim).toHaveBeenCalledWith(expect.anything(), stateId); + expect(getGrantStatus).not.toHaveBeenCalled(); + }); + + it('a claim with a live lease stays in progress', async () => { + const config = await browserConfig(); + const { bound, row, stateId } = browserFlowRow(config, { status: 'claiming' }); + getCliFlow.mockResolvedValue({ ...row, lease_owner: randomUUID(), lease_until: new Date(Date.now() + 30_000) }); + abandonLapsedCliClaim.mockResolvedValue(false); + const { pollBrowserFlow } = await import('./browser-bff'); + + await expect(pollBrowserFlow(flowRequest(stateId), bound.value, stateId)).rejects.toEqual( + new BffError(409, 'claim_in_progress'), + ); + }); + // A9 it('no migration added by the browser bootstrap', () => { const migrations = readdirSync(path.resolve(process.cwd(), 'db/bff')) diff --git a/src/server/marketplace-grant/browser-bff.ts b/src/server/marketplace-grant/browser-bff.ts index 9251043e00..baec9977b6 100644 --- a/src/server/marketplace-grant/browser-bff.ts +++ b/src/server/marketplace-grant/browser-bff.ts @@ -20,6 +20,7 @@ import { } from './crypto'; import { abandonCliClaim, + abandonLapsedCliClaim, acquireCliClaim, assertCliGrantSchema, bindCliFlow, @@ -327,7 +328,12 @@ export async function pollBrowserFlow( if (flow.status === 'expired') throw new BffError(410, 'flow_expired'); if (flow.status === 'cancelled') throw new BffError(410, 'flow_cancelled'); if (flow.status === 'mismatch') return { state_id: flow.state_id, status: 'mismatch' }; - if (flow.status === 'claiming') throw new BffError(409, 'claim_in_progress'); + if (flow.status === 'claiming') { + // A claimer that died mid-claim leaves the row here until cleanup; once + // its lease lapses the flow ends and the browser starts a fresh approval. + if (await abandonLapsedCliClaim(config, flow.state_id)) throw new BffError(422, 'fresh_approval_required'); + throw new BffError(409, 'claim_in_progress'); + } if (flow.status !== 'awaiting' || !flow.flow_id) { throw new BffError(422, 'fresh_approval_required'); } diff --git a/src/server/marketplace-grant/db.ts b/src/server/marketplace-grant/db.ts index 7e002180b5..808d350571 100644 --- a/src/server/marketplace-grant/db.ts +++ b/src/server/marketplace-grant/db.ts @@ -532,6 +532,20 @@ export async function abandonCliClaim(config: MarketplaceGrantConfig, stateId: s `; } +/** + * Abandons a claim whose lease lapsed (its claimer crashed or timed out). + * The database clock decides the lapse, the same rule as the cleanup job. + */ +export async function abandonLapsedCliClaim(config: MarketplaceGrantConfig, stateId: string): Promise { + const result = await grantSql(config)` + UPDATE shop_grant_bff.cli_flow_state + SET status = 'abandoned', context_sealed = NULL, result_token_sealed = NULL, terminal_at = now(), + lease_owner = NULL, lease_until = NULL, version = version + 1 + WHERE state_id = ${stateId} AND status = 'claiming' AND lease_until <= now() + `; + return result.count === 1; +} + export async function consumeCliRateLimit( config: MarketplaceGrantConfig, bucketKey: string, From 15af2e8781465325c2d8cded84c473067837e73a Mon Sep 17 00:00:00 2001 From: Bitcoin Error Log <18273620+BitcoinErrorLog@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:55:01 +0100 Subject: [PATCH 05/15] fix(marketplace): terminalize a cancelled bootstrap flow before opening its context A context that could not be opened left the cancelled flow awaiting. The flow is now terminal first, so no later poll can claim it. --- .../marketplace-grant/browser-bff.test.ts | 65 +++++++++++++++++++ src/server/marketplace-grant/browser-bff.ts | 6 +- 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/server/marketplace-grant/browser-bff.test.ts b/src/server/marketplace-grant/browser-bff.test.ts index 6e3d7ac894..623137337f 100644 --- a/src/server/marketplace-grant/browser-bff.test.ts +++ b/src/server/marketplace-grant/browser-bff.test.ts @@ -797,6 +797,71 @@ describe('browser purchase bootstrap BFF', () => { ); }); + it('cancel ends the flow even when its context cannot be opened', async () => { + const config = await browserConfig(); + const stateId = randomUUID(); + const bound = makeBoundCookie(stateId); + const cliContext = sealCliFlowContext(config, stateId, pubky, { + resultDeliveryId: encodeBase64Url(new Uint8Array(32).fill(4)), + version: 1, + }); + const { row } = browserFlowRow(config, { + tokenHash: hashBoundCookie(config, 1, 'flow', stateId, bound.secret), + contextSealed: cliContext, + }); + getCliFlow.mockResolvedValue({ ...row, state_id: stateId }); + const { cancelBrowserFlow } = await import('./browser-bff'); + + await expect( + cancelBrowserFlow( + jsonRequest(`${ORIGIN}/api/marketplace/bootstrap-flows/${stateId}/cancel`, {}), + bound.value, + stateId, + ), + ).rejects.toEqual(new BffError(403, 'result_denied')); + expect(terminalizeCliFlow).toHaveBeenCalledWith(expect.anything(), stateId, 'cancelled'); + expect(cancelGrant).not.toHaveBeenCalled(); + }); + + it('a flow whose key epoch rotated out can be neither cancelled nor claimed', async () => { + const epoch1 = await browserConfig(); + const { bound, row, stateId } = browserFlowRow(epoch1); + rotateTo(3, STATE_KEY_3, { epoch: 2, key: STATE_KEY_2 }); + getCliFlow.mockResolvedValue(row); + acquireCliClaim.mockResolvedValue(row); + completeServiceFlow(row.flow_id); + const { cancelBrowserFlow, pollBrowserFlow } = await import('./browser-bff'); + const freshApproval = new BffError(422, 'fresh_approval_required'); + + await expect( + cancelBrowserFlow( + jsonRequest(`${ORIGIN}/api/marketplace/bootstrap-flows/${stateId}/cancel`, {}), + bound.value, + stateId, + ), + ).rejects.toEqual(freshApproval); + await expect(pollBrowserFlow(flowRequest(stateId), bound.value, stateId)).rejects.toEqual(freshApproval); + expect(acquireCliClaim).not.toHaveBeenCalled(); + expect(claimGrantResult).not.toHaveBeenCalled(); + }); + + it('cancel ends the flow locally and cancels it at the service', async () => { + const config = await browserConfig(); + const { bound, derived, row, stateId } = browserFlowRow(config); + getCliFlow.mockResolvedValue(row); + cancelGrant.mockResolvedValue(undefined); + const { cancelBrowserFlow } = await import('./browser-bff'); + + await cancelBrowserFlow( + jsonRequest(`${ORIGIN}/api/marketplace/bootstrap-flows/${stateId}/cancel`, {}), + bound.value, + stateId, + ); + + expect(terminalizeCliFlow).toHaveBeenCalledWith(expect.anything(), stateId, 'cancelled'); + expect(cancelGrant).toHaveBeenCalledWith(expect.anything(), row.flow_id, encodeBase64Url(derived.resultDeliveryId)); + }); + // A9 it('no migration added by the browser bootstrap', () => { const migrations = readdirSync(path.resolve(process.cwd(), 'db/bff')) diff --git a/src/server/marketplace-grant/browser-bff.ts b/src/server/marketplace-grant/browser-bff.ts index baec9977b6..f08b9f0c56 100644 --- a/src/server/marketplace-grant/browser-bff.ts +++ b/src/server/marketplace-grant/browser-bff.ts @@ -396,9 +396,13 @@ export async function cancelBrowserFlow( const { config, flow } = await authenticatedBrowserFlow(request, flowCookie, stateIdParam); await parseStrictJson(request, emptyBody); if (flow.status !== 'awaiting' && flow.status !== 'creating') throw new BffError(403, 'result_denied'); + // Terminal first, so a context that cannot be opened still ends the flow + // and no later poll can claim it. The service flow then expires on its own. + // A flow whose key epoch left the config never reaches this line: its + // cookie cannot be checked, and it cannot be claimed either. + await terminalizeCliFlow(config, flow.state_id, 'cancelled'); if (flow.flow_id && flow.context_sealed) { const context = openContext(config, flow); await cancelGrant(config, flow.flow_id, context.resultDeliveryId); } - await terminalizeCliFlow(config, flow.state_id, 'cancelled'); } From 6a60b15fc3fe29463fe3115d59c5e9a5fce54ca7 Mon Sep 17 00:00:00 2001 From: Bitcoin Error Log <18273620+BitcoinErrorLog@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:55:01 +0100 Subject: [PATCH 06/15] fix(marketplace): key the challenge pubky bucket by client IP Challenge creation is unauthenticated and names any pubky, so a bucket keyed by pubky alone let a third party spend the owner's bucket. Browser and CLI challenges now key it by IP and pubky. --- .../marketplace-grant/browser-bff.test.ts | 21 +++++++++++++++++++ src/server/marketplace-grant/browser-bff.ts | 11 ++++------ src/server/marketplace-grant/cli-bff.test.ts | 21 +++++++++++++++++++ src/server/marketplace-grant/cli-bff.ts | 19 +++++++++++------ 4 files changed, 59 insertions(+), 13 deletions(-) diff --git a/src/server/marketplace-grant/browser-bff.test.ts b/src/server/marketplace-grant/browser-bff.test.ts index 623137337f..36a44e3d97 100644 --- a/src/server/marketplace-grant/browser-bff.test.ts +++ b/src/server/marketplace-grant/browser-bff.test.ts @@ -862,6 +862,27 @@ describe('browser purchase bootstrap BFF', () => { expect(cancelGrant).toHaveBeenCalledWith(expect.anything(), row.flow_id, encodeBase64Url(derived.resultDeliveryId)); }); + it("challenges for a pubky from another client do not spend its owner's bucket", async () => { + storeInsertedChallenges(); + process.env.VERCEL = '1'; + resetMarketplaceGrantConfigForTests(); + const { createBrowserChallenge } = await import('./browser-bff'); + const buckets = new Map(); + consumeCliRateLimit.mockImplementation(async (_config: unknown, key: string, limit: number) => { + const count = (buckets.get(key) ?? 0) + 1; + buckets.set(key, count); + return count <= limit; + }); + const config = await browserConfig(); + const from = (ip: string) => challengeRequest({ pubky }, { 'x-vercel-forwarded-for': ip }); + + for (let i = 0; i < config.createPerPubkyPerMinute; i += 1) { + await createBrowserChallenge(from('203.0.113.66')); + } + await expect(createBrowserChallenge(from('203.0.113.66'))).rejects.toEqual(new BffError(429, 'retry_later', 60)); + await expect(createBrowserChallenge(from('198.51.100.7'))).resolves.toMatchObject({ proof_uri: expect.any(String) }); + }); + // A9 it('no migration added by the browser bootstrap', () => { const migrations = readdirSync(path.resolve(process.cwd(), 'db/bff')) diff --git a/src/server/marketplace-grant/browser-bff.ts b/src/server/marketplace-grant/browser-bff.ts index f08b9f0c56..cd282203d7 100644 --- a/src/server/marketplace-grant/browser-bff.ts +++ b/src/server/marketplace-grant/browser-bff.ts @@ -1,7 +1,7 @@ import { randomBytes, randomUUID } from 'node:crypto'; import { z } from 'zod'; import { assertSameOrigin, BffError, FLOW_COOKIE, parseStrictJson } from './bff'; -import { canonicalZ32, clientIp, hashesEqual, requireUuid } from './cli-bff'; +import { canonicalZ32, challengePubkyBucket, clientIp, hashesEqual, requireUuid } from './cli-bff'; import type { CliGrantConfig } from './config'; import { getBrowserBootstrapConfig } from './config'; import { @@ -97,12 +97,9 @@ export async function createBrowserChallenge(request: Request): Promise { resetMarketplaceGrantConfigForTests(); }); + it("challenges for a pubky from another client do not spend its owner's bucket", async () => { + process.env.VERCEL = '1'; + resetMarketplaceGrantConfigForTests(); + const { getCliGrantConfig } = await import('./config'); + const { createCliChallenge } = await import('./cli-bff'); + const buckets = new Map(); + consumeCliRateLimit.mockImplementation(async (_config: unknown, key: string, limit: number) => { + const count = (buckets.get(key) ?? 0) + 1; + buckets.set(key, count); + return count <= limit; + }); + const body = { pubky, result_cpk: pubky, result_delivery_id: deliveryId }; + const from = (ip: string) => jsonRequest(body, { 'x-vercel-forwarded-for': ip }); + + for (let i = 0; i < getCliGrantConfig()!.createPerPubkyPerMinute; i += 1) { + await createCliChallenge(from('203.0.113.66')); + } + await expect(createCliChallenge(from('203.0.113.66'))).rejects.toEqual(new BffError(429, 'retry_later', 60)); + await expect(createCliChallenge(from('198.51.100.7'))).resolves.toMatchObject({ proof_uri: expect.any(String) }); + }); + it('returns 404 grant_unavailable when the CLI flag is off', async () => { process.env.SHOP_BFF_CLI_GRANT_ENABLED = 'false'; resetMarketplaceGrantConfigForTests(); diff --git a/src/server/marketplace-grant/cli-bff.ts b/src/server/marketplace-grant/cli-bff.ts index 3c5a807d0a..8870c11e08 100644 --- a/src/server/marketplace-grant/cli-bff.ts +++ b/src/server/marketplace-grant/cli-bff.ts @@ -130,6 +130,16 @@ export function clientIp(request: Request, trustedProxyCount: number): string { return xffHopBehindTrustedProxies(request.headers.get('x-forwarded-for'), trustedProxyCount) || '0.0.0.0'; } +/** + * Challenge creation is unauthenticated and names any pubky, so a bucket + * keyed by pubky alone lets a third party exhaust the owner's bucket. Keyed + * by client IP and pubky, a flood from elsewhere never reaches the owner's + * bucket; the per-IP bucket still bounds total creation. + */ +export function challengePubkyBucket(prefix: string, ip: string, pubky: string): string { + return `${prefix}:${ip}:${pubky}`; +} + function tokenBucketKey(prefix: string, digest: Uint8Array): string { return `${prefix}:${Buffer.from(digest).toString('hex')}`; } @@ -177,12 +187,9 @@ export async function createCliChallenge(request: Request): Promise<{ } catch { throw new BffError(400, 'invalid_request'); } - await rateLimit( - config, - `cli_challenge_ip:${clientIp(request, config.trustedProxyCount)}`, - config.createPerIpPerMinute, - ); - await rateLimit(config, `cli_challenge_pubky:${pubky}`, config.createPerPubkyPerMinute); + const ip = clientIp(request, config.trustedProxyCount); + await rateLimit(config, `cli_challenge_ip:${ip}`, config.createPerIpPerMinute); + await rateLimit(config, challengePubkyBucket('cli_challenge_pubky', ip, pubky), config.createPerPubkyPerMinute); const challengeId = randomUUID(); const nonce = Uint8Array.from(randomBytes(32)); const expiresAt = new Date(Date.now() + config.challengeTtlSeconds * 1000); From a3833566f0c96cad569ab10927909d1696d09eae Mon Sep 17 00:00:00 2001 From: Bitcoin Error Log <18273620+BitcoinErrorLog@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:55:02 +0100 Subject: [PATCH 07/15] fix(auth): hold every grant session to the full Shop grant and drop its step-up refusal A grant approval or stored grant narrower than CAPABILITIES is signed out and refused, so a grant session never reaches the capability-based needs_reauth state. A 401/403 on the private document of a grant session no longer reports needs_reauth: a step-up widens scope and cannot repair a refused grant. MarketplaceReauthDialog loses its unreachable grant branch; R3.9a is not built, and step-up-approval.md records why. --- docs/ecommerce/step-up-approval.md | 8 ++++++ .../MarketplaceReauthDialog.test.tsx | 15 +++-------- .../Marketplace/MarketplaceReauthDialog.tsx | 11 +++----- src/core/application/auth/auth.test.ts | 18 +++++++++++-- src/core/application/auth/auth.ts | 27 ++++++++++++++++++- .../commerce/commerce.receipts.test.ts | 10 +++++++ src/core/application/commerce/commerce.ts | 11 ++++++-- .../commerce/commerce.watchlist.test.ts | 11 ++++++++ src/core/controllers/auth/auth.test.ts | 21 +++++++++++++-- src/core/controllers/auth/auth.ts | 1 + src/core/services/homeserver/homeserver.ts | 4 +++ 11 files changed, 111 insertions(+), 26 deletions(-) diff --git a/docs/ecommerce/step-up-approval.md b/docs/ecommerce/step-up-approval.md index 3b0d3238eb..f64f0f5ae4 100644 --- a/docs/ecommerce/step-up-approval.md +++ b/docs/ecommerce/step-up-approval.md @@ -103,6 +103,14 @@ Approvals per payment method and feature under Option C: - Manual, blocks launch: test what Pubky Ring displays for an empty-capabilities pubkyauth request (`caps=''`); if it does not visibly distinguish empty from wide, file a Ring issue before launch (the QR/phish-swap row's "low-value empty-caps prompt" reasoning depends on this). - marketplace-service: `create_session` accepts an empty-capabilities token; posting identical bytes twice returns 401 on the second call (integration-level replay test — current `auth.rs:269–374` tests cover verification, not the single-use INSERT path). +## Grant (Bitkit) sessions need no step-up + +A Bitkit sign-in (`pubkyauth://signin_grant`) requests exactly `CAPABILITIES`, and the Shop refuses anything else: `AuthApplication.assertFullGrantSession` signs out and rejects an approved grant session whose `info.capabilities` do not match `capabilitiesMatchFullGrant`, and a stored grant session that restores narrower is signed out and its record removed. Every live grant session therefore already holds `/priv/pubky.app/:rw`, so `canCurrentSessionWrite(PRIVATE_APP_DATA_PATH)` is true and the capability-based `needs_reauth` state cannot occur for it. + +The other `needs_reauth` trigger is a 401/403 on the private document. For a grant session with the full grant, that refusal means the grant itself is no longer honored (revoked in Bitkit, or expired). A step-up approval widens scope; it cannot repair a refused grant. `CommerceApplication.isPrivateAccessDenied` therefore does not report `needs_reauth` for a grant session: watchlist sync reports `error` (the outbox job stays pending) and receipt publication reports `unavailable`, both retried on the next load. + +`MarketplaceReauthDialog` renders only in the `needs_reauth` state, so it never opens for a grant session and has no grant branch. A delegated-grant step-up QR (contract row R3.9a) is not built: no state reaches it. + ## Verification that differed from the brief 1. **`signinWithAuthToken` does not exist.** `pubky.d.ts` has no AuthToken→Session API (only `AuthFlow.awaitApproval` line 188, `awaitToken` line 198, `Pubky.restoreSession` line 831, `Signer.signin` line 1294). Option A's "same bytes sign in to the homeserver" requires re-implementing protocol internals, not an SDK call. diff --git a/src/components/organisms/Marketplace/MarketplaceReauthDialog.test.tsx b/src/components/organisms/Marketplace/MarketplaceReauthDialog.test.tsx index 9b63005d41..c756ab2bc2 100644 --- a/src/components/organisms/Marketplace/MarketplaceReauthDialog.test.tsx +++ b/src/components/organisms/Marketplace/MarketplaceReauthDialog.test.tsx @@ -20,26 +20,19 @@ vi.mock('@/hooks/useStepUpReauth/useStepUpReauth', () => ({ useStepUpReauth: () => reauth, })); -const grant = vi.hoisted(() => ({ isGrantSession: false })); -vi.mock('@/hooks/useIsGrantSession/useIsGrantSession', () => ({ - useIsGrantSession: () => grant.isGrantSession, -})); - describe('MarketplaceReauthDialog', () => { beforeEach(() => { - grant.isGrantSession = false; vi.mocked(reauth.start).mockClear(); }); - it('grant session sees refusal not classic qr (step-up)', async () => { - grant.isGrantSession = true; + it('opening starts a fresh step-up flow and shows its QR', async () => { render(); await userEvent.setup().click(screen.getByRole('button', { name: 'Sign in again' })); - expect(screen.getByTestId('grant-session-refusal')).toBeInTheDocument(); - expect(screen.queryByLabelText('Copy authorization link')).not.toBeInTheDocument(); - expect(reauth.start).not.toHaveBeenCalled(); + expect(reauth.start).toHaveBeenCalledOnce(); + expect(screen.getByLabelText('Copy authorization link')).toBeInTheDocument(); + expect(screen.queryByTestId('grant-session-refusal')).not.toBeInTheDocument(); }); it('asks for a sign-in in product language and does not print capability paths', async () => { diff --git a/src/components/organisms/Marketplace/MarketplaceReauthDialog.tsx b/src/components/organisms/Marketplace/MarketplaceReauthDialog.tsx index 0a4c151298..7148605e8e 100644 --- a/src/components/organisms/Marketplace/MarketplaceReauthDialog.tsx +++ b/src/components/organisms/Marketplace/MarketplaceReauthDialog.tsx @@ -5,10 +5,8 @@ import { Copy, KeyRound, Loader2, RefreshCw, Smartphone } from 'lucide-react'; import { Button } from '@/atoms/Button/Button'; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/atoms/Dialog/Dialog'; import { Typography } from '@/atoms/Typography/Typography'; -import { useIsGrantSession } from '@/hooks/useIsGrantSession/useIsGrantSession'; import { useStepUpReauth } from '@/hooks/useStepUpReauth/useStepUpReauth'; import { Logger } from '@/libs/logger/logger'; -import { GrantSessionRefusal } from '@/molecules/GrantSessionRefusal/GrantSessionRefusal'; import { QrCodeSlot } from '@/molecules/QrCodeSlot/QrCodeSlot'; import { toast } from '@/molecules/Toaster/use-toast'; @@ -47,14 +45,13 @@ export function MarketplaceReauthDialog({ // Referencing `reauth.start`/`reauth.cancel` directly keeps the effect // dependency-stable: both are useCallback-memoized in the hook. const { start, cancel } = reauth; - const isGrantSession = useIsGrantSession(); useEffect(() => { if (open) { - if (!isGrantSession) start(); + start(); return; } cancel(); - }, [open, start, cancel, isGrantSession]); + }, [open, start, cancel]); const copyUrl = async () => { try { @@ -83,9 +80,7 @@ export function MarketplaceReauthDialog({ Sign in again for this device. - {isGrantSession ? ( - - ) : reauth.status === 'error' ? ( + {reauth.status === 'error' ? (
{reauth.errorMessage} diff --git a/src/core/application/auth/auth.test.ts b/src/core/application/auth/auth.test.ts index e1418b7fa1..f3e15b7ba3 100644 --- a/src/core/application/auth/auth.test.ts +++ b/src/core/application/auth/auth.test.ts @@ -2,6 +2,7 @@ import type { AuthToken, Keypair, Session } from '@synonymdev/pubky'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { AuthApplication, isDefinitiveSessionAuthFailure } from '@/application/auth/auth'; import type { THomeserverAuthenticateParams } from '@/application/auth/auth.types'; +import { CAPABILITIES } from '@/config/app'; import { getHomeserver, isStagingHomeserverDeploy } from '@/config/network'; import { AppError } from '@/libs/error/error'; import { AuthErrorCode, ClientErrorCode, NetworkErrorCode, ServerErrorCode } from '@/libs/error/error.codes'; @@ -276,8 +277,8 @@ describe('AuthApplication', () => { setIsRestoringSession: vi.fn(), init: vi.fn(), }); - const grantSession = () => - asOpaque({ grant: {}, info: { publicKey: asOpaque({ z32: () => 'user-pubky' }) } }); + const grantSession = (capabilities: string[] = CAPABILITIES.split(',')) => + asOpaque({ grant: {}, info: { publicKey: asOpaque({ z32: () => 'user-pubky' }), capabilities } }); it('reload restores grant session from store', async () => { const session = grantSession(); @@ -292,6 +293,19 @@ describe('AuthApplication', () => { expect(cookieRestoreSpy).not.toHaveBeenCalled(); }); + it('a stored grant narrower than the Shop grant is signed out and its record removed', async () => { + const session = grantSession(['/pub/pubky.app/:rw']); + vi.spyOn(HomeserverService, 'restoreGrantSession').mockResolvedValue(session); + const logoutSpy = vi.spyOn(HomeserverService, 'logout').mockResolvedValue(undefined); + const removeSpy = vi.spyOn(HomeserverService, 'removeGrantSession').mockResolvedValue(undefined); + + const result = await AuthApplication.restorePersistedSession({ authStore: grantStore() }); + + expect(result).toEqual({ status: 'signed-out' }); + expect(logoutSpy).toHaveBeenCalledWith({ session }); + expect(removeSpy).toHaveBeenCalledWith('rec-1'); + }); + it('grant restore drops a #s= export captured on the same load', async () => { vi.spyOn(HomeserverService, 'restoreGrantSession').mockRejectedValue(createAuthError()); vi.spyOn(HomeserverService, 'removeGrantSession').mockResolvedValue(undefined); diff --git a/src/core/application/auth/auth.ts b/src/core/application/auth/auth.ts index 450c9579c7..113ccdc28c 100644 --- a/src/core/application/auth/auth.ts +++ b/src/core/application/auth/auth.ts @@ -9,7 +9,7 @@ import type { TSingleApprovalCeremonyHooks, TSingleApprovalResult, } from '@/application/auth/auth.types'; -import { CAPABILITIES } from '@/config/app'; +import { CAPABILITIES, capabilitiesMatchFullGrant } from '@/config/app'; import { getCommerceAdapterMode, isDurableCommerceMode } from '@/config/commerce'; import { ValidationErrorCode } from '@/libs/error/error.codes'; import { Err } from '@/libs/error/error.factories'; @@ -196,6 +196,14 @@ export class AuthApplication { let session: Session | null = null; try { session = await HomeserverService.restoreGrantSession(recordId); + if (!capabilitiesMatchFullGrant(session.info.capabilities)) { + Logger.warn('Stored grant session does not hold the full Shop grant; removing its record'); + await HomeserverService.logout({ session }).catch((logoutError) => { + Logger.warn('Failed to sign out a narrow grant session', { logoutError }); + }); + await this.removeGrantSession(recordId); + return { status: 'signed-out' }; + } await HomeserverService.assertUserHomeserverAllowed({ publicKey: session.info.publicKey }); return { status: 'restored', session }; } catch (error) { @@ -365,6 +373,23 @@ export class AuthApplication { return HomeserverService.isGrantSession(session); } + /** + * A grant session holds exactly the Shop grant (`CAPABILITIES`), so it never + * needs a step-up re-approval. An approval for anything narrower is signed + * out and refused, the way the Ring token path refuses it. + */ + static async assertFullGrantSession(session: Session): Promise { + if (capabilitiesMatchFullGrant(session.info.capabilities)) return; + await HomeserverService.logout({ session }).catch((logoutError) => { + Logger.warn('Failed to sign out a narrow grant session', { logoutError }); + }); + throw Err.validation( + ValidationErrorCode.INVALID_INPUT, + 'This approval does not include the full Shop permission list. Scan again from Shop.', + { service: ErrorService.Homeserver, operation: 'assertFullGrantSession' }, + ); + } + static async saveGrantSession(session: Session): Promise { return await HomeserverService.saveGrantSession(session); } diff --git a/src/core/application/commerce/commerce.receipts.test.ts b/src/core/application/commerce/commerce.receipts.test.ts index cb47d958a4..559679f170 100644 --- a/src/core/application/commerce/commerce.receipts.test.ts +++ b/src/core/application/commerce/commerce.receipts.test.ts @@ -400,6 +400,16 @@ describe('CommerceApplication.publishOrderReceipts publication status (step-up O expect(fetch).toHaveBeenCalledTimes(2); }); + it('a grant session refused with 403 reports unavailable, not a step-up', async () => { + grantCapableSession(); + vi.spyOn(HomeserverService, 'isCurrentSessionGrant').mockReturnValue(true); + vi.spyOn(CommerceHomeserverService, 'fetchJson').mockRejectedValue(forbiddenError()); + + await expect( + CommerceApplication.publishOrderReceipts(BUYER, [paidOrder('018f47d2-6a27-7c23-a49d-6b21bb770219')]), + ).resolves.toBe('unavailable'); + }); + it('reports needs_reauth when the private read is refused with 403 mid-pass', async () => { grantCapableSession(); vi.spyOn(CommerceHomeserverService, 'fetchJson').mockRejectedValue(forbiddenError()); diff --git a/src/core/application/commerce/commerce.ts b/src/core/application/commerce/commerce.ts index 01982cdad4..123e481778 100644 --- a/src/core/application/commerce/commerce.ts +++ b/src/core/application/commerce/commerce.ts @@ -1464,9 +1464,16 @@ export class CommerceApplication { } } - /** 403 (scope refused) or 401 (session rejected) on the private document. */ + /** + * 403 (scope refused) or 401 (session rejected) on the private document, + * for a session that a step-up approval can widen. A grant session already + * holds the full Shop grant (enforced at sign-in and restore), so a refusal + * means its grant is no longer honored (revoked or expired), which no + * step-up QR can fix: the round fails and retries on the next load. + */ private static isPrivateAccessDenied(error: unknown): boolean { - return hasHttpStatus(error, HttpStatusCode.FORBIDDEN) || hasHttpStatus(error, HttpStatusCode.UNAUTHORIZED); + const denied = hasHttpStatus(error, HttpStatusCode.FORBIDDEN) || hasHttpStatus(error, HttpStatusCode.UNAUTHORIZED); + return denied && !HomeserverService.isCurrentSessionGrant(); } // --------------------------------------------------------------------- diff --git a/src/core/application/commerce/commerce.watchlist.test.ts b/src/core/application/commerce/commerce.watchlist.test.ts index 51599a57ff..10c3386f03 100644 --- a/src/core/application/commerce/commerce.watchlist.test.ts +++ b/src/core/application/commerce/commerce.watchlist.test.ts @@ -92,6 +92,17 @@ describe('CommerceApplication.syncWatchlist capability gating', () => { expect(put).toHaveBeenCalledOnce(); }); + it('a grant session refused with 403 fails the round instead of asking for a step-up', async () => { + vi.spyOn(HomeserverService, 'hasActiveSession').mockReturnValue(true); + vi.spyOn(HomeserverService, 'canCurrentSessionWrite').mockReturnValue(true); + vi.spyOn(HomeserverService, 'isCurrentSessionGrant').mockReturnValue(true); + vi.spyOn(CommerceHomeserverService, 'fetchJson').mockRejectedValue(httpError(403)); + const complete = vi.spyOn(LocalCommerceService, 'completeSyncJob'); + + expect(await CommerceApplication.syncWatchlist(OWNER)).toBe('error'); + expect(complete).not.toHaveBeenCalled(); + }); + it('pulls, merges remote-only watches into Dexie, and pushes nothing when the merge equals remote', async () => { vi.spyOn(HomeserverService, 'hasActiveSession').mockReturnValue(true); vi.spyOn(HomeserverService, 'canCurrentSessionWrite').mockReturnValue(true); diff --git a/src/core/controllers/auth/auth.test.ts b/src/core/controllers/auth/auth.test.ts index bfc2f591f1..f6046f3ecb 100644 --- a/src/core/controllers/auth/auth.test.ts +++ b/src/core/controllers/auth/auth.test.ts @@ -6,6 +6,7 @@ import { BootstrapApplication } from '@/application/bootstrap/bootstrap'; import { CommerceApplication } from '@/application/commerce/commerce'; import { SettingsApplication } from '@/application/settings/settings'; import { postStreamQueue } from '@/application/stream/posts/muting/post-stream-queue'; +import { CAPABILITIES } from '@/config/app'; import { MUTE_SYNC_CURSOR_STORAGE_PREFIX } from '@/config/mute-sync'; import { AUTH_EPOCH_KEY, bumpAuthEpoch, readAuthEpoch, subscribeSignedOut } from '@/controllers/auth/auth-epoch'; import { resetAuthFinalizationLockForTests } from '@/controllers/auth/auth-finalization-lock'; @@ -15,7 +16,7 @@ import { StreamCoordinator } from '@/coordinators/streams/stream'; import { TtlCoordinator } from '@/coordinators/ttl/ttl'; import { clearDatabase, clearPrivateData } from '@/database/franky/franky.helpers'; import { AppError } from '@/libs/error/error'; -import { AuthErrorCode, ServerErrorCode } from '@/libs/error/error.codes'; +import { AuthErrorCode, ServerErrorCode, ValidationErrorCode } from '@/libs/error/error.codes'; import { Err } from '@/libs/error/error.factories'; import { ErrorCategory, ErrorService } from '@/libs/error/error.types'; import { Identity } from '@/libs/identity/identity'; @@ -29,6 +30,7 @@ import { NotificationNormalizer } from '@/pipes/notification/notification.normal import { PubkySpecsSingleton } from '@/pipes/pipes.builder'; import { SettingsNormalizer } from '@/pipes/settings/settings.normalizer'; import { grantKeyRemovalFailed, isGrantKeyRemovalError } from '@/services/homeserver/error.utils'; +import { HomeserverService } from '@/services/homeserver/homeserver'; import { useAuthStore } from '@/stores/auth/auth.store'; import type { AuthStore } from '@/stores/auth/auth.types'; import { useCommerceStore } from '@/stores/commerce/commerce.store'; @@ -2396,7 +2398,11 @@ describe('AuthController', () => { }); describe('grant sessions (Bitkit sign-in)', () => { - const grantSession = () => buildMockSession({ grant: asOpaque({}) }); + const grantSession = (capabilities: string[] = CAPABILITIES.split(',')) => + buildMockSession({ + grant: asOpaque({}), + info: asOpaque({ publicKey: { z32: () => 'mock-session-pubky' }, capabilities }), + }); const grantAuthStore = (overrides: Partial = {}): AuthStore => mockAuthStore({ @@ -2448,6 +2454,17 @@ describe('AuthController', () => { ); }); + it('an approval narrower than the Shop grant is signed out and never saved', async () => { + const session = grantSession(['/pub/pubky.app/:rw']); + vi.spyOn(useAuthStore, 'getState').mockReturnValue(grantAuthStore()); + const saveSpy = vi.spyOn(AuthApplication, 'saveGrantSession').mockResolvedValue('rec-1'); + const logoutSpy = vi.spyOn(HomeserverService, 'logout').mockResolvedValue(undefined); + + await expect(approveGrantSignIn(session)).rejects.toMatchObject({ code: ValidationErrorCode.INVALID_INPUT }); + expect(logoutSpy).toHaveBeenCalledWith({ session }); + expect(saveSpy).not.toHaveBeenCalled(); + }); + it('a failed save signs the grant out, removes its keys and surfaces the failure', async () => { const order: string[] = []; const session = grantSession(); diff --git a/src/core/controllers/auth/auth.ts b/src/core/controllers/auth/auth.ts index 35f6da1ca5..0edd7f05e4 100644 --- a/src/core/controllers/auth/auth.ts +++ b/src/core/controllers/auth/auth.ts @@ -927,6 +927,7 @@ export class AuthController { if (this.lostToAnotherSignIn(session)) { return await this.discardLosingApproval(session); } + await AuthApplication.assertFullGrantSession(session); this.grantEpochAtStart.set(session, epochAtStart); return session; }); diff --git a/src/core/services/homeserver/homeserver.ts b/src/core/services/homeserver/homeserver.ts index b203cfd884..9c258d26fa 100644 --- a/src/core/services/homeserver/homeserver.ts +++ b/src/core/services/homeserver/homeserver.ts @@ -692,6 +692,10 @@ export class HomeserverService { return Boolean(session && session.grant !== undefined); } + static isCurrentSessionGrant(): boolean { + return this.isGrantSession(useAuthStore.getState().selectSession()); + } + /** Persists a completed grant session in IndexedDB and returns its record id. */ static async saveGrantSession(session: Session): Promise { try { From a5a2109a6c3dea7c240801c8af77ab8ae9068625 Mon Sep 17 00:00:00 2001 From: Bitcoin Error Log <18273620+BitcoinErrorLog@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:55:03 +0100 Subject: [PATCH 08/15] fix(auth): confirm a #s= session hand-off before it signs the tab in Any page can link to the Shop with #s= for a session whose cookie the browser holds, and nothing binds the link to this device. The fragment leg now asks the user to confirm the named pubky. Not me, Escape, or a logout declines it and suppresses the bridge leg for the tab; with no confirmer the hand-off is declined. --- docs/adr/0029-vibe-session-consumer.md | 3 +- src/app/layout.tsx | 3 + .../DialogSessionHandoff.test.tsx | 61 +++++++++++++++++++ .../DialogSessionHandoff.tsx | 58 ++++++++++++++++++ src/core/application/auth/auth.test.ts | 44 ++++++++++++- src/core/application/auth/auth.ts | 22 +++++-- src/core/application/auth/auth.types.ts | 6 ++ src/core/controllers/auth/auth.test.ts | 42 ++++++++++++- src/core/controllers/auth/auth.ts | 27 +++++++- .../sessionHandoff/sessionHandoff.store.ts | 14 +++++ .../useSessionHandoff/useSessionHandoff.ts | 15 +++++ 11 files changed, 286 insertions(+), 9 deletions(-) create mode 100644 src/components/organisms/DialogSessionHandoff/DialogSessionHandoff.test.tsx create mode 100644 src/components/organisms/DialogSessionHandoff/DialogSessionHandoff.tsx create mode 100644 src/core/stores/sessionHandoff/sessionHandoff.store.ts create mode 100644 src/hooks/useSessionHandoff/useSessionHandoff.ts diff --git a/docs/adr/0029-vibe-session-consumer.md b/docs/adr/0029-vibe-session-consumer.md index e9ff2abc5d..8adf88e225 100644 --- a/docs/adr/0029-vibe-session-consumer.md +++ b/docs/adr/0029-vibe-session-consumer.md @@ -34,7 +34,7 @@ Both are read as literal `process.env.NEXT_PUBLIC_*` so Next inlines them. They 1. If a persisted `sessionExport` exists, restore it with the existing retry loop (`HomeserverService.restoreSession` + homeserver environment check). 2. If there is **no** persist, or persist fails with a **definitive auth** error (`AppError` auth category except wrong-environment, or `isPubkyExpiredError`), and consumer mode is on: obtain an export via **fragment → bridge**, then run the same `restoreSession` path. -3. Fragment `#s=` is consumed on the first client pass (`instrumentation-client.ts` via `consumeFragmentSessionExport`) and taken once by restore (`takeFragmentSessionExport`). The hash is stripped with `history.replaceState` even when consumer mode is off. +3. Fragment `#s=` is consumed on the first client pass (`instrumentation-client.ts` via `consumeFragmentSessionExport`) and taken once by restore (`takeFragmentSessionExport`). The hash is stripped with `history.replaceState` even when consumer mode is off. A fragment export that restores is **not applied until the user confirms it**: the Controller's `confirmSessionHandoff` opens `DialogSessionHandoff`, which names the pubky the link would sign the tab in as. Only Continue applies it. Not me, Escape, or a logout declines it; a declined hand-off also suppresses the bridge leg for the tab, so the refused identity is not applied silently by another route. Application declines every hand-off when no confirmer is passed. 4. Bridge: hidden iframe to `${bridgeOrigin}/session-bridge`, `sandbox="allow-scripts allow-same-origin"`. After `load`, post `{ type: 'pubky-session-request', v: 1 }` to `bridgeOrigin`. Accept a reply only if `event.origin === bridgeOrigin && event.source === iframe.contentWindow && data.v === 1`. Load timeout 15 s; reply timeout 3 s from load; one request per load; `AbortSignal`; cleanup of listener / iframe / timers on every path; late messages ignored. ### Contract @@ -75,6 +75,7 @@ RouteGuard and auth-store rehydrate both call `shouldAttemptSessionRestore` (`sr - The homeserver **HttpOnly cookie** binds identity. The consumer never reads or copies that cookie. - Accept `postMessage` only from `bridgeOrigin` and the iframe `contentWindow`. Never `'*'`. - Strip `#s=` before any auth-dependent routing or network. +- A `#s=` hand-off needs the user's confirmation of the named pubky. Any page can link to the Shop with `#s=` for a session whose cookie this browser holds, including one a third party may have planted through a cross-site sign-in, and nothing binds the link to this device: the board opens the Shop without a Shop-issued state or nonce to echo. Binding the hand-off to a same-device nonce needs the board to carry that nonce; until it does, the prompt is the control. - The iframe sandbox allows scripts and same-origin so the bridge page keeps the pubky-app origin; it cannot navigate the parent. ## Consequences diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 3dbbc0dec6..10e4f83356 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -8,6 +8,7 @@ import { Metadata } from '@/molecules/Metadata/Metadata'; import { StructuredData } from '@/molecules/StructuredData/StructuredData'; import { Toaster } from '@/molecules/Toaster/Toaster'; import { CoordinatorsManager } from '@/organisms/CoordinatorsManager/CoordinatorsManager'; +import { DialogSessionHandoff } from '@/organisms/DialogSessionHandoff/DialogSessionHandoff'; import { DialogSignIn } from '@/organisms/DialogSignIn/DialogSignIn'; import { Header } from '@/organisms/Header/Header'; import { DatabaseProvider } from '@/providers/DatabaseProvider/DatabaseProvider'; @@ -56,6 +57,8 @@ export default function RootLayout({ children }: { children: React.ReactNode }) + {/* Outside RouteGuardProvider: it waits on the restore this dialog answers. */} + diff --git a/src/components/organisms/DialogSessionHandoff/DialogSessionHandoff.test.tsx b/src/components/organisms/DialogSessionHandoff/DialogSessionHandoff.test.tsx new file mode 100644 index 0000000000..1d8f453e5e --- /dev/null +++ b/src/components/organisms/DialogSessionHandoff/DialogSessionHandoff.test.tsx @@ -0,0 +1,61 @@ +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Pubky } from '@/models/models.types'; +import { useSessionHandoffStore } from '@/stores/sessionHandoff/sessionHandoff.store'; +import { DialogSessionHandoff } from './DialogSessionHandoff'; + +const answerSessionHandoff = vi.hoisted(() => vi.fn()); +vi.mock('@/controllers/auth/auth', () => ({ + AuthController: { answerSessionHandoff }, +})); + +const PUBKY = 'o1gg8yc7mj4ksrzr6ms3s5rs8h7bo8y7ohcq7j88wbkm7ns7tuxo' as Pubky; + +describe('DialogSessionHandoff', () => { + afterEach(() => { + act(() => useSessionHandoffStore.getState().setPendingPubky(null)); + answerSessionHandoff.mockClear(); + }); + + it('renders nothing while no hand-off waits', () => { + render(); + + expect(screen.queryByTestId('session-handoff-dialog')).not.toBeInTheDocument(); + }); + + it('names the account the link would sign in as', () => { + act(() => useSessionHandoffStore.getState().setPendingPubky(PUBKY)); + render(); + + expect(screen.getByRole('heading', { name: 'Continue as this account?' })).toBeInTheDocument(); + expect(screen.getByTestId('session-handoff-pubky')).toHaveTextContent('pubkyo1gg8yc7...7ns7tuxo'); + }); + + it('Continue accepts the hand-off', async () => { + act(() => useSessionHandoffStore.getState().setPendingPubky(PUBKY)); + render(); + + await userEvent.setup().click(screen.getByTestId('session-handoff-accept')); + + expect(answerSessionHandoff).toHaveBeenCalledExactlyOnceWith(true); + }); + + it('Not me declines the hand-off', async () => { + act(() => useSessionHandoffStore.getState().setPendingPubky(PUBKY)); + render(); + + await userEvent.setup().click(screen.getByTestId('session-handoff-decline')); + + expect(answerSessionHandoff).toHaveBeenCalledExactlyOnceWith(false); + }); + + it('dismissing the dialog declines the hand-off', async () => { + act(() => useSessionHandoffStore.getState().setPendingPubky(PUBKY)); + render(); + + await userEvent.setup().keyboard('{Escape}'); + + expect(answerSessionHandoff).toHaveBeenCalledExactlyOnceWith(false); + }); +}); diff --git a/src/components/organisms/DialogSessionHandoff/DialogSessionHandoff.tsx b/src/components/organisms/DialogSessionHandoff/DialogSessionHandoff.tsx new file mode 100644 index 0000000000..e359241590 --- /dev/null +++ b/src/components/organisms/DialogSessionHandoff/DialogSessionHandoff.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { Button } from '@/atoms/Button/Button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/atoms/Dialog/Dialog'; +import { Typography } from '@/atoms/Typography/Typography'; +import { useSessionHandoff } from '@/hooks/useSessionHandoff/useSessionHandoff'; +import { formatPublicKey, withPubkyPrefix } from '@/libs/utils/utils'; + +/** + * Asks before a `#s=` link signs this tab in. Closing the dialog any way other + * than Continue declines the hand-off. + */ +export function DialogSessionHandoff() { + const { pendingPubky, accept, decline } = useSessionHandoff(); + if (!pendingPubky) return null; + + return ( + { + if (!open) decline(); + }} + > + + + Continue as this account? + + The link you opened signs this browser in to the Shop. Continue only if you opened it from your own Pubky + account. + + + + {formatPublicKey({ key: pendingPubky, length: 16, includePrefix: true })} + + + + + + + + ); +} diff --git a/src/core/application/auth/auth.test.ts b/src/core/application/auth/auth.test.ts index f3e15b7ba3..8b3f41ba64 100644 --- a/src/core/application/auth/auth.test.ts +++ b/src/core/application/auth/auth.test.ts @@ -652,15 +652,52 @@ describe('AuthApplication', () => { const restoreSpy = vi.spyOn(HomeserverService, 'restoreSession').mockResolvedValue(session); vi.spyOn(HomeserverService, 'assertUserHomeserverAllowed').mockResolvedValue(undefined); const authStore = createMockAuthStore(null); + const confirmSessionHandoff = vi.fn().mockResolvedValue(true); - const result = await AuthApplication.restorePersistedSession({ authStore }); + const result = await AuthApplication.restorePersistedSession({ authStore, confirmSessionHandoff }); expect(restoreSpy).toHaveBeenCalledOnce(); expect(restoreSpy).toHaveBeenCalledWith({ sessionExport: FRAGMENT_EXPORT }); + expect(confirmSessionHandoff).toHaveBeenCalledWith('user-pubky'); expect(vibeSessionBridge.requestFromBridge).not.toHaveBeenCalled(); expect(result).toEqual({ status: 'restored', session }); }); + it('a declined #s= hand-off restores nothing and turns the bridge off for the tab', async () => { + vi.mocked(vibeSessionConfig.getVibeSessionBridgeOrigin).mockReturnValue(BRIDGE); + vi.mocked(vibeSessionFragment.takeFragmentSessionExport).mockReturnValue(FRAGMENT_EXPORT); + vi.spyOn(HomeserverService, 'restoreSession').mockResolvedValue(liveSession()); + vi.spyOn(HomeserverService, 'assertUserHomeserverAllowed').mockResolvedValue(undefined); + const confirmSessionHandoff = vi.fn().mockResolvedValue(false); + try { + const result = await AuthApplication.restorePersistedSession({ + authStore: createMockAuthStore(null), + confirmSessionHandoff, + }); + + expect(confirmSessionHandoff).toHaveBeenCalledWith('user-pubky'); + expect(result).toEqual({ status: 'signed-out' }); + expect(vibeSessionBridge.requestFromBridge).not.toHaveBeenCalled(); + expect(vibeSessionAutoRestore.isVibeSessionAutoRestoreSuppressed()).toBe(true); + } finally { + vibeSessionAutoRestore.clearVibeSessionAutoRestoreSuppressed(); + } + }); + + it('a #s= hand-off is declined when no one can confirm it', async () => { + vi.mocked(vibeSessionConfig.getVibeSessionBridgeOrigin).mockReturnValue(BRIDGE); + vi.mocked(vibeSessionFragment.takeFragmentSessionExport).mockReturnValue(FRAGMENT_EXPORT); + vi.spyOn(HomeserverService, 'restoreSession').mockResolvedValue(liveSession()); + vi.spyOn(HomeserverService, 'assertUserHomeserverAllowed').mockResolvedValue(undefined); + try { + const result = await AuthApplication.restorePersistedSession({ authStore: createMockAuthStore(null) }); + + expect(result).toEqual({ status: 'signed-out' }); + } finally { + vibeSessionAutoRestore.clearVibeSessionAutoRestoreSuppressed(); + } + }); + it('restores from a bridge reply when consumer mode is on and nothing is persisted', async () => { vi.mocked(vibeSessionConfig.getVibeSessionBridgeOrigin).mockReturnValue(BRIDGE); vi.mocked(vibeSessionFragment.takeFragmentSessionExport).mockReturnValue(null); @@ -771,7 +808,10 @@ describe('AuthApplication', () => { vi.spyOn(HomeserverService, 'assertUserHomeserverAllowed').mockResolvedValue(undefined); const authStore = createMockAuthStore(null); - const result = await AuthApplication.restorePersistedSession({ authStore }); + const result = await AuthApplication.restorePersistedSession({ + authStore, + confirmSessionHandoff: async () => true, + }); expect(restoreSpy).toHaveBeenCalledWith({ sessionExport: FRAGMENT_EXPORT }); expect(vibeSessionBridge.requestFromBridge).not.toHaveBeenCalled(); diff --git a/src/core/application/auth/auth.ts b/src/core/application/auth/auth.ts index 113ccdc28c..3fae44e294 100644 --- a/src/core/application/auth/auth.ts +++ b/src/core/application/auth/auth.ts @@ -25,7 +25,8 @@ import { import { HttpMethod } from '@/libs/http/http.types'; import { Logger } from '@/libs/logger/logger'; import { sleep } from '@/libs/utils/utils'; -import { isVibeSessionBridgeLegSkipped } from '@/libs/vibe-session/auto-restore'; +import { Identity } from '@/libs/identity/identity'; +import { isVibeSessionBridgeLegSkipped, suppressVibeSessionAutoRestore } from '@/libs/vibe-session/auto-restore'; import { requestFromBridge } from '@/libs/vibe-session/bridge'; import { getVibeId, getVibeSessionBridgeOrigin } from '@/libs/vibe-session/config'; import { isPubkyExpiredError } from '@/libs/vibe-session/expired'; @@ -81,7 +82,10 @@ export class AuthApplication { * @param authStore - The auth store object containing state and actions needed for restoration * @returns The restored session, or null if restoration failed */ - static async restorePersistedSession({ authStore }: TRestoreSessionParams): TRestoreSessionResult { + static async restorePersistedSession({ + authStore, + confirmSessionHandoff = async () => false, + }: TRestoreSessionParams): TRestoreSessionResult { // If a restoration is already in progress, return the existing promise if (this.restoreSessionPromise) { return await this.restoreSessionPromise; @@ -117,7 +121,7 @@ export class AuthApplication { // flag for the whole restore+finalization span so no leg can leave a loading gap. this.restoreSessionPromise = (async () => { try { - return await this.runSessionRestore({ persistedExport, consumerOrigin }); + return await this.runSessionRestore({ persistedExport, consumerOrigin, confirmSessionHandoff }); } finally { // The first restore decision of this page load has run (whichever leg // decided it) — drop any cached `#s=` export so a later same-tab @@ -133,9 +137,11 @@ export class AuthApplication { private static async runSessionRestore({ persistedExport, consumerOrigin, + confirmSessionHandoff, }: { persistedExport: string | null; consumerOrigin: string | undefined; + confirmSessionHandoff: (pubky: Pubky) => Promise; }): TRestoreSessionResult { let keepPersistedExport = false; @@ -162,7 +168,15 @@ export class AuthApplication { if (fragmentExport) { const fromFragment = await this.restoreSessionFromExport(fragmentExport); if (fromFragment.session) { - return { status: 'restored', session: fromFragment.session }; + // Any page can link here with a `#s=` for a session the browser holds a + // cookie for, including one a third party planted. Nothing binds the + // hand-off to this device, so the user confirms the identity first. + if (await confirmSessionHandoff(Identity.z32FromSession({ session: fromFragment.session }))) { + return { status: 'restored', session: fromFragment.session }; + } + // Declined: the bridge must not apply an identity the user just refused. + suppressVibeSessionAutoRestore(); + return this.unresolvedConsumerRestore(keepPersistedExport); } } diff --git a/src/core/application/auth/auth.types.ts b/src/core/application/auth/auth.types.ts index 50a6a37ed8..fd0d7be25f 100644 --- a/src/core/application/auth/auth.types.ts +++ b/src/core/application/auth/auth.types.ts @@ -1,4 +1,5 @@ import { Keypair, type Session } from '@synonymdev/pubky'; +import type { Pubky } from '@/models/models.types'; import type { MarketplaceSessionInfo } from '@/services/marketplace/marketplace-session'; import type { AuthStore } from '@/stores/auth/auth.types'; @@ -14,6 +15,11 @@ export type THomeserverAuthenticateParams = TKeypairParams & TSecretKey; export interface TRestoreSessionParams { authStore: AuthStore; + /** + * Asks the user whether a `#s=` hand-off may sign this tab in as `pubky`. + * Resolves true only on an explicit yes. Absent, every hand-off is declined. + */ + confirmSessionHandoff?: (pubky: Pubky) => Promise; } export type TRestoreSessionOutcome = diff --git a/src/core/controllers/auth/auth.test.ts b/src/core/controllers/auth/auth.test.ts index f6046f3ecb..922759e309 100644 --- a/src/core/controllers/auth/auth.test.ts +++ b/src/core/controllers/auth/auth.test.ts @@ -42,6 +42,7 @@ import { useNotificationStore } from '@/stores/notification/notification.store'; import type { NotificationState } from '@/stores/notification/notification.types'; import { useOnboardingStore } from '@/stores/onboarding/onboarding.store'; import { useSearchStore } from '@/stores/search/search.store'; +import { useSessionHandoffStore } from '@/stores/sessionHandoff/sessionHandoff.store'; import { useSettingsStore } from '@/stores/settings/settings.store'; import { defaultNotificationPreferences, @@ -1150,7 +1151,10 @@ describe('AuthController', () => { const result = await AuthController.restorePersistedSession(); expect(result).toEqual({ status: 'restored' }); - expect(AuthApplication.restorePersistedSession).toHaveBeenCalledWith({ authStore }); + expect(AuthApplication.restorePersistedSession).toHaveBeenCalledWith({ + authStore, + confirmSessionHandoff: expect.any(Function), + }); expect(Identity.z32FromSession).toHaveBeenCalledWith({ session: mockSession }); expect(userIsSignedUpSpy).not.toHaveBeenCalled(); expect(authStore.reset).not.toHaveBeenCalled(); @@ -2818,6 +2822,42 @@ describe('AuthController', () => { expect(storeMocks.resetAuthStore).not.toHaveBeenCalled(); }); + it('a #s= hand-off waits for the prompt and only a yes passes through', async () => { + vi.spyOn(useAuthStore, 'getState').mockReturnValue(grantAuthStore({ currentUserPubky: null })); + let answer: boolean | undefined; + vi.spyOn(AuthApplication, 'restorePersistedSession').mockImplementation(async ({ confirmSessionHandoff }) => { + answer = await confirmSessionHandoff?.('handoff-pubky' as Pubky); + return { status: 'deferred' }; + }); + + const restoring = AuthController.restorePersistedSession(); + await vi.waitFor(() => expect(useSessionHandoffStore.getState().pendingPubky).toBe('handoff-pubky')); + expect(answer).toBeUndefined(); + AuthController.answerSessionHandoff(true); + await restoring; + + expect(answer).toBe(true); + expect(useSessionHandoffStore.getState().pendingPubky).toBeNull(); + }); + + it('logout declines an unanswered #s= prompt', async () => { + vi.spyOn(useAuthStore, 'getState').mockReturnValue(grantAuthStore({ currentUserPubky: null })); + let answer: boolean | undefined; + vi.spyOn(AuthApplication, 'restorePersistedSession').mockImplementation(async ({ confirmSessionHandoff }) => { + answer = await confirmSessionHandoff?.('handoff-pubky' as Pubky); + return { status: 'deferred' }; + }); + const restoring = AuthController.restorePersistedSession(); + await vi.waitFor(() => expect(useSessionHandoffStore.getState().pendingPubky).toBe('handoff-pubky')); + + vi.spyOn(AuthApplication, 'clearGrantSessions').mockResolvedValue(undefined); + await AuthController.logout(); + await restoring; + + expect(answer).toBe(false); + expect(useSessionHandoffStore.getState().pendingPubky).toBeNull(); + }); + it('logout tells other tabs to let go', async () => { vi.spyOn(useAuthStore, 'getState').mockReturnValue(grantAuthStore({ session: grantSession() })); vi.spyOn(AuthApplication, 'logout').mockResolvedValue(undefined); diff --git a/src/core/controllers/auth/auth.ts b/src/core/controllers/auth/auth.ts index 0edd7f05e4..aecad0910a 100644 --- a/src/core/controllers/auth/auth.ts +++ b/src/core/controllers/auth/auth.ts @@ -67,6 +67,7 @@ import { useNotificationStore } from '@/stores/notification/notification.store'; import { useOnboardingStore } from '@/stores/onboarding/onboarding.store'; import { ONBOARDING_PERSIST_KEY } from '@/stores/persistedKeys'; import { useSearchStore } from '@/stores/search/search.store'; +import { useSessionHandoffStore } from '@/stores/sessionHandoff/sessionHandoff.store'; import { useSettingsStore } from '@/stores/settings/settings.store'; import type { SettingsState } from '@/stores/settings/settings.types'; import { useSignInStore } from '@/stores/signIn/signIn.store'; @@ -128,6 +129,25 @@ export class AuthController { /** `authFlowGeneration` when each sign-in QR (Ring or Bitkit) started, keyed by its approved session. */ private static sessionFlowGeneration = new WeakMap(); + /** Resolver of the `#s=` hand-off prompt the user has not answered yet. */ + private static pendingSessionHandoff: ((accepted: boolean) => void) | null = null; + + private static async confirmSessionHandoff(pubky: Pubky): Promise { + this.pendingSessionHandoff?.(false); + return await new Promise((resolve) => { + this.pendingSessionHandoff = resolve; + useSessionHandoffStore.getState().setPendingPubky(pubky); + }); + } + + /** The user's answer to the `#s=` hand-off prompt. Only `true` signs the tab in. */ + static answerSessionHandoff(accepted: boolean): void { + const resolve = this.pendingSessionHandoff; + this.pendingSessionHandoff = null; + useSessionHandoffStore.getState().setPendingPubky(null); + resolve?.(accepted); + } + /** * Single-run guard for cleanupLocalState: concurrent Controller invocations * (e.g. a logout racing an in-flight restore) share one run, and once a run @@ -271,7 +291,10 @@ export class AuthController { authStore.setIsRestoringSession(true); let cleanedUp = false; try { - const result = await AuthApplication.restorePersistedSession({ authStore }); + const result = await AuthApplication.restorePersistedSession({ + authStore, + confirmSessionHandoff: (pubky) => this.confirmSessionHandoff(pubky), + }); if (result.status === 'restored') { const { session } = result; const pubky = Identity.z32FromSession({ session }); @@ -1269,6 +1292,8 @@ export class AuthController { // Bump before any await so an in-flight restore (sharing the Application // singleton promise) can tell its restored-branch result is stale. this.logoutGeneration += 1; + // An unanswered `#s=` prompt holds the shared restore this logout joins. + this.answerSessionHandoff(false); // Set before any await so RouteGuard cannot re-bridge between cleanup and this flag. suppressVibeSessionAutoRestore(); AuthApplication.abortInFlightBridgeRequest(); diff --git a/src/core/stores/sessionHandoff/sessionHandoff.store.ts b/src/core/stores/sessionHandoff/sessionHandoff.store.ts new file mode 100644 index 0000000000..5df88bd8c7 --- /dev/null +++ b/src/core/stores/sessionHandoff/sessionHandoff.store.ts @@ -0,0 +1,14 @@ +import { create } from 'zustand'; +import type { Pubky } from '@/models/models.types'; + +type SessionHandoffStore = { + /** Identity a pending `#s=` hand-off would sign this tab in as; null when none is waiting. */ + pendingPubky: Pubky | null; + setPendingPubky: (pubky: Pubky | null) => void; +}; + +// No persistence: a hand-off belongs to one page load. +export const useSessionHandoffStore = create()((set) => ({ + pendingPubky: null, + setPendingPubky: (pubky) => set({ pendingPubky: pubky }), +})); diff --git a/src/hooks/useSessionHandoff/useSessionHandoff.ts b/src/hooks/useSessionHandoff/useSessionHandoff.ts new file mode 100644 index 0000000000..2df5d39a9a --- /dev/null +++ b/src/hooks/useSessionHandoff/useSessionHandoff.ts @@ -0,0 +1,15 @@ +'use client'; + +import { AuthController } from '@/controllers/auth/auth'; +import type { Pubky } from '@/models/models.types'; +import { useSessionHandoffStore } from '@/stores/sessionHandoff/sessionHandoff.store'; + +/** The pending `#s=` hand-off prompt, if any, and the two answers to it. */ +export function useSessionHandoff(): { pendingPubky: Pubky | null; accept: () => void; decline: () => void } { + const pendingPubky = useSessionHandoffStore((state) => state.pendingPubky); + return { + pendingPubky, + accept: () => AuthController.answerSessionHandoff(true), + decline: () => AuthController.answerSessionHandoff(false), + }; +} From ed0e90ceda361d1aeb788be5682218882d7a7449 Mon Sep 17 00:00:00 2001 From: Bitcoin Error Log <18273620+BitcoinErrorLog@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:55:03 +0100 Subject: [PATCH 09/15] docs(changelog): note the Bitkit sign-in and hand-off fixes --- changelog.d/next/48.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/next/48.fixed.md diff --git a/changelog.d/next/48.fixed.md b/changelog.d/next/48.fixed.md new file mode 100644 index 0000000000..da0c93fde6 --- /dev/null +++ b/changelog.d/next/48.fixed.md @@ -0,0 +1 @@ +A link that opens the Shop with a pubky.app session hand-off now asks "Continue as this account?" and names the pubky before it signs the tab in. Bitkit sign-in no longer leaves an approved session behind when the browser cannot save it, and it refuses an approval narrower than the Shop permissions. From 255157ee3c7c1c550c57cc1c92af073cfb123144 Mon Sep 17 00:00:00 2001 From: Bitcoin Error Log <18273620+BitcoinErrorLog@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:58:13 +0100 Subject: [PATCH 10/15] style(auth): format the grant backlog tests and sort the restore imports --- src/components/molecules/QrCodeSlot/QrCodeSlot.test.tsx | 5 ++++- src/core/application/auth/auth.ts | 2 +- src/server/marketplace-grant/browser-bff.test.ts | 4 +++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/components/molecules/QrCodeSlot/QrCodeSlot.test.tsx b/src/components/molecules/QrCodeSlot/QrCodeSlot.test.tsx index f2fc279d98..da4a899a3b 100644 --- a/src/components/molecules/QrCodeSlot/QrCodeSlot.test.tsx +++ b/src/components/molecules/QrCodeSlot/QrCodeSlot.test.tsx @@ -77,7 +77,10 @@ describe('QrCodeSlot', () => { const qr = screen.getByTestId('qrcode-svg'); expect(qr).toHaveAttribute('data-value', 'auth-url'); expect(qr).toHaveAttribute('width', '176'); - const slotAttributeValues = Array.from(screen.getByTestId('qr-auth-url').attributes, (attribute) => attribute.value); + const slotAttributeValues = Array.from( + screen.getByTestId('qr-auth-url').attributes, + (attribute) => attribute.value, + ); expect(slotAttributeValues).not.toContain('auth-url'); const ringLogo = screen.getByAltText('Pubky Ring'); diff --git a/src/core/application/auth/auth.ts b/src/core/application/auth/auth.ts index 3fae44e294..f9db2ffe1f 100644 --- a/src/core/application/auth/auth.ts +++ b/src/core/application/auth/auth.ts @@ -23,9 +23,9 @@ import { toAppError, } from '@/libs/error/error.utils'; import { HttpMethod } from '@/libs/http/http.types'; +import { Identity } from '@/libs/identity/identity'; import { Logger } from '@/libs/logger/logger'; import { sleep } from '@/libs/utils/utils'; -import { Identity } from '@/libs/identity/identity'; import { isVibeSessionBridgeLegSkipped, suppressVibeSessionAutoRestore } from '@/libs/vibe-session/auto-restore'; import { requestFromBridge } from '@/libs/vibe-session/bridge'; import { getVibeId, getVibeSessionBridgeOrigin } from '@/libs/vibe-session/config'; diff --git a/src/server/marketplace-grant/browser-bff.test.ts b/src/server/marketplace-grant/browser-bff.test.ts index 36a44e3d97..ce5430c326 100644 --- a/src/server/marketplace-grant/browser-bff.test.ts +++ b/src/server/marketplace-grant/browser-bff.test.ts @@ -880,7 +880,9 @@ describe('browser purchase bootstrap BFF', () => { await createBrowserChallenge(from('203.0.113.66')); } await expect(createBrowserChallenge(from('203.0.113.66'))).rejects.toEqual(new BffError(429, 'retry_later', 60)); - await expect(createBrowserChallenge(from('198.51.100.7'))).resolves.toMatchObject({ proof_uri: expect.any(String) }); + await expect(createBrowserChallenge(from('198.51.100.7'))).resolves.toMatchObject({ + proof_uri: expect.any(String), + }); }); // A9 From 2bc65d16a37f46205e1388c97496c64fd55be3e6 Mon Sep 17 00:00:00 2001 From: Bitcoin Error Log <18273620+BitcoinErrorLog@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:37:59 +0100 Subject: [PATCH 11/15] fix(auth): keep a failed grant-key cleanup pending until the store is empty A save failure whose key removal also failed dropped the obligation: no record pointer, no marker, so the partial record and delegated key stayed at rest. A durable marker is now set before every grant save and cleared only when the save lands or BrowserSessionStore reads back empty. The next load, the next Bitkit sign-in (before its new flow key exists) and every sign-out retry it. A signed-in grant session in any tab defers the sweep to its own sign-out. --- src/core/controllers/auth/auth.test.ts | 98 +++++++++++++++++++ src/core/controllers/auth/auth.ts | 54 ++++++++-- .../controllers/auth/grant-key-cleanup.ts | 41 ++++++++ src/core/stores/auth/auth.persisted.ts | 12 +++ ...teGuardProvider.suppressed-reload.test.tsx | 2 + .../RouteGuardProvider.test.tsx | 25 +++++ .../RouteGuardProvider/RouteGuardProvider.tsx | 6 ++ 7 files changed, 228 insertions(+), 10 deletions(-) create mode 100644 src/core/controllers/auth/grant-key-cleanup.ts diff --git a/src/core/controllers/auth/auth.test.ts b/src/core/controllers/auth/auth.test.ts index 922759e309..b4a2dc929f 100644 --- a/src/core/controllers/auth/auth.test.ts +++ b/src/core/controllers/auth/auth.test.ts @@ -10,6 +10,11 @@ import { CAPABILITIES } from '@/config/app'; import { MUTE_SYNC_CURSOR_STORAGE_PREFIX } from '@/config/mute-sync'; import { AUTH_EPOCH_KEY, bumpAuthEpoch, readAuthEpoch, subscribeSignedOut } from '@/controllers/auth/auth-epoch'; import { resetAuthFinalizationLockForTests } from '@/controllers/auth/auth-finalization-lock'; +import { + clearGrantKeyCleanupPending, + isGrantKeyCleanupPending, + markGrantKeyCleanupPending, +} from '@/controllers/auth/grant-key-cleanup'; import { CommerceController } from '@/controllers/commerce/commerce'; import { NotificationCoordinator } from '@/coordinators/notifications/notifications'; import { StreamCoordinator } from '@/coordinators/streams/stream'; @@ -2427,6 +2432,7 @@ describe('AuthController', () => { beforeEach(() => { storeMocks.resetAuthStore.mockReset(); localStorage.removeItem(AUTH_EPOCH_KEY); + clearGrantKeyCleanupPending(); Object.defineProperty(document, 'cookie', { writable: true, value: '' }); mockClearDatabase.mockResolvedValue(undefined); vi.spyOn(Identity, 'z32FromSession').mockReturnValue(TEST_PUBKY as Pubky); @@ -2490,6 +2496,98 @@ describe('AuthController', () => { expect(authStore.init).not.toHaveBeenCalled(); }); + /** In-memory BrowserSessionStore behind `clearGrantSessions`: fails `failures` times, then empties. */ + function fakeGrantStore(records: string[], failures: number) { + let remainingFailures = failures; + const clear = vi.spyOn(AuthApplication, 'clearGrantSessions').mockImplementation(async () => { + if (remainingFailures > 0) { + remainingFailures -= 1; + throw grantKeyRemovalFailed('clearGrantSessions', null); + } + records.splice(0); + }); + return { records, clear }; + } + + it('a failed save whose key removal also fails keeps the cleanup, and the next Bitkit sign-in empties the store first', async () => { + const session = grantSession(); + vi.spyOn(useAuthStore, 'getState').mockReturnValue(grantAuthStore()); + vi.spyOn(AuthApplication, 'saveGrantSession').mockRejectedValue(new Error('IndexedDB write failed')); + vi.spyOn(AuthApplication, 'logout').mockResolvedValue(undefined); + const store = fakeGrantStore(['partial-record'], 1); + + const approved = await approveGrantSignIn(session); + await expect(AuthController.initializeAuthenticatedSession({ session: approved })).rejects.toThrow( + 'IndexedDB write failed', + ); + expect(store.records).toEqual(['partial-record']); + expect(isGrantKeyCleanupPending()).toBe(true); + + const order: string[] = []; + store.clear.mockImplementation(async () => { + order.push('clear'); + store.records.splice(0); + }); + vi.spyOn(AuthApplication, 'generateGrantAuthUrl').mockImplementation(async () => { + order.push('new-flow'); + return { + authorizationUrl: 'pubkyauth://signin_grant?x', + awaitApproval: new Promise(() => {}), + cancelAuthFlow: vi.fn(), + }; + }); + await AuthController.getGrantAuthUrl(); + + expect(order).toEqual(['clear', 'new-flow']); + expect(store.records).toEqual([]); + expect(isGrantKeyCleanupPending()).toBe(false); + }); + + it('the next load removes grant keys a failed cleanup left behind', async () => { + vi.spyOn(useAuthStore, 'getState').mockReturnValue(grantAuthStore()); + const store = fakeGrantStore(['stranded'], 1); + markGrantKeyCleanupPending(); + + await expect(AuthController.settlePendingGrantKeyCleanup()).resolves.toBe(false); + expect(isGrantKeyCleanupPending()).toBe(true); + + await expect(AuthController.settlePendingGrantKeyCleanup()).resolves.toBe(true); + expect(store.records).toEqual([]); + expect(isGrantKeyCleanupPending()).toBe(false); + }); + + it('a pending cleanup leaves a signed-in grant session alone until its sign-out removes every key', async () => { + const authStore = grantAuthStore({ session: grantSession(), grantSessionRecordId: 'rec-live' }); + vi.spyOn(useAuthStore, 'getState').mockReturnValue(authStore); + vi.spyOn(AuthApplication, 'logout').mockResolvedValue(undefined); + const store = fakeGrantStore(['rec-live', 'stranded'], 0); + markGrantKeyCleanupPending(); + + await expect(AuthController.settlePendingGrantKeyCleanup()).resolves.toBe(false); + expect(store.clear).not.toHaveBeenCalled(); + expect(isGrantKeyCleanupPending()).toBe(true); + + await AuthController.logout(); + + expect(store.records).toEqual([]); + expect(isGrantKeyCleanupPending()).toBe(false); + }); + + it('a successful grant save drops only the marker it set', async () => { + vi.spyOn(useAuthStore, 'getState').mockReturnValue(grantAuthStore()); + vi.spyOn(AuthApplication, 'saveGrantSession').mockResolvedValue('rec-1'); + + await AuthController.initializeAuthenticatedSession({ session: await approveGrantSignIn(grantSession()) }); + expect(isGrantKeyCleanupPending()).toBe(false); + + vi.spyOn(AuthApplication, 'clearGrantSessions').mockRejectedValue( + grantKeyRemovalFailed('clearGrantSessions', null), + ); + markGrantKeyCleanupPending(); + await AuthController.initializeAuthenticatedSession({ session: await approveGrantSignIn(grantSession()) }); + expect(isGrantKeyCleanupPending()).toBe(true); + }); + it('save aborts after a sign-out since QR start', async () => { const session = grantSession(); const authStore = grantAuthStore(); diff --git a/src/core/controllers/auth/auth.ts b/src/core/controllers/auth/auth.ts index aecad0910a..93552d70d7 100644 --- a/src/core/controllers/auth/auth.ts +++ b/src/core/controllers/auth/auth.ts @@ -27,6 +27,11 @@ import { shouldAbortIdentityPersist, shouldSkipDestructiveCleanup, } from '@/controllers/auth/auth-identity-guard'; +import { + clearGrantKeyCleanupPending, + isGrantKeyCleanupPending, + markGrantKeyCleanupPending, +} from '@/controllers/auth/grant-key-cleanup'; import { CommerceController } from '@/controllers/commerce/commerce'; import { NotificationCoordinator } from '@/coordinators/notifications/notifications'; import { StreamCoordinator } from '@/coordinators/streams/stream'; @@ -55,6 +60,7 @@ import { clearPersistedAuthIdentity, hasPersistedAuthIdentity, readPersistedAuthIdentity, + readPersistedGrantSessionRecordId, } from '@/stores/auth/auth.persisted'; import { useAuthStore } from '@/stores/auth/auth.store'; import { useCommerceStore } from '@/stores/commerce/commerce.store'; @@ -575,19 +581,21 @@ export class AuthController { if (epochAtStart === undefined || epochAtStart !== readAuthEpoch()) { return false; } + // Recorded before the save: a save that fails, or a tab that closes + // mid-save, leaves an obligation the next load can discharge. + const cleanupAlreadyPending = isGrantKeyCleanupPending(); + markGrantKeyCleanupPending(); try { grantSessionRecordId = await AuthApplication.saveGrantSession(session); } catch (error) { grantSaveError = error; return false; } + authStore.init({ session, currentUserPubky: pubky, hasProfile: null, grantSessionRecordId }); + if (!cleanupAlreadyPending) clearGrantKeyCleanupPending(); + return true; } - authStore.init({ - session, - currentUserPubky: pubky, - hasProfile: null, - ...(grantSessionRecordId ? { grantSessionRecordId } : {}), - }); + authStore.init({ session, currentUserPubky: pubky, hasProfile: null }); return true; }); if (!persisted) { @@ -598,10 +606,9 @@ export class AuthController { if (grantSaveError !== null) { // A failed save can leave a partial record and this flow's delegated // key in IndexedDB. The grant is signed out above (that needs the - // key), so the key goes now. Sign-out uses the same order. - await withAuthFinalizationLock(() => AuthApplication.clearGrantSessions()).catch((clearError) => { - Logger.error('Grant keys left by a failed save could not be removed', { clearError }); - }); + // key), so the key goes now. Sign-out uses the same order. If the + // removal fails too, the pending marker keeps the obligation. + await this.settlePendingGrantKeyCleanup(); throw grantSaveError; } throw createCanceledError(); @@ -920,6 +927,8 @@ export class AuthController { * saved to BrowserSessionStore at completion. */ static async getGrantAuthUrl(): Promise { + // Before the new flow creates its key: the cleanup removes every key. + await this.settlePendingGrantKeyCleanup(); const epochAtStart = readAuthEpoch(); BootstrapApplication.cancelModerationFollow(); const captured = this.captureAuthIdentity(); @@ -1387,9 +1396,34 @@ export class AuthController { await withAuthFinalizationLock(async () => { bumpAuthEpoch(); await AuthApplication.clearGrantSessions(); + clearGrantKeyCleanupPending(); }); } + /** + * Discharges a pending grant-key cleanup (see `grant-key-cleanup.ts`): + * removes every stored grant record and key, then drops the marker once the + * store reads back empty. A signed-in grant session in any tab still needs + * its own record, so the cleanup waits for that session's sign-out, which + * removes every key and drops the marker. A failed removal keeps the marker + * for the next attempt. Returns whether nothing is left pending. + */ + static async settlePendingGrantKeyCleanup(): Promise { + if (!isGrantKeyCleanupPending()) return true; + try { + return await withAuthFinalizationLock(async () => { + if (!isGrantKeyCleanupPending()) return true; + if (useAuthStore.getState().grantSessionRecordId || readPersistedGrantSessionRecordId()) return false; + await AuthApplication.clearGrantSessions(); + clearGrantKeyCleanupPending(); + return true; + }); + } catch (error) { + Logger.error('Grant keys left by a failed save are still stored; the cleanup is retried later', { error }); + return false; + } + } + /** * Another tab signed out. A tab still holding a grant session in memory * signs it out and drops local state; cookie sessions are left alone. diff --git a/src/core/controllers/auth/grant-key-cleanup.ts b/src/core/controllers/auth/grant-key-cleanup.ts new file mode 100644 index 0000000000..01d21bfe7f --- /dev/null +++ b/src/core/controllers/auth/grant-key-cleanup.ts @@ -0,0 +1,41 @@ +/** + * Durable obligation to remove grant keys that a failed save left in + * BrowserSessionStore. Set before the cleanup is attempted and cleared only + * once the store reads back empty, so a cleanup that fails (or a tab that + * closes mid-cleanup) is retried on the next load, the next Bitkit sign-in, + * or the next sign-out. Origin-scoped like the store it guards; not an + * account-owned key, so account cleanup must not drop it. + */ +export const GRANT_KEY_CLEANUP_PENDING_KEY = 'pubky-grant-key-cleanup-pending-v1'; + +function storage(): Storage | null { + try { + return typeof localStorage === 'undefined' ? null : localStorage; + } catch { + return null; + } +} + +export function markGrantKeyCleanupPending(): void { + try { + storage()?.setItem(GRANT_KEY_CLEANUP_PENDING_KEY, '1'); + } catch { + // Storage full or disabled: the cleanup below still runs this once. + } +} + +export function isGrantKeyCleanupPending(): boolean { + try { + return storage()?.getItem(GRANT_KEY_CLEANUP_PENDING_KEY) === '1'; + } catch { + return false; + } +} + +export function clearGrantKeyCleanupPending(): void { + try { + storage()?.removeItem(GRANT_KEY_CLEANUP_PENDING_KEY); + } catch { + // Unreadable storage cannot hold the marker either. + } +} diff --git a/src/core/stores/auth/auth.persisted.ts b/src/core/stores/auth/auth.persisted.ts index 30524e1353..9d3399dff6 100644 --- a/src/core/stores/auth/auth.persisted.ts +++ b/src/core/stores/auth/auth.persisted.ts @@ -63,6 +63,18 @@ export function hasPersistedAuthIdentity(): boolean { return readPersistedAuthIdentity().present; } +/** The BrowserSessionStore record id a signed-in grant session (any tab) points at, if one is persisted. */ +export function readPersistedGrantSessionRecordId(): string | null { + try { + const raw = globalThis.localStorage?.getItem(AUTH_PERSIST_KEY); + if (!raw) return null; + const state = (JSON.parse(raw) as { state?: { grantSessionRecordId?: unknown } }).state; + return isNonEmptyString(state?.grantSessionRecordId) ? state.grantSessionRecordId : null; + } catch { + return null; + } +} + /** * Drop the persist blob so a later `init` of a different pubky is not * treated as a foreign clobber. Only call this inside the finalization lock diff --git a/src/providers/RouteGuardProvider/RouteGuardProvider.suppressed-reload.test.tsx b/src/providers/RouteGuardProvider/RouteGuardProvider.suppressed-reload.test.tsx index 545c5b9902..fb64216450 100644 --- a/src/providers/RouteGuardProvider/RouteGuardProvider.suppressed-reload.test.tsx +++ b/src/providers/RouteGuardProvider/RouteGuardProvider.suppressed-reload.test.tsx @@ -13,6 +13,7 @@ import { RouteGuardProvider } from './RouteGuardProvider'; const mocks = vi.hoisted(() => ({ mockRouterPush: vi.fn(), subscribeCrossTabSignOut: vi.fn(() => () => {}), + settlePendingGrantKeyCleanup: vi.fn().mockResolvedValue(true), restorePersistedSession: vi.fn().mockResolvedValue({ status: 'signed-out' }), pathname: '/home', })); @@ -63,6 +64,7 @@ vi.mock('@/controllers/auth/auth', () => ({ AuthController: { restorePersistedSession: mocks.restorePersistedSession, subscribeCrossTabSignOut: mocks.subscribeCrossTabSignOut, + settlePendingGrantKeyCleanup: mocks.settlePendingGrantKeyCleanup, }, })); diff --git a/src/providers/RouteGuardProvider/RouteGuardProvider.test.tsx b/src/providers/RouteGuardProvider/RouteGuardProvider.test.tsx index 19fb2d4664..3fb6c42580 100644 --- a/src/providers/RouteGuardProvider/RouteGuardProvider.test.tsx +++ b/src/providers/RouteGuardProvider/RouteGuardProvider.test.tsx @@ -19,6 +19,7 @@ const mocks = vi.hoisted(() => { const mockToast = vi.fn(); const mockSetShowSignInDialog = vi.fn(); const restorePersistedSession = vi.fn().mockResolvedValue(true); + const settlePendingGrantKeyCleanup = vi.fn().mockResolvedValue(true); return { subscribeCrossTabSignOut, @@ -30,6 +31,7 @@ const mocks = vi.hoisted(() => { mockToast, mockSetShowSignInDialog, restorePersistedSession, + settlePendingGrantKeyCleanup, consumerEnabled: false, autoRestoreSuppressed: false, // Auth store state defaults @@ -154,6 +156,7 @@ vi.mock('@/controllers/auth/auth', () => ({ AuthController: { restorePersistedSession: mocks.restorePersistedSession, subscribeCrossTabSignOut: mocks.subscribeCrossTabSignOut, + settlePendingGrantKeyCleanup: mocks.settlePendingGrantKeyCleanup, }, })); vi.mock('@/controllers/migration/migration', () => ({ @@ -194,6 +197,28 @@ describe('RouteGuardProvider — migration resync', () => { vi.useRealTimers(); }); + it('retries a pending grant-key cleanup once the auth store has hydrated', async () => { + mocks.hasHydrated = false; + const { rerender } = render( + +
Protected Content
+
, + ); + expect(mocks.settlePendingGrantKeyCleanup).not.toHaveBeenCalled(); + + mocks.hasHydrated = true; + rerender( + +
Protected Content
+
, + ); + await act(async () => { + await vi.runAllTimersAsync(); + }); + + expect(mocks.settlePendingGrantKeyCleanup).toHaveBeenCalledTimes(1); + }); + it('calls MigrationController.resync when wasDbReset is true and user is authenticated', async () => { mocks.wasDbReset = true; mocks.mockResync.mockResolvedValue(undefined); diff --git a/src/providers/RouteGuardProvider/RouteGuardProvider.tsx b/src/providers/RouteGuardProvider/RouteGuardProvider.tsx index d361533083..6c63ee6e4f 100644 --- a/src/providers/RouteGuardProvider/RouteGuardProvider.tsx +++ b/src/providers/RouteGuardProvider/RouteGuardProvider.tsx @@ -68,6 +68,12 @@ export function RouteGuardProvider({ children }: RouteGuardProviderProps) { // Another tab signed out: a grant session held in this tab lets go too. useEffect(() => AuthController.subscribeCrossTabSignOut(), []); + // Grant keys a failed save left behind are removed on the next load. + useEffect(() => { + if (!hasHydrated) return; + void AuthController.settlePendingGrantKeyCleanup(); + }, [hasHydrated]); + // Attempt to restore an existing session snapshot on fresh loads. useEffect(() => { if (!hasHydrated) return; From 9390b585b62b9855307cb67c0d1d33acc7f582a2 Mon Sep 17 00:00:00 2001 From: Bitcoin Error Log <18273620+BitcoinErrorLog@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:37:59 +0100 Subject: [PATCH 12/15] fix(marketplace): drop the per-pubky challenge bucket Challenge creation is unauthenticated and may name any pubky, so any bucket keyed by pubky (alone or with the client IP) can be spent by someone else: a peer behind the same NAT still locked the owner out. The per-IP bucket alone bounds creation; clients behind one egress share it, which is documented and pinned by a test. SHOP_BFF_CLI_GRANT_CREATE_PER_PUBKY_PER_MINUTE is no longer read. --- .../marketplace-grant/browser-bff.test.ts | 43 ++++++++++++++----- src/server/marketplace-grant/browser-bff.ts | 11 +++-- src/server/marketplace-grant/cli-bff.test.ts | 20 +++++---- src/server/marketplace-grant/cli-bff.ts | 22 ++++------ src/server/marketplace-grant/config.test.ts | 1 - src/server/marketplace-grant/config.ts | 2 - 6 files changed, 59 insertions(+), 40 deletions(-) diff --git a/src/server/marketplace-grant/browser-bff.test.ts b/src/server/marketplace-grant/browser-bff.test.ts index ce5430c326..446ee766b0 100644 --- a/src/server/marketplace-grant/browser-bff.test.ts +++ b/src/server/marketplace-grant/browser-bff.test.ts @@ -862,25 +862,46 @@ describe('browser purchase bootstrap BFF', () => { expect(cancelGrant).toHaveBeenCalledWith(expect.anything(), row.flow_id, encodeBase64Url(derived.resultDeliveryId)); }); - it("challenges for a pubky from another client do not spend its owner's bucket", async () => { - storeInsertedChallenges(); - process.env.VERCEL = '1'; - resetMarketplaceGrantConfigForTests(); - const { createBrowserChallenge } = await import('./browser-bff'); + function countingRateLimit(): void { const buckets = new Map(); consumeCliRateLimit.mockImplementation(async (_config: unknown, key: string, limit: number) => { const count = (buckets.get(key) ?? 0) + 1; buckets.set(key, count); return count <= limit; }); + } + + it("challenges a NAT peer creates for someone's pubky never lock the owner out", async () => { + storeInsertedChallenges(); + process.env.VERCEL = '1'; + resetMarketplaceGrantConfigForTests(); + const { createBrowserChallenge } = await import('./browser-bff'); + countingRateLimit(); + const natExit = { 'x-vercel-forwarded-for': '203.0.113.66' }; + + // Five was the old per-pubky allowance; the peer spends it naming the owner's pubky. + for (let i = 0; i < 5; i += 1) await createBrowserChallenge(challengeRequest({ pubky }, natExit)); + + await expect(createBrowserChallenge(challengeRequest({ pubky }, natExit))).resolves.toMatchObject({ + proof_uri: expect.stringContaining(pubky), + }); + }); + + it('the per-IP bucket is shared behind one NAT (accepted limit of unauthenticated creation)', async () => { + storeInsertedChallenges(); + process.env.VERCEL = '1'; + resetMarketplaceGrantConfigForTests(); + const { createBrowserChallenge } = await import('./browser-bff'); + countingRateLimit(); const config = await browserConfig(); - const from = (ip: string) => challengeRequest({ pubky }, { 'x-vercel-forwarded-for': ip }); + const from = (ip: string, who = pubky) => challengeRequest({ pubky: who }, { 'x-vercel-forwarded-for': ip }); - for (let i = 0; i < config.createPerPubkyPerMinute; i += 1) { - await createBrowserChallenge(from('203.0.113.66')); - } - await expect(createBrowserChallenge(from('203.0.113.66'))).rejects.toEqual(new BffError(429, 'retry_later', 60)); - await expect(createBrowserChallenge(from('198.51.100.7'))).resolves.toMatchObject({ + for (let i = 0; i < config.createPerIpPerMinute; i += 1) await createBrowserChallenge(from('203.0.113.66')); + + await expect(createBrowserChallenge(from('203.0.113.66', otherPubky))).rejects.toEqual( + new BffError(429, 'retry_later', 60), + ); + await expect(createBrowserChallenge(from('198.51.100.7', otherPubky))).resolves.toMatchObject({ proof_uri: expect.any(String), }); }); diff --git a/src/server/marketplace-grant/browser-bff.ts b/src/server/marketplace-grant/browser-bff.ts index cd282203d7..9005a5aa68 100644 --- a/src/server/marketplace-grant/browser-bff.ts +++ b/src/server/marketplace-grant/browser-bff.ts @@ -1,7 +1,7 @@ import { randomBytes, randomUUID } from 'node:crypto'; import { z } from 'zod'; import { assertSameOrigin, BffError, FLOW_COOKIE, parseStrictJson } from './bff'; -import { canonicalZ32, challengePubkyBucket, clientIp, hashesEqual, requireUuid } from './cli-bff'; +import { canonicalZ32, clientIp, hashesEqual, requireUuid } from './cli-bff'; import type { CliGrantConfig } from './config'; import { getBrowserBootstrapConfig } from './config'; import { @@ -97,9 +97,12 @@ export async function createBrowserChallenge(request: Request): Promise { resetMarketplaceGrantConfigForTests(); }); - it("challenges for a pubky from another client do not spend its owner's bucket", async () => { + it("challenges a NAT peer creates for someone's pubky never lock the owner out", async () => { process.env.VERCEL = '1'; resetMarketplaceGrantConfigForTests(); - const { getCliGrantConfig } = await import('./config'); const { createCliChallenge } = await import('./cli-bff'); const buckets = new Map(); consumeCliRateLimit.mockImplementation(async (_config: unknown, key: string, limit: number) => { @@ -159,14 +158,17 @@ describe('CLI grant BFF', () => { buckets.set(key, count); return count <= limit; }); - const body = { pubky, result_cpk: pubky, result_delivery_id: deliveryId }; - const from = (ip: string) => jsonRequest(body, { 'x-vercel-forwarded-for': ip }); + const request = () => + jsonRequest( + { pubky, result_cpk: pubky, result_delivery_id: deliveryId }, + { 'x-vercel-forwarded-for': '203.0.113.66' }, + ); - for (let i = 0; i < getCliGrantConfig()!.createPerPubkyPerMinute; i += 1) { - await createCliChallenge(from('203.0.113.66')); - } - await expect(createCliChallenge(from('203.0.113.66'))).rejects.toEqual(new BffError(429, 'retry_later', 60)); - await expect(createCliChallenge(from('198.51.100.7'))).resolves.toMatchObject({ proof_uri: expect.any(String) }); + // Five was the old per-pubky allowance; the peer spends it naming the owner's pubky. + for (let i = 0; i < 5; i += 1) await createCliChallenge(request()); + + await expect(createCliChallenge(request())).resolves.toMatchObject({ proof_uri: expect.stringContaining(pubky) }); + expect(buckets.size).toBe(1); }); it('returns 404 grant_unavailable when the CLI flag is off', async () => { diff --git a/src/server/marketplace-grant/cli-bff.ts b/src/server/marketplace-grant/cli-bff.ts index 8870c11e08..48ab55deee 100644 --- a/src/server/marketplace-grant/cli-bff.ts +++ b/src/server/marketplace-grant/cli-bff.ts @@ -130,16 +130,6 @@ export function clientIp(request: Request, trustedProxyCount: number): string { return xffHopBehindTrustedProxies(request.headers.get('x-forwarded-for'), trustedProxyCount) || '0.0.0.0'; } -/** - * Challenge creation is unauthenticated and names any pubky, so a bucket - * keyed by pubky alone lets a third party exhaust the owner's bucket. Keyed - * by client IP and pubky, a flood from elsewhere never reaches the owner's - * bucket; the per-IP bucket still bounds total creation. - */ -export function challengePubkyBucket(prefix: string, ip: string, pubky: string): string { - return `${prefix}:${ip}:${pubky}`; -} - function tokenBucketKey(prefix: string, digest: Uint8Array): string { return `${prefix}:${Buffer.from(digest).toString('hex')}`; } @@ -187,9 +177,15 @@ export async function createCliChallenge(request: Request): Promise<{ } catch { throw new BffError(400, 'invalid_request'); } - const ip = clientIp(request, config.trustedProxyCount); - await rateLimit(config, `cli_challenge_ip:${ip}`, config.createPerIpPerMinute); - await rateLimit(config, challengePubkyBucket('cli_challenge_pubky', ip, pubky), config.createPerPubkyPerMinute); + // Challenge creation is unauthenticated and may name any pubky, so there is + // no per-pubky bucket: whoever spends it could lock the owner out, from any + // address. The per-IP bucket alone bounds creation. Clients behind one NAT or + // VPN exit share it, as with every per-source limit on unauthenticated input. + await rateLimit( + config, + `cli_challenge_ip:${clientIp(request, config.trustedProxyCount)}`, + config.createPerIpPerMinute, + ); const challengeId = randomUUID(); const nonce = Uint8Array.from(randomBytes(32)); const expiresAt = new Date(Date.now() + config.challengeTtlSeconds * 1000); diff --git a/src/server/marketplace-grant/config.test.ts b/src/server/marketplace-grant/config.test.ts index de5436a38b..acc8515b23 100644 --- a/src/server/marketplace-grant/config.test.ts +++ b/src/server/marketplace-grant/config.test.ts @@ -91,7 +91,6 @@ describe('marketplace grant BFF config', () => { expect(getCliGrantConfig()).toMatchObject({ challengeTtlSeconds: 60, createPerIpPerMinute: 10, - createPerPubkyPerMinute: 5, verifyPerIpPerMinute: 10, statusPerTokenPerMinute: 60, resultPerTokenPerMinute: 30, diff --git a/src/server/marketplace-grant/config.ts b/src/server/marketplace-grant/config.ts index 78db1791d6..3889bbbec2 100644 --- a/src/server/marketplace-grant/config.ts +++ b/src/server/marketplace-grant/config.ts @@ -76,7 +76,6 @@ export type MarketplaceGrantConfig = z.infer; const cliExtrasSchema = z.object({ challengeTtlSeconds: z.coerce.number().int().min(30).max(120).default(60), createPerIpPerMinute: z.coerce.number().int().min(1).default(10), - createPerPubkyPerMinute: z.coerce.number().int().min(1).default(5), verifyPerIpPerMinute: z.coerce.number().int().min(1).default(10), statusPerTokenPerMinute: z.coerce.number().int().min(1).default(60), resultPerTokenPerMinute: z.coerce.number().int().min(1).default(30), @@ -94,7 +93,6 @@ function cliExtrasFromEnv(): z.infer { return cliExtrasSchema.parse({ challengeTtlSeconds: process.env.SHOP_BFF_CLI_GRANT_CHALLENGE_TTL_SECONDS, createPerIpPerMinute: process.env.SHOP_BFF_CLI_GRANT_CREATE_PER_IP_PER_MINUTE, - createPerPubkyPerMinute: process.env.SHOP_BFF_CLI_GRANT_CREATE_PER_PUBKY_PER_MINUTE, verifyPerIpPerMinute: process.env.SHOP_BFF_CLI_GRANT_VERIFY_PER_IP_PER_MINUTE, statusPerTokenPerMinute: process.env.SHOP_BFF_CLI_GRANT_STATUS_PER_TOKEN_PER_MINUTE, resultPerTokenPerMinute: process.env.SHOP_BFF_CLI_GRANT_RESULT_PER_TOKEN_PER_MINUTE, From 0645f3cbbf3dd5437c0fd5ccd6b4512ceb1e364c Mon Sep 17 00:00:00 2001 From: Bitcoin Error Log <18273620+BitcoinErrorLog@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:35:50 +0100 Subject: [PATCH 13/15] fix(marketplace): key rate limits by the platform-written client hop On Vercel the challenge buckets read the leftmost x-vercel-forwarded-for hop. Vercel writes that header itself, but a proxy that appends leaves client-written hops on the left, so the rightmost hop is the one used now. A rotating client-written hop no longer escapes the per-IP bucket. --- .../marketplace-grant/browser-bff.test.ts | 15 +++++++++++++++ src/server/marketplace-grant/cli-bff.ts | 18 ++++++++++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/server/marketplace-grant/browser-bff.test.ts b/src/server/marketplace-grant/browser-bff.test.ts index 446ee766b0..88d9517bb2 100644 --- a/src/server/marketplace-grant/browser-bff.test.ts +++ b/src/server/marketplace-grant/browser-bff.test.ts @@ -887,6 +887,21 @@ describe('browser purchase bootstrap BFF', () => { }); }); + it('rotating a client-written forwarded-for hop does not escape the per-IP bucket', async () => { + storeInsertedChallenges(); + process.env.VERCEL = '1'; + resetMarketplaceGrantConfigForTests(); + const { createBrowserChallenge } = await import('./browser-bff'); + countingRateLimit(); + const config = await browserConfig(); + const spoofed = (i: number) => + challengeRequest({ pubky }, { 'x-vercel-forwarded-for': `10.0.${i}.1, 203.0.113.66` }); + + for (let i = 0; i < config.createPerIpPerMinute; i += 1) await createBrowserChallenge(spoofed(i)); + + await expect(createBrowserChallenge(spoofed(999))).rejects.toEqual(new BffError(429, 'retry_later', 60)); + }); + it('the per-IP bucket is shared behind one NAT (accepted limit of unauthenticated creation)', async () => { storeInsertedChallenges(); process.env.VERCEL = '1'; diff --git a/src/server/marketplace-grant/cli-bff.ts b/src/server/marketplace-grant/cli-bff.ts index 48ab55deee..caffae3460 100644 --- a/src/server/marketplace-grant/cli-bff.ts +++ b/src/server/marketplace-grant/cli-bff.ts @@ -100,9 +100,19 @@ export function requireUuid(value: string): string { return value; } -function firstHop(value: string | null): string { - const hop = value?.split(',')[0]?.trim(); - return hop || ''; +/** + * The hop the platform wrote. Vercel overwrites `x-vercel-forwarded-for` + * with the address it received the request from; a proxy that appends + * instead leaves any client-written hops to the left, so the leftmost hop is + * never trusted. + */ +function lastHop(value: string | null): string { + const hops = + value + ?.split(',') + .map((hop) => hop.trim()) + .filter(Boolean) ?? []; + return hops.at(-1) ?? ''; } function platformRequestIp(request: Request): string { @@ -125,7 +135,7 @@ function xffHopBehindTrustedProxies(forwarded: string | null, trustedProxyCount: export function clientIp(request: Request, trustedProxyCount: number): string { if (process.env.VERCEL === '1') { - return firstHop(request.headers.get('x-vercel-forwarded-for')) || platformRequestIp(request) || '0.0.0.0'; + return lastHop(request.headers.get('x-vercel-forwarded-for')) || platformRequestIp(request) || '0.0.0.0'; } return xffHopBehindTrustedProxies(request.headers.get('x-forwarded-for'), trustedProxyCount) || '0.0.0.0'; } From a2a2e07ac590918533ebc6b3705a872e52554e49 Mon Sep 17 00:00:00 2001 From: Bitcoin Error Log <18273620+BitcoinErrorLog@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:35:50 +0100 Subject: [PATCH 14/15] fix(auth): refuse a step-up for a grant session at the controller Grant sessions are held to the full Shop grant at sign-in and restore, so the re-auth dialog has no state that opens it for one. getStepUpAuthUrl now also refuses a grant session before any Ring flow starts, so a future grant path that skips those checks cannot swap the grant session for a cookie session. --- docs/ecommerce/step-up-approval.md | 2 +- src/core/controllers/auth/auth.test.ts | 8 ++++++++ src/core/controllers/auth/auth.ts | 11 +++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/ecommerce/step-up-approval.md b/docs/ecommerce/step-up-approval.md index f64f0f5ae4..b3043cefe5 100644 --- a/docs/ecommerce/step-up-approval.md +++ b/docs/ecommerce/step-up-approval.md @@ -109,7 +109,7 @@ A Bitkit sign-in (`pubkyauth://signin_grant`) requests exactly `CAPABILITIES`, a The other `needs_reauth` trigger is a 401/403 on the private document. For a grant session with the full grant, that refusal means the grant itself is no longer honored (revoked in Bitkit, or expired). A step-up approval widens scope; it cannot repair a refused grant. `CommerceApplication.isPrivateAccessDenied` therefore does not report `needs_reauth` for a grant session: watchlist sync reports `error` (the outbox job stays pending) and receipt publication reports `unavailable`, both retried on the next load. -`MarketplaceReauthDialog` renders only in the `needs_reauth` state, so it never opens for a grant session and has no grant branch. A delegated-grant step-up QR (contract row R3.9a) is not built: no state reaches it. +`MarketplaceReauthDialog` renders only in the `needs_reauth` state, so it never opens for a grant session. A delegated-grant step-up QR (contract row R3.9a) is not built: no state reaches it. `AuthController.getStepUpAuthUrl` still refuses a grant session before any Ring flow starts, so a future grant path that skips the full-grant checks gets an error in the dialog, not a Ring step-up that would replace the grant session with a cookie session while its grant record stays stored. ## Verification that differed from the brief diff --git a/src/core/controllers/auth/auth.test.ts b/src/core/controllers/auth/auth.test.ts index b4a2dc929f..6ff36c3c1b 100644 --- a/src/core/controllers/auth/auth.test.ts +++ b/src/core/controllers/auth/auth.test.ts @@ -2956,6 +2956,14 @@ describe('AuthController', () => { expect(useSessionHandoffStore.getState().pendingPubky).toBeNull(); }); + it('a grant session is refused a step-up and no Ring flow starts', async () => { + vi.spyOn(useAuthStore, 'getState').mockReturnValue(grantAuthStore({ session: grantSession() })); + const ringFlow = vi.spyOn(AuthApplication, 'generateAuthUrl'); + + await expect(AuthController.getStepUpAuthUrl()).rejects.toMatchObject({ code: AuthErrorCode.UNAUTHORIZED }); + expect(ringFlow).not.toHaveBeenCalled(); + }); + it('logout tells other tabs to let go', async () => { vi.spyOn(useAuthStore, 'getState').mockReturnValue(grantAuthStore({ session: grantSession() })); vi.spyOn(AuthApplication, 'logout').mockResolvedValue(undefined); diff --git a/src/core/controllers/auth/auth.ts b/src/core/controllers/auth/auth.ts index 93552d70d7..6e40b58558 100644 --- a/src/core/controllers/auth/auth.ts +++ b/src/core/controllers/auth/auth.ts @@ -972,6 +972,17 @@ export class AuthController { } static async getStepUpAuthUrl(): Promise { + // A grant session already holds the full Shop grant (checked at sign-in + // and restore), and a Ring step-up would swap it for a cookie session + // while its grant record stays stored. Refused here so every caller is + // covered even if a new grant path skips those checks. + if (AuthApplication.isGrantSession(useAuthStore.getState().session)) { + throw Err.auth( + AuthErrorCode.UNAUTHORIZED, + 'Bitkit sign-in already includes every Shop permission. If this keeps asking, sign out and sign in with Bitkit again.', + { service: ErrorService.Local, operation: 'getStepUpAuthUrl' }, + ); + } if (!isSingleApprovalSignInEnabled()) { return this.wrapAuthFlow(() => AuthApplication.generateAuthUrl(), { preserveLocalState: true }); } From b656309aa7be7adbc4f00aceab8fb30e9aadf65f Mon Sep 17 00:00:00 2001 From: Bitcoin Error Log <18273620+BitcoinErrorLog@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:25:06 +0100 Subject: [PATCH 15/15] test(auth): give the side-by-side Bitkit fixture the full Shop grant Grant approvals narrower than CAPABILITIES are now refused, as a real Bitkit approval never is. --- src/core/controllers/auth/auth.single-approval.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/core/controllers/auth/auth.single-approval.test.ts b/src/core/controllers/auth/auth.single-approval.test.ts index 5b57bda70a..701bda8bdc 100644 --- a/src/core/controllers/auth/auth.single-approval.test.ts +++ b/src/core/controllers/auth/auth.single-approval.test.ts @@ -2,6 +2,7 @@ import type { AuthToken, Session } from '@synonymdev/pubky'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { AuthApplication } from '@/application/auth/auth'; import { BootstrapApplication } from '@/application/bootstrap/bootstrap'; +import { CAPABILITIES } from '@/config/app'; import { clearDatabase } from '@/database/franky/franky.helpers'; import { AuthErrorCode } from '@/libs/error/error.codes'; import { Err } from '@/libs/error/error.factories'; @@ -583,7 +584,7 @@ describe('AuthController single-approval ceremony', () => { describe('AuthController Ring and Bitkit QRs side by side', () => { const grantSession = asOpaque({ - info: { publicKey: { z32: () => 'test-pubky' } }, + info: { publicKey: { z32: () => 'test-pubky' }, capabilities: CAPABILITIES.split(',') }, grant: {}, });