diff --git a/src/components/molecules/GrantSessionRefusal/GrantSessionRefusal.tsx b/src/components/molecules/GrantSessionRefusal/GrantSessionRefusal.tsx new file mode 100644 index 0000000000..f35defe4be --- /dev/null +++ b/src/components/molecules/GrantSessionRefusal/GrantSessionRefusal.tsx @@ -0,0 +1,16 @@ +import { Typography } from '@/atoms/Typography/Typography'; + +/** Shown instead of a Pubky Ring approval QR when the Shop session came from a Bitkit sign-in. */ +export function GrantSessionRefusal() { + return ( +
+ + {'Bitkit sign-in does not cover this step yet. Sign in with Pubky Ring to continue.'} + +
+ ); +} diff --git a/src/components/molecules/QrCodeSlot/QrCodeSlot.tsx b/src/components/molecules/QrCodeSlot/QrCodeSlot.tsx index 20ff5fce9b..209e30b7f7 100644 --- a/src/components/molecules/QrCodeSlot/QrCodeSlot.tsx +++ b/src/components/molecules/QrCodeSlot/QrCodeSlot.tsx @@ -19,6 +19,7 @@ export function QrCodeSlot({ size = DEFAULT_QR_SIZE, activeQrHasHoverEffect = false, expiredReloadAction, + showRingLogo = true, }: QrCodeSlotProps) { const ringLogoSize = Math.round((DEFAULT_RING_LOGO_SIZE / DEFAULT_QR_SIZE) * size); @@ -68,16 +69,18 @@ export function QrCodeSlot({ return ( - Pubky Ring + {showRingLogo && ( + Pubky Ring + )} ); } diff --git a/src/components/molecules/QrCodeSlot/QrCodeSlot.types.ts b/src/components/molecules/QrCodeSlot/QrCodeSlot.types.ts index 8faeb829ed..e75638234e 100644 --- a/src/components/molecules/QrCodeSlot/QrCodeSlot.types.ts +++ b/src/components/molecules/QrCodeSlot/QrCodeSlot.types.ts @@ -23,4 +23,6 @@ export interface QrCodeSlotProps { onClick: () => void; ariaLabel: string; }; + /** The Pubky Ring logo over the QR. Off for QRs meant for another signer (Bitkit). Defaults to true. */ + showRingLogo?: boolean; } diff --git a/src/components/organisms/Marketplace/MarketplaceGrantSessionRefusal.test.tsx b/src/components/organisms/Marketplace/MarketplaceGrantSessionRefusal.test.tsx new file mode 100644 index 0000000000..3cbc40fa49 --- /dev/null +++ b/src/components/organisms/Marketplace/MarketplaceGrantSessionRefusal.test.tsx @@ -0,0 +1,79 @@ +import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { MarketplaceInventoryGrantDialog } from './MarketplaceInventoryGrantDialog'; +import { MarketplaceMessagingEnablePanel } from './MarketplaceMessagingEnableDialog'; + +const state = vi.hoisted(() => ({ + isGrantSession: true, + inventoryStart: vi.fn(), + messagingStart: vi.fn(), +})); + +vi.mock('@/hooks/useIsGrantSession/useIsGrantSession', () => ({ + useIsGrantSession: () => state.isGrantSession, +})); + +vi.mock('@/hooks/useMarketplaceInventoryGrantConnect/useMarketplaceInventoryGrantConnect', () => ({ + useMarketplaceInventoryGrantConnect: () => ({ + status: 'awaiting', + authorizationUrl: 'pubkyauth:///?relay=https%3A%2F%2Frelay.example.com%2Finbox&secret=x', + errorMessage: null, + start: state.inventoryStart, + cancel: vi.fn(), + copyAuthUrl: vi.fn(async () => {}), + openInSigner: vi.fn(), + isOpeningSigner: false, + }), +})); + +vi.mock('@/hooks/useMarketplaceMessagingEnable/useMarketplaceMessagingEnable', () => ({ + useMarketplaceMessagingEnable: () => ({ + status: 'awaiting', + authorizationUrl: 'pubkyauth:///?relay=https%3A%2F%2Frelay.example.com%2Finbox&secret=y', + errorMessage: null, + start: state.messagingStart, + cancel: vi.fn(), + copyAuthUrl: vi.fn(async () => {}), + openInRing: vi.fn(), + isOpeningRing: false, + }), +})); + +vi.mock('@/atoms/Dialog/Dialog', () => ({ + Dialog: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogFooter: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: React.ReactNode }) =>

{children}

, + DialogTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +describe('classic Pubky Ring approvals refuse a grant session', () => { + beforeEach(() => { + state.isGrantSession = true; + state.inventoryStart.mockClear(); + state.messagingStart.mockClear(); + }); + + it('grant session sees refusal not classic qr (inventory grant)', () => { + render(); + + expect(screen.getByTestId('grant-session-refusal')).toBeInTheDocument(); + expect(state.inventoryStart).not.toHaveBeenCalled(); + }); + + it('grant session sees refusal not classic qr (messaging enable)', () => { + render(); + + expect(screen.getByTestId('grant-session-refusal')).toBeInTheDocument(); + expect(state.messagingStart).not.toHaveBeenCalled(); + }); + + it('cookie session keeps messaging resume path (the paykit-wasm flow still starts)', () => { + state.isGrantSession = false; + render(); + + expect(screen.queryByTestId('grant-session-refusal')).not.toBeInTheDocument(); + expect(state.messagingStart).toHaveBeenCalled(); + }); +}); diff --git a/src/components/organisms/Marketplace/MarketplaceInventoryGrantDialog.tsx b/src/components/organisms/Marketplace/MarketplaceInventoryGrantDialog.tsx index 40cdc06b15..6805c14a7b 100644 --- a/src/components/organisms/Marketplace/MarketplaceInventoryGrantDialog.tsx +++ b/src/components/organisms/Marketplace/MarketplaceInventoryGrantDialog.tsx @@ -5,7 +5,9 @@ import { Copy, KeyRound, Loader2, 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 { useMarketplaceInventoryGrantConnect } from '@/hooks/useMarketplaceInventoryGrantConnect/useMarketplaceInventoryGrantConnect'; +import { GrantSessionRefusal } from '@/molecules/GrantSessionRefusal/GrantSessionRefusal'; import { QrCodeSlot } from '@/molecules/QrCodeSlot/QrCodeSlot'; import { toast } from '@/molecules/Toaster/use-toast'; @@ -35,13 +37,14 @@ export function MarketplaceInventoryGrantDialog({ if (autoOpen) setOpen(true); }, [autoOpen]); + const isGrantSession = useIsGrantSession(); useEffect(() => { if (open) { - start(); + if (!isGrantSession) start(); return; } cancel(); - }, [open, start, cancel]); + }, [open, start, cancel, isGrantSession]); const copyUrl = async () => { try { @@ -67,7 +70,9 @@ export function MarketplaceInventoryGrantDialog({ Approve this grant in Bitkit or Pubky Ring; it does not replace your purchase session. - {grant.status === 'error' ? ( + {isGrantSession ? ( + + ) : grant.status === 'error' ? (
{grant.errorMessage}
diff --git a/src/components/organisms/Marketplace/MarketplaceMessagingEnableDialog.tsx b/src/components/organisms/Marketplace/MarketplaceMessagingEnableDialog.tsx index a81b343f06..0f44a508cb 100644 --- a/src/components/organisms/Marketplace/MarketplaceMessagingEnableDialog.tsx +++ b/src/components/organisms/Marketplace/MarketplaceMessagingEnableDialog.tsx @@ -5,9 +5,11 @@ import { Copy, Loader2, LockKeyhole, 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 { useMarketplaceMessagingEnable } from '@/hooks/useMarketplaceMessagingEnable/useMarketplaceMessagingEnable'; import { MESSAGING_COPY } from '@/libs/commerce/messaging-copy'; 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'; @@ -46,10 +48,12 @@ export function MarketplaceMessagingEnablePanel({ }); const { start, cancel } = enable; + const isGrantSession = useIsGrantSession(); useEffect(() => { + if (isGrantSession) return; start(); return cancel; - }, [start, cancel]); + }, [start, cancel, isGrantSession]); const copyUrl = async () => { try { @@ -67,7 +71,9 @@ export function MarketplaceMessagingEnablePanel({ {reconnect ? MESSAGING_COPY.reconnect : MESSAGING_COPY.enable} - {enable.status === 'error' ? ( + {isGrantSession ? ( + + ) : enable.status === 'error' ? (
{enable.errorMessage} diff --git a/src/components/organisms/Marketplace/MarketplaceReauthDialog.test.tsx b/src/components/organisms/Marketplace/MarketplaceReauthDialog.test.tsx index 6d9b5fedbf..9b63005d41 100644 --- a/src/components/organisms/Marketplace/MarketplaceReauthDialog.test.tsx +++ b/src/components/organisms/Marketplace/MarketplaceReauthDialog.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { CAPABILITIES } from '@/config/app'; import type { UseStepUpReauthReturn } from '@/hooks/useStepUpReauth/useStepUpReauth.types'; import { MarketplaceReauthDialog } from './MarketplaceReauthDialog'; @@ -20,7 +20,28 @@ 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; + 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(); + }); + it('asks for a sign-in in product language and does not print capability paths', async () => { render(); diff --git a/src/components/organisms/Marketplace/MarketplaceReauthDialog.tsx b/src/components/organisms/Marketplace/MarketplaceReauthDialog.tsx index 7148605e8e..0a4c151298 100644 --- a/src/components/organisms/Marketplace/MarketplaceReauthDialog.tsx +++ b/src/components/organisms/Marketplace/MarketplaceReauthDialog.tsx @@ -5,8 +5,10 @@ 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'; @@ -45,13 +47,14 @@ 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) { - start(); + if (!isGrantSession) start(); return; } cancel(); - }, [open, start, cancel]); + }, [open, start, cancel, isGrantSession]); const copyUrl = async () => { try { @@ -80,7 +83,9 @@ export function MarketplaceReauthDialog({ Sign in again for this device. - {reauth.status === 'error' ? ( + {isGrantSession ? ( + + ) : reauth.status === 'error' ? (
{reauth.errorMessage} diff --git a/src/components/organisms/Marketplace/MarketplaceSessionConnectDialog.test.tsx b/src/components/organisms/Marketplace/MarketplaceSessionConnectDialog.test.tsx index 7602efaa51..be85fc9072 100644 --- a/src/components/organisms/Marketplace/MarketplaceSessionConnectDialog.test.tsx +++ b/src/components/organisms/Marketplace/MarketplaceSessionConnectDialog.test.tsx @@ -16,6 +16,11 @@ const view = vi.hoisted(() => ({ isOpeningRing: false, requestsFullGrant: true, requestsGrantReconnect: false, + isGrantSession: false, + start: vi.fn(), +})); +vi.mock('@/hooks/useIsGrantSession/useIsGrantSession', () => ({ + useIsGrantSession: () => view.isGrantSession, })); vi.mock('@/hooks/useMarketplaceSessionConnect/useMarketplaceSessionConnect', () => ({ @@ -25,7 +30,7 @@ vi.mock('@/hooks/useMarketplaceSessionConnect/useMarketplaceSessionConnect', () errorMessage: view.errorMessage, requestsFullGrant: view.requestsFullGrant, requestsGrantReconnect: view.requestsGrantReconnect, - start: vi.fn(), + start: view.start, cancel: vi.fn(), copyAuthUrl: vi.fn(async () => {}), openInRing: vi.fn(), @@ -56,6 +61,29 @@ describe('MarketplaceSessionConnectDialog', () => { view.isOpeningRing = false; view.requestsFullGrant = true; view.requestsGrantReconnect = false; + view.isGrantSession = false; + view.start.mockClear(); + }); + + it('grant session sees refusal not classic qr', () => { + view.status = 'awaiting'; + view.authorizationUrl = 'pubkyauth:///?relay=https%3A%2F%2Frelay.example.com%2Finbox&secret=x'; + view.isGrantSession = true; + + render(); + + expect(screen.getByTestId('grant-session-refusal')).toBeInTheDocument(); + expect(screen.queryByLabelText('Copy authorization link')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /open in pubky ring/i })).not.toBeInTheDocument(); + expect(view.start).not.toHaveBeenCalled(); + }); + + it('a cookie session still starts the classic approval when opened', () => { + view.status = 'awaiting'; + render(); + + expect(view.start).toHaveBeenCalled(); + expect(screen.queryByTestId('grant-session-refusal')).not.toBeInTheDocument(); }); it('joined state: honest copy, and no QR slot, Copy, or Open affordances', () => { diff --git a/src/components/organisms/Marketplace/MarketplaceSessionConnectDialog.tsx b/src/components/organisms/Marketplace/MarketplaceSessionConnectDialog.tsx index f34674f73d..d470b7ccef 100644 --- a/src/components/organisms/Marketplace/MarketplaceSessionConnectDialog.tsx +++ b/src/components/organisms/Marketplace/MarketplaceSessionConnectDialog.tsx @@ -5,9 +5,11 @@ 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 { useMarketplaceSessionConnect } from '@/hooks/useMarketplaceSessionConnect/useMarketplaceSessionConnect'; import { Logger } from '@/libs/logger/logger'; import { getMarketplaceGrantFlowEnabled } from '@/libs/runtime-config/runtime-config'; +import { GrantSessionRefusal } from '@/molecules/GrantSessionRefusal/GrantSessionRefusal'; import { QrCodeSlot } from '@/molecules/QrCodeSlot/QrCodeSlot'; import { toast } from '@/molecules/Toaster/use-toast'; @@ -51,13 +53,14 @@ export function MarketplaceSessionConnectDialog({ if (autoOpen) setOpen(true); }, [autoOpen]); + const isGrantSession = useIsGrantSession(); useEffect(() => { if (open) { - start(); + if (!isGrantSession) start(); return; } cancel(); - }, [open, start, cancel]); + }, [open, start, cancel, isGrantSession]); const copyUrl = async () => { try { @@ -96,7 +99,9 @@ export function MarketplaceSessionConnectDialog({ : 'Approve purchases for this device.'} - {['error', 'mismatch', 'expired', 'cancelled'].includes(session.status) ? ( + {isGrantSession ? ( + + ) : ['error', 'mismatch', 'expired', 'cancelled'].includes(session.status) ? (
{session.status === 'mismatch' diff --git a/src/components/organisms/SignIn/SignIn.test.tsx b/src/components/organisms/SignIn/SignIn.test.tsx index 3c612e20a3..f1cbb2c124 100644 --- a/src/components/organisms/SignIn/SignIn.test.tsx +++ b/src/components/organisms/SignIn/SignIn.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useGrantSignInAvailable } from '@/hooks/useGrantSignInAvailable/useGrantSignInAvailable'; import { useMobileAuth } from '@/hooks/useMobileAuth/useMobileAuth'; import { asOpaque } from '@/test-utils/type-assertions'; import { SignInContent, SignInFooter } from './SignIn'; @@ -91,6 +92,10 @@ vi.mock('@/hooks/useMobileAuth/useMobileAuth', () => ({ })), })); +vi.mock('@/hooks/useGrantSignInAvailable/useGrantSignInAvailable', () => ({ + useGrantSignInAvailable: vi.fn(() => false), +})); + const resetMobileAuthMock = () => { vi.mocked(useMobileAuth).mockReturnValue({ url: 'mock-auth-url', @@ -509,6 +514,53 @@ describe('SignInContent', () => { }); }); +describe('SignInContent - Bitkit grant sign-in', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetMockSignInState(); + resetMobileAuthMock(); + vi.mocked(useGrantSignInAvailable).mockReturnValue(false); + }); + + afterEach(() => { + vi.mocked(useGrantSignInAvailable).mockReturnValue(false); + }); + + it('bitkit qr hidden without delegation', async () => { + await act(async () => { + render(); + }); + + expect(screen.queryAllByTestId('sign-in-use-grant')).toHaveLength(0); + expect(screen.queryByTestId('sign-in-grant-qr-card')).not.toBeInTheDocument(); + expect(useMobileAuth).not.toHaveBeenCalledWith({ type: 'grant' }); + }); + + it('switches to a Bitkit grant QR and back to a fresh Ring QR', async () => { + vi.mocked(useGrantSignInAvailable).mockReturnValue(true); + await act(async () => { + render(); + }); + + expect(useMobileAuth).not.toHaveBeenCalledWith({ type: 'grant' }); + await act(async () => { + fireEvent.click(screen.getAllByTestId('sign-in-use-grant')[0]); + }); + + expect(useMobileAuth).toHaveBeenCalledWith({ type: 'grant' }); + expect(screen.getByTestId('sign-in-grant-qr-card')).toBeInTheDocument(); + expect(screen.queryByTestId('sign-in-qr-card')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Copy Bitkit authentication link' })).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getAllByTestId('sign-in-use-ring')[0]); + }); + + expect(screen.getByTestId('sign-in-qr-card')).toBeInTheDocument(); + expect(mockFetchUrl).toHaveBeenCalledTimes(1); + }); +}); + describe('SignInContent - Snapshots', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/src/components/organisms/SignIn/SignIn.tsx b/src/components/organisms/SignIn/SignIn.tsx index 7da61d08a8..8e041c272f 100644 --- a/src/components/organisms/SignIn/SignIn.tsx +++ b/src/components/organisms/SignIn/SignIn.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import Image from 'next/image'; import { CheckCircle, Circle, Key, Loader2, RefreshCw } from 'lucide-react'; import { Button } from '@/atoms/Button/Button'; @@ -10,6 +10,7 @@ import { Link } from '@/atoms/Link/Link'; import { PageHeader } from '@/atoms/PageHeader/PageHeader'; import { PageSubtitle } from '@/atoms/PageSubtitle/PageSubtitle'; import { Typography } from '@/atoms/Typography/Typography'; +import { useGrantSignInAvailable } from '@/hooks/useGrantSignInAvailable/useGrantSignInAvailable'; import { useMobileAuth } from '@/hooks/useMobileAuth/useMobileAuth'; import { Logger } from '@/libs/logger/logger'; import { cn } from '@/libs/utils/utils'; @@ -90,28 +91,135 @@ const SignInProgress = () => { ); }; +async function copyWithToast(copy: () => Promise) { + try { + await copy(); + toast({ + variant: 'info', + title: 'Authentication link copied', + }); + } catch (error) { + Logger.error('Failed to copy auth URL to clipboard:', error); + toast({ + variant: 'error', + description: 'Could not copy to clipboard', + }); + } +} + +/** + * Bitkit sign-in: a grant QR (`pubkyauth://signin_grant`). Mounting it starts + * the grant flow, which supersedes the Ring QR's flow. + */ +const SignInGrantPanel = ({ onUseRing }: { onUseRing: () => void }) => { + const { url, isLoading, isExpired, fetchUrl, copyAuthUrl, isOpeningRing, onAuthorizeClick } = useMobileAuth({ + type: 'grant', + }); + const isMobileLaunching = isLoading || isOpeningRing; + const handleQRClick = async () => { + if (!url) return; + await copyWithToast(copyAuthUrl); + }; + const ringSwitch = ( + + ); + + return ( + <> + + + + } + > + + + + + {'Scan with Bitkit 2.5 or newer.'} + + {ringSwitch} + + + + + + + + + {ringSwitch} + + + + + ); +}; + export const SignInContent = () => { const { url, isLoading, isExpired, fetchUrl, copyAuthUrl, isOpeningRing, onAuthorizeClick } = useMobileAuth(); const authUrlResolved = useSignInStore((state) => state.authUrlResolved); + const isGrantSignInAvailable = useGrantSignInAvailable(); + const [signer, setSigner] = useState<'ring' | 'bitkit'>('ring'); useEffect(() => { // Clear onboarding storage when sign-in flow begins to prevent backup reminders from showing for existing users useOnboardingStore.getState().reset(); }, []); const handleQRClick = async () => { if (!url) return; - try { - await copyAuthUrl(); - toast({ - variant: 'info', - title: 'Authentication link copied', - }); - } catch (error) { - Logger.error('Failed to copy auth URL to clipboard:', error); - toast({ - variant: 'error', - description: 'Could not copy to clipboard', - }); - } + await copyWithToast(copyAuthUrl); }; const isMobileLaunching = isLoading || isOpeningRing; const mobileAuthorizeContent = isMobileLaunching ? ( @@ -144,6 +252,22 @@ export const SignInContent = () => { ); } + if (signer === 'bitkit') { + return ( + { + setSigner('ring'); + // The grant flow superseded the Ring flow; mint a fresh Ring QR. + void fetchUrl(); + }} + /> + ); + } + const bitkitSwitch = isGrantSignInAvailable ? ( + + ) : null; return ( <> @@ -178,6 +302,7 @@ export const SignInContent = () => { /> + {bitkitSwitch} {/** Mobile view */} @@ -196,6 +321,7 @@ export const SignInContent = () => { > {mobileAuthorizeContent} + {bitkitSwitch} @@ -215,7 +341,7 @@ export const SignInFooter = () => { ); }; -export const SignInHeader = () => { +export const SignInHeader = ({ signer = 'ring' }: { signer?: 'ring' | 'bitkit' }) => { return ( @@ -224,7 +350,7 @@ export const SignInHeader = () => { {'Authorize with '} - {'Pubky Ring'} + {signer === 'bitkit' ? 'Bitkit' : 'Pubky Ring'} {' to sign in.'} diff --git a/src/components/templates/Auth/Logout/Logout.tsx b/src/components/templates/Auth/Logout/Logout.tsx index c0b22609fe..c3848c58f6 100644 --- a/src/components/templates/Auth/Logout/Logout.tsx +++ b/src/components/templates/Auth/Logout/Logout.tsx @@ -37,11 +37,12 @@ export function Logout() { const authHasHydrated = useAuthStore((state) => state.hasHydrated); const session = useAuthStore((state) => state.session); const sessionExport = useAuthStore((state) => state.sessionExport); + const grantSessionRecordId = useAuthStore((state) => state.grantSessionRecordId); const isLoggingOut = useAuthStore((state) => state.isLoggingOut); const [viewState, setViewState] = useState('idle'); const isHydrated = onboardingHasHydrated && authHasHydrated; - const isSignedOut = session === null && sessionExport === null; + const isSignedOut = session === null && sessionExport === null && !grantSessionRecordId; useEffect(() => { if (!isHydrated) return; diff --git a/src/config/app.ts b/src/config/app.ts index 403490ba0d..7996f2c21b 100644 --- a/src/config/app.ts +++ b/src/config/app.ts @@ -20,6 +20,9 @@ export const APP_VERSION = Env.NEXT_PUBLIC_APP_VERSION; */ export const CAPABILITIES = '/pub/pubky.app/:rw,/pub/paykit/:rw,/priv/pubky.app/:rw'; +/** Client id Bitkit shows on its Authorize screen for the Shop's grant sign-in. */ +export const SHOP_GRANT_CLIENT_ID = 'shop.pubky.app'; + /** * Interim dual-POST ceremony (docs/ecommerce/single-approval.md). Runtime * flag (`PUBKY_RUNTIME_SINGLE_APPROVAL_SIGN_IN`, default on): `false` diff --git a/src/core/application/auth/auth.test.ts b/src/core/application/auth/auth.test.ts index b908e199bb..f60ce8e23f 100644 --- a/src/core/application/auth/auth.test.ts +++ b/src/core/application/auth/auth.test.ts @@ -14,6 +14,7 @@ import * as vibeSessionBridge from '@/libs/vibe-session/bridge'; import * as vibeSessionConfig from '@/libs/vibe-session/config'; import * as vibeSessionFragment from '@/libs/vibe-session/fragment'; import type { Pubky } from '@/models/models.types'; +import { grantKeyRemovalFailed, isGrantKeyRemovalError } from '@/services/homeserver/error.utils'; import { HomeserverService } from '@/services/homeserver/homeserver'; import type { THomeserverSignUpParams } from '@/services/homeserver/homeserver.types'; import { MarketplaceSessionService } from '@/services/marketplace/marketplace-session'; @@ -266,6 +267,111 @@ describe('AuthApplication', () => { expect(sleepSpy).not.toHaveBeenCalled(); }); + describe('grant session record (Bitkit sign-in)', () => { + const grantStore = () => + mockAuthStore({ + sessionExport: null, + grantSessionRecordId: 'rec-1', + isRestoringSession: false, + setIsRestoringSession: vi.fn(), + init: vi.fn(), + }); + const grantSession = () => + asOpaque({ grant: {}, info: { publicKey: asOpaque({ z32: () => 'user-pubky' }) } }); + + it('reload restores grant session from store', async () => { + const session = grantSession(); + const restoreGrantSpy = vi.spyOn(HomeserverService, 'restoreGrantSession').mockResolvedValue(session); + const cookieRestoreSpy = vi.spyOn(HomeserverService, 'restoreSession'); + vi.spyOn(HomeserverService, 'assertUserHomeserverAllowed').mockResolvedValue(undefined); + + const result = await AuthApplication.restorePersistedSession({ authStore: grantStore() }); + + expect(result).toEqual({ status: 'restored', session }); + expect(restoreGrantSpy).toHaveBeenCalledWith('rec-1'); + expect(cookieRestoreSpy).not.toHaveBeenCalled(); + }); + + it('restore never calls save', async () => { + vi.spyOn(HomeserverService, 'restoreGrantSession').mockResolvedValue(grantSession()); + vi.spyOn(HomeserverService, 'assertUserHomeserverAllowed').mockResolvedValue(undefined); + const saveSpy = vi.spyOn(HomeserverService, 'saveGrantSession'); + + await AuthApplication.restorePersistedSession({ authStore: grantStore() }); + + expect(saveSpy).not.toHaveBeenCalled(); + }); + + it('reload after grant expiry shows signed-out and drops the record', async () => { + vi.spyOn(HomeserverService, 'restoreGrantSession').mockRejectedValue(createAuthError()); + const removeSpy = vi.spyOn(HomeserverService, 'removeGrantSession').mockResolvedValue(undefined); + + const result = await AuthApplication.restorePersistedSession({ authStore: grantStore() }); + + expect(result).toEqual({ status: 'signed-out' }); + expect(removeSpy).toHaveBeenCalledWith('rec-1'); + }); + + it('expiry cleanup rejects instead of signing out when the grant key cannot be removed', async () => { + vi.spyOn(HomeserverService, 'restoreGrantSession').mockRejectedValue(createAuthError()); + const removeSpy = vi + .spyOn(HomeserverService, 'removeGrantSession') + .mockRejectedValue(grantKeyRemovalFailed('removeGrantSession', null)); + + const failure = await AuthApplication.restorePersistedSession({ authStore: grantStore() }).catch( + (error: unknown) => error, + ); + + expect(isGrantKeyRemovalError(failure)).toBe(true); + // One attempt plus two retries before giving up. + expect(removeSpy).toHaveBeenCalledTimes(3); + }); + + it('expiry cleanup signs out once a retried removal succeeds', async () => { + vi.spyOn(HomeserverService, 'restoreGrantSession').mockRejectedValue(createAuthError()); + const removeSpy = vi + .spyOn(HomeserverService, 'removeGrantSession') + .mockRejectedValueOnce(grantKeyRemovalFailed('removeGrantSession', null)) + .mockResolvedValueOnce(undefined); + + await expect(AuthApplication.restorePersistedSession({ authStore: grantStore() })).resolves.toEqual({ + status: 'signed-out', + }); + expect(removeSpy).toHaveBeenCalledTimes(2); + }); + + it('keeps the record on a transient restore failure', async () => { + vi.spyOn(HomeserverService, 'restoreGrantSession').mockRejectedValue(createNetworkError()); + const removeSpy = vi.spyOn(HomeserverService, 'removeGrantSession').mockResolvedValue(undefined); + + const result = await AuthApplication.restorePersistedSession({ authStore: grantStore() }); + + expect(result).toEqual({ status: 'deferred' }); + expect(removeSpy).not.toHaveBeenCalled(); + }); + + it('consumer mode ignores grant record id: no bridge, no cookie restore', async () => { + const originSpy = vi + .spyOn(vibeSessionConfig, 'getVibeSessionBridgeOrigin') + .mockReturnValue('https://pubky.app'); + vi.spyOn(HomeserverService, 'restoreGrantSession').mockRejectedValue(createAuthError()); + vi.spyOn(HomeserverService, 'removeGrantSession').mockResolvedValue(undefined); + const cookieRestoreSpy = vi.spyOn(HomeserverService, 'restoreSession'); + const bridgeSpy = vi.spyOn(vibeSessionBridge, 'requestFromBridge'); + + try { + const result = await AuthApplication.restorePersistedSession({ authStore: grantStore() }); + + expect(result).toEqual({ status: 'signed-out' }); + expect(cookieRestoreSpy).not.toHaveBeenCalled(); + expect(bridgeSpy).not.toHaveBeenCalled(); + } finally { + originSpy.mockRestore(); + bridgeSpy.mockRestore(); + } + }); + }); + it('should return null when sessionExport is missing', async () => { const authStore = createMockAuthStore(null); diff --git a/src/core/application/auth/auth.ts b/src/core/application/auth/auth.ts index a572a94582..e1a603e4a2 100644 --- a/src/core/application/auth/auth.ts +++ b/src/core/application/auth/auth.ts @@ -87,6 +87,20 @@ export class AuthApplication { return await this.restoreSessionPromise; } + // A grant session (Bitkit sign-in) restores from BrowserSessionStore only: + // it has no cookie export and never takes the bridge or fragment legs. + const grantRecordId = authStore.grantSessionRecordId; + if (grantRecordId) { + this.restoreSessionPromise = (async () => { + try { + return await this.restoreGrantSession(grantRecordId); + } finally { + this.restoreSessionPromise = null; + } + })(); + return await this.restoreSessionPromise; + } + const consumerOrigin = getVibeSessionBridgeOrigin(); const persistedExport = authStore.sessionExport; @@ -169,6 +183,39 @@ export class AuthApplication { return keepPersistedExport ? { status: 'deferred' } : { status: 'signed-out' }; } + /** + * Restores a grant session from its BrowserSessionStore record. A transient + * failure keeps the record (deferred); a definitive one removes it and signs + * out, and a failed removal rejects so the record pointer is kept. There is + * no cookie fallback for a grant sign-in. + */ + private static async restoreGrantSession(recordId: string): TRestoreSessionResult { + let session: Session | null = null; + try { + session = await HomeserverService.restoreGrantSession(recordId); + await HomeserverService.assertUserHomeserverAllowed({ publicKey: session.info.publicKey }); + return { status: 'restored', session }; + } catch (error) { + if (isWrongEnvironmentHomeserverError(error)) { + if (session) { + await HomeserverService.logout({ session }).catch((logoutError) => { + Logger.warn('Failed to sign out wrong-environment grant session', { logoutError }); + }); + } + await this.removeGrantSession(recordId); + throw error; + } + if (session === null && !isDefinitiveSessionAuthFailure(error) && isAppError(error) && isRetryable(error)) { + Logger.warn('Grant session restore failed with a transient error; keeping the record', { error }); + return { status: 'deferred' }; + } + Logger.info('Grant session could not be restored; removing its record', { error }); + // Rejects while the record is still stored: the caller keeps the pointer. + await this.removeGrantSession(recordId); + return { status: 'signed-out' }; + } + } + private static async restoreSessionFromExport( sessionExport: string, ): Promise<{ session: Session; lastError?: undefined } | { session: null; lastError: unknown }> { @@ -302,6 +349,49 @@ export class AuthApplication { return await HomeserverService.generateAuthUrl(); } + /** Grant sign-in URL (`pubkyauth://signin_grant`) for signers such as Bitkit. */ + static async generateGrantAuthUrl(): Promise { + return await HomeserverService.generateGrantAuthUrl(); + } + + static isGrantSignInAvailable(): boolean { + return HomeserverService.isGrantSignInAvailable(); + } + + static isGrantSession(session: Session | null | undefined): boolean { + return HomeserverService.isGrantSession(session); + } + + static async saveGrantSession(session: Session): Promise { + return await HomeserverService.saveGrantSession(session); + } + + /** Removes one stored grant session and its key; rejects while it is still stored. */ + static async removeGrantSession(recordId: string): Promise { + await this.withGrantKeyRemovalRetry(() => HomeserverService.removeGrantSession(recordId)); + } + + /** Removes every stored grant session and key for this origin; rejects while any remains. */ + static async clearGrantSessions(): Promise { + await this.withGrantKeyRemovalRetry(() => HomeserverService.clearGrantSessions()); + } + + private static readonly GRANT_KEY_REMOVAL_RETRY_DELAYS_MS = [100, 400]; + + private static async withGrantKeyRemovalRetry(remove: () => Promise): Promise { + for (let attempt = 0; ; attempt += 1) { + try { + await remove(); + return; + } catch (error) { + const delay = this.GRANT_KEY_REMOVAL_RETRY_DELAYS_MS[attempt]; + if (delay === undefined) throw error; + Logger.warn('Grant key removal failed; retrying', { attempt: attempt + 1 }); + await sleep(delay); + } + } + } + static startDirectSignInFlow(): TGenerateAuthTokenFlowResult { return HomeserverService.generateAuthTokenFlow(CAPABILITIES); } diff --git a/src/core/controllers/auth/auth-epoch.ts b/src/core/controllers/auth/auth-epoch.ts new file mode 100644 index 0000000000..b7a8226e3a --- /dev/null +++ b/src/core/controllers/auth/auth-epoch.ts @@ -0,0 +1,49 @@ +/** + * Cross-tab sign-out fence for grant sessions. + * + * `authEpoch` is bumped inside the auth finalization lock on every sign-out. + * A grant sign-in captures it when its QR starts and saves the session to + * BrowserSessionStore only if the epoch is unchanged inside the same lock, so + * a tab that signed out cannot have its key put back by another tab. + */ +export const AUTH_EPOCH_KEY = 'pubky-auth-epoch-v1'; +export const AUTH_CHANNEL_NAME = 'pubky-auth-v1'; + +type AuthChannelMessage = { type: 'signed-out' }; + +function storage(): Storage | null { + try { + return typeof localStorage === 'undefined' ? null : localStorage; + } catch { + return null; + } +} + +export function readAuthEpoch(): number { + const raw = storage()?.getItem(AUTH_EPOCH_KEY); + const value = raw ? Number.parseInt(raw, 10) : 0; + return Number.isSafeInteger(value) && value >= 0 ? value : 0; +} + +/** Call only inside the auth finalization lock. */ +export function bumpAuthEpoch(): number { + const next = readAuthEpoch() + 1; + storage()?.setItem(AUTH_EPOCH_KEY, String(next)); + return next; +} + +export function broadcastSignedOut(): void { + if (typeof BroadcastChannel === 'undefined') return; + const channel = new BroadcastChannel(AUTH_CHANNEL_NAME); + channel.postMessage({ type: 'signed-out' } satisfies AuthChannelMessage); + channel.close(); +} + +export function subscribeSignedOut(onSignedOut: () => void): () => void { + if (typeof BroadcastChannel === 'undefined') return () => {}; + const channel = new BroadcastChannel(AUTH_CHANNEL_NAME); + channel.onmessage = (event: MessageEvent) => { + if (event.data?.type === 'signed-out') onSignedOut(); + }; + return () => channel.close(); +} diff --git a/src/core/controllers/auth/auth-identity-guard.ts b/src/core/controllers/auth/auth-identity-guard.ts index 239b11f602..7fc7a02779 100644 --- a/src/core/controllers/auth/auth-identity-guard.ts +++ b/src/core/controllers/auth/auth-identity-guard.ts @@ -6,6 +6,7 @@ export type CapturedAuthIdentity = { export type AuthIdentitySnapshot = { session?: unknown; sessionExport?: unknown; + grantSessionRecordId?: unknown; currentUserPubky?: unknown; }; @@ -20,7 +21,11 @@ export function nonEmptyPubky(value: unknown): string | null { function identityPresent(live: AuthIdentitySnapshot, persistedIdentityPresent: boolean): boolean { return Boolean( - live.session || live.sessionExport || nonEmptyPubky(live.currentUserPubky) || persistedIdentityPresent, + live.session || + live.sessionExport || + live.grantSessionRecordId || + nonEmptyPubky(live.currentUserPubky) || + persistedIdentityPresent, ); } diff --git a/src/core/controllers/auth/auth.test.ts b/src/core/controllers/auth/auth.test.ts index d628c9525a..d11513c19b 100644 --- a/src/core/controllers/auth/auth.test.ts +++ b/src/core/controllers/auth/auth.test.ts @@ -1,3 +1,4 @@ +import type { Session } from '@synonymdev/pubky'; import { LastReadResult } from 'pubky-app-specs'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { AuthApplication } from '@/application/auth/auth'; @@ -6,6 +7,7 @@ import { CommerceApplication } from '@/application/commerce/commerce'; import { SettingsApplication } from '@/application/settings/settings'; import { postStreamQueue } from '@/application/stream/posts/muting/post-stream-queue'; 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 { CommerceController } from '@/controllers/commerce/commerce'; import { NotificationCoordinator } from '@/coordinators/notifications/notifications'; @@ -26,6 +28,7 @@ import { NotificationType } from '@/models/notification/notification.types'; import { NotificationNormalizer } from '@/pipes/notification/notification.normalizer'; import { PubkySpecsSingleton } from '@/pipes/pipes.builder'; import { SettingsNormalizer } from '@/pipes/settings/settings.normalizer'; +import { grantKeyRemovalFailed, isGrantKeyRemovalError } from '@/services/homeserver/error.utils'; import { useAuthStore } from '@/stores/auth/auth.store'; import type { AuthStore } from '@/stores/auth/auth.types'; import { useCommerceStore } from '@/stores/commerce/commerce.store'; @@ -349,6 +352,9 @@ describe('AuthController', () => { resetAuthFinalizationLockForTests(); // Default: homeserver environment check passes (non-staging test config / allowed key) vi.spyOn(AuthApplication, 'assertUserHomeserverAllowed').mockResolvedValue(undefined); + // Default: the grant key store is empty; grant tests override these. + vi.spyOn(AuthApplication, 'clearGrantSessions').mockResolvedValue(undefined); + vi.spyOn(AuthApplication, 'removeGrantSession').mockResolvedValue(undefined); // Re-apply factory implementations: vi.restoreAllMocks() in afterEach can // clear vi.fn implementations whenever a property has been spied on, which // would leave subsequent tests with empty mocks returning undefined. @@ -2388,4 +2394,293 @@ describe('AuthController', () => { expect(result).toBe('invalid'); }); }); + + describe('grant sessions (Bitkit sign-in)', () => { + const grantSession = () => buildMockSession({ grant: asOpaque({}) }); + + const grantAuthStore = (overrides: Partial = {}): AuthStore => + mockAuthStore({ + ...storeMocks.getAuthState(), + currentUserPubky: TEST_PUBKY as Pubky, + session: null, + sessionExport: null, + grantSessionRecordId: null, + hasProfile: false, + isRestoringSession: false, + isLoggingOut: false, + setIsLoggingOut: vi.fn(), + setIsRestoringSession: vi.fn(), + setSessionRestoreDeferred: vi.fn(), + ...overrides, + }); + + beforeEach(() => { + storeMocks.resetAuthStore.mockReset(); + localStorage.removeItem(AUTH_EPOCH_KEY); + Object.defineProperty(document, 'cookie', { writable: true, value: '' }); + mockClearDatabase.mockResolvedValue(undefined); + vi.spyOn(Identity, 'z32FromSession').mockReturnValue(TEST_PUBKY as Pubky); + vi.spyOn(AuthApplication, 'userIsSignedUp').mockResolvedValue(false); + }); + + async function approveGrantSignIn(session: Session) { + vi.spyOn(AuthApplication, 'generateGrantAuthUrl').mockResolvedValue({ + authorizationUrl: 'pubkyauth://signin_grant?caps=x&relay=r&secret=s&cid=shop.pubky.app&cpk=k', + awaitApproval: Promise.resolve(session), + cancelAuthFlow: vi.fn(), + }); + const { awaitApproval } = await AuthController.getGrantAuthUrl(); + return await awaitApproval; + } + + it('grant sign-in persists record id not export', async () => { + const session = grantSession(); + const authStore = grantAuthStore(); + vi.spyOn(useAuthStore, 'getState').mockReturnValue(authStore); + const saveSpy = vi.spyOn(AuthApplication, 'saveGrantSession').mockResolvedValue('rec-1'); + + await AuthController.initializeAuthenticatedSession({ session: await approveGrantSignIn(session) }); + + expect(saveSpy).toHaveBeenCalledTimes(1); + expect(saveSpy).toHaveBeenCalledWith(session); + expect(authStore.init).toHaveBeenCalledWith( + expect.objectContaining({ session, currentUserPubky: TEST_PUBKY, grantSessionRecordId: 'rec-1' }), + ); + }); + + it('save aborts after a sign-out since QR start', async () => { + const session = grantSession(); + const authStore = grantAuthStore(); + vi.spyOn(useAuthStore, 'getState').mockReturnValue(authStore); + const saveSpy = vi.spyOn(AuthApplication, 'saveGrantSession').mockResolvedValue('rec-1'); + const logoutSpy = vi.spyOn(AuthApplication, 'logout').mockResolvedValue(undefined); + + const approved = await approveGrantSignIn(session); + // Another tab (or this one) signed out between QR start and completion. + bumpAuthEpoch(); + + await expect(AuthController.initializeAuthenticatedSession({ session: approved })).rejects.toBeDefined(); + expect(saveSpy).not.toHaveBeenCalled(); + expect(authStore.init).not.toHaveBeenCalled(); + expect(logoutSpy).toHaveBeenCalledWith({ session }); + }); + + it('second tab cannot save after sign-out', async () => { + // Tab B started its grant QR; tab A then signed out (epoch bumped under the lock). + const session = grantSession(); + vi.spyOn(useAuthStore, 'getState').mockReturnValue(grantAuthStore()); + const saveSpy = vi.spyOn(AuthApplication, 'saveGrantSession').mockResolvedValue('rec-2'); + vi.spyOn(AuthApplication, 'logout').mockResolvedValue(undefined); + const approved = await approveGrantSignIn(session); + localStorage.setItem(AUTH_EPOCH_KEY, String(readAuthEpoch() + 1)); + + await expect(AuthController.initializeAuthenticatedSession({ session: approved })).rejects.toBeDefined(); + expect(saveSpy).not.toHaveBeenCalled(); + }); + + it('signout calls signout then clearAll under lock', async () => { + const order: string[] = []; + const session = grantSession(); + vi.spyOn(useAuthStore, 'getState').mockReturnValue(grantAuthStore({ session, grantSessionRecordId: 'rec-1' })); + vi.spyOn(AuthApplication, 'logout').mockImplementation(async () => { + order.push('signout'); + }); + vi.spyOn(AuthApplication, 'clearGrantSessions').mockImplementation(async () => { + order.push('clearAll'); + }); + const epochBefore = readAuthEpoch(); + + await AuthController.logout(); + + expect(order).toEqual(['signout', 'clearAll']); + expect(readAuthEpoch()).toBe(epochBefore + 1); + }); + + it('cold logout calls signout before clearAll', async () => { + const order: string[] = []; + const session = grantSession(); + const authStore = grantAuthStore({ session: null, grantSessionRecordId: 'rec-1' }); + vi.spyOn(useAuthStore, 'getState').mockReturnValue(authStore); + vi.spyOn(AuthController, 'restorePersistedSession').mockImplementation(async () => { + order.push('restore'); + authStore.session = session; + return { status: 'restored' }; + }); + vi.spyOn(AuthApplication, 'logout').mockImplementation(async () => { + order.push('signout'); + }); + vi.spyOn(AuthApplication, 'clearGrantSessions').mockImplementation(async () => { + order.push('clearAll'); + }); + + await AuthController.logout(); + + expect(order).toEqual(['restore', 'signout', 'clearAll']); + }); + + it('cold logout still clears grant keys when the restore fails', async () => { + const authStore = grantAuthStore({ session: null, grantSessionRecordId: 'rec-1' }); + vi.spyOn(useAuthStore, 'getState').mockReturnValue(authStore); + vi.spyOn(AuthController, 'restorePersistedSession').mockResolvedValue({ status: 'signed-out' }); + const logoutSpy = vi.spyOn(AuthApplication, 'logout').mockResolvedValue(undefined); + const clearSpy = vi.spyOn(AuthApplication, 'clearGrantSessions').mockResolvedValue(undefined); + + await AuthController.logout(); + + expect(logoutSpy).not.toHaveBeenCalled(); + expect(clearSpy).toHaveBeenCalledTimes(1); + }); + + function listenForSignedOut() { + const heard = vi.fn(); + const unsubscribe = subscribeSignedOut(heard); + return { heard, unsubscribe }; + } + + it('warm logout removes the grant key before it clears the record pointer', async () => { + const order: string[] = []; + vi.spyOn(useAuthStore, 'getState').mockReturnValue( + grantAuthStore({ session: grantSession(), grantSessionRecordId: 'rec-1' }), + ); + vi.spyOn(AuthApplication, 'logout').mockImplementation(async () => { + order.push('signout'); + }); + vi.spyOn(AuthApplication, 'clearGrantSessions').mockImplementation(async () => { + order.push('clearAll'); + }); + storeMocks.resetAuthStore.mockImplementation(() => { + order.push('clear-pointer'); + }); + + await AuthController.logout(); + + expect(order).toEqual(['signout', 'clearAll', 'clear-pointer']); + }); + + it('warm logout fails and keeps the record pointer when the grant key cannot be removed', async () => { + const authStore = grantAuthStore({ session: grantSession(), grantSessionRecordId: 'rec-1' }); + vi.spyOn(useAuthStore, 'getState').mockReturnValue(authStore); + vi.spyOn(AuthApplication, 'logout').mockResolvedValue(undefined); + vi.spyOn(AuthApplication, 'clearGrantSessions').mockRejectedValue( + grantKeyRemovalFailed('clearGrantSessions', null), + ); + const { heard, unsubscribe } = listenForSignedOut(); + + try { + const failure = await AuthController.logout().catch((error: unknown) => error); + + expect(isGrantKeyRemovalError(failure)).toBe(true); + expect(storeMocks.resetAuthStore).not.toHaveBeenCalled(); + expect(authStore.setIsLoggingOut).toHaveBeenLastCalledWith(false); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(heard).not.toHaveBeenCalled(); + } finally { + unsubscribe(); + } + }); + + it('cold logout fails and keeps the record pointer when the grant key cannot be removed', async () => { + const authStore = grantAuthStore({ session: null, grantSessionRecordId: 'rec-1' }); + vi.spyOn(useAuthStore, 'getState').mockReturnValue(authStore); + // The restore could not remove its record either, so it kept the pointer. + vi.spyOn(AuthController, 'restorePersistedSession').mockResolvedValue({ status: 'deferred' }); + vi.spyOn(AuthApplication, 'clearGrantSessions').mockRejectedValue( + grantKeyRemovalFailed('clearGrantSessions', null), + ); + const { heard, unsubscribe } = listenForSignedOut(); + + try { + const failure = await AuthController.logout().catch((error: unknown) => error); + + expect(isGrantKeyRemovalError(failure)).toBe(true); + expect(storeMocks.resetAuthStore).not.toHaveBeenCalled(); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(heard).not.toHaveBeenCalled(); + } finally { + unsubscribe(); + } + }); + + it('expiry cleanup keeps the record pointer when its grant key cannot be removed', async () => { + const authStore = grantAuthStore({ session: null, grantSessionRecordId: 'rec-1' }); + vi.spyOn(useAuthStore, 'getState').mockReturnValue(authStore); + vi.spyOn(AuthApplication, 'restorePersistedSession').mockRejectedValue( + grantKeyRemovalFailed('removeGrantSession', null), + ); + + await expect(AuthController.restorePersistedSession()).resolves.toEqual({ status: 'deferred' }); + + expect(authStore.setSessionRestoreDeferred).toHaveBeenCalledWith(true); + expect(storeMocks.resetAuthStore).not.toHaveBeenCalled(); + }); + + it('cross-tab finalization removes this tab record before it clears the pointer', async () => { + const order: string[] = []; + vi.spyOn(useAuthStore, 'getState').mockReturnValue( + grantAuthStore({ session: grantSession(), grantSessionRecordId: 'rec-1' }), + ); + vi.spyOn(AuthApplication, 'logout').mockResolvedValue(undefined); + const removeSpy = vi.spyOn(AuthApplication, 'removeGrantSession').mockImplementation(async () => { + order.push('remove'); + }); + storeMocks.resetAuthStore.mockImplementation(() => { + order.push('clear-pointer'); + }); + + await AuthController.handleCrossTabSignOut(); + + expect(removeSpy).toHaveBeenCalledWith('rec-1'); + expect(order).toEqual(['remove', 'clear-pointer']); + }); + + it('cross-tab finalization keeps the tab signed in when its grant key cannot be removed', async () => { + vi.spyOn(useAuthStore, 'getState').mockReturnValue( + grantAuthStore({ session: grantSession(), grantSessionRecordId: 'rec-1' }), + ); + vi.spyOn(AuthApplication, 'logout').mockResolvedValue(undefined); + vi.spyOn(AuthApplication, 'removeGrantSession').mockRejectedValue( + grantKeyRemovalFailed('removeGrantSession', null), + ); + + await AuthController.handleCrossTabSignOut(); + + expect(storeMocks.resetAuthStore).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); + vi.spyOn(AuthApplication, 'clearGrantSessions').mockResolvedValue(undefined); + const received = new Promise((resolve) => { + const unsubscribe = subscribeSignedOut(() => { + unsubscribe(); + resolve(); + }); + }); + + await AuthController.logout(); + + await expect(received).resolves.toBeUndefined(); + }); + + it('second tab drops live grant session on broadcast', async () => { + const session = grantSession(); + vi.spyOn(useAuthStore, 'getState').mockReturnValue(grantAuthStore({ session })); + const logoutSpy = vi.spyOn(AuthApplication, 'logout').mockResolvedValue(undefined); + + await AuthController.handleCrossTabSignOut(); + + expect(logoutSpy).toHaveBeenCalledWith({ session }); + expect(storeMocks.resetAuthStore).toHaveBeenCalled(); + }); + + it('a cookie session ignores the cross-tab broadcast', async () => { + vi.spyOn(useAuthStore, 'getState').mockReturnValue(grantAuthStore({ session: buildMockSession() })); + const logoutSpy = vi.spyOn(AuthApplication, 'logout').mockResolvedValue(undefined); + + await AuthController.handleCrossTabSignOut(); + + expect(logoutSpy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/core/controllers/auth/auth.ts b/src/core/controllers/auth/auth.ts index 403b5d5a89..f7582eeec7 100644 --- a/src/core/controllers/auth/auth.ts +++ b/src/core/controllers/auth/auth.ts @@ -18,6 +18,7 @@ import type { TLoginWithMnemonicParams, TSignUpParams, } from '@/controllers/auth/auth.types'; +import { broadcastSignedOut, bumpAuthEpoch, readAuthEpoch, subscribeSignedOut } from '@/controllers/auth/auth-epoch'; import { withAuthFinalizationLock } from '@/controllers/auth/auth-finalization-lock'; import { captureAuthIdentityFromStore, @@ -47,7 +48,7 @@ import { NotificationNormalizer } from '@/pipes/notification/notification.normal import { PubkySpecsSingleton } from '@/pipes/pipes.builder'; import { SettingsNormalizer } from '@/pipes/settings/settings.normalizer'; import { clearRouteGuardReturnTo } from '@/providers/RouteGuardProvider/RouteGuardProvider.returnPath'; -import { createCanceledError } from '@/services/homeserver/error.utils'; +import { createCanceledError, isGrantKeyRemovalError } from '@/services/homeserver/error.utils'; import type { TGenerateAuthUrlResult, THomeserverSessionResult } from '@/services/homeserver/homeserver.types'; import type { MarketplaceSessionFlow } from '@/services/marketplace/marketplace-session'; import { @@ -107,6 +108,9 @@ export class AuthController { */ private static logoutGeneration = 0; + /** `authEpoch` read when each grant sign-in QR started, keyed by its approved session. */ + private static grantEpochAtStart = new WeakMap(); + /** * Single-run guard for cleanupLocalState: concurrent Controller invocations * (e.g. a logout racing an in-flight restore) share one run, and once a run @@ -199,6 +203,9 @@ export class AuthController { // Application restore is in flight invalidates this invocation's // restored-branch finalization (compared again right before `init`). const logoutGenerationAtStart = this.logoutGeneration; + // Captured before cleanup can reset the store: a restored grant session + // keeps its BrowserSessionStore record (restore never saves). + const restoredGrantRecordId = authStore.grantSessionRecordId; // The Controller owns the restore loading flag for the whole flow: set once // before restore begins, cleared once after finalization. With a fresh-vibe // bridge restore (sessionExport === null) the isSessionRestorePending @@ -244,6 +251,9 @@ export class AuthController { session, currentUserPubky: pubky, hasProfile, + ...(AuthApplication.isGrantSession(session) && restoredGrantRecordId + ? { grantSessionRecordId: restoredGrantRecordId } + : {}), }); }); if (!persisted) { @@ -273,6 +283,13 @@ export class AuthController { cleanedUp = true; return { status: 'signed-out' }; } catch (error) { + if (isGrantKeyRemovalError(error)) { + // The grant record and its key are still stored: keep the pointer so + // the next restore or sign-out retries the removal. Not signed-out. + Logger.error('Grant session key could not be removed; keeping its record for a retry', { error }); + authStore.setSessionRestoreDeferred(true); + return { status: 'deferred' }; + } const appError = toAppError(error, ErrorService.Local, 'restorePersistedSession'); if (!cleanedUp) { await this.finalizeSignedOutUnderLock({ @@ -454,8 +471,24 @@ export class AuthController { // the persisted identity and skips. A different account that signed in // after this ceremony captured local state aborts persist rather than // overwriting that account's Dexie rows. - const persisted = await this.persistIdentityUnderLock(pubky, this.pendingLocalStateCapture, () => { - authStore.init({ session, currentUserPubky: pubky, hasProfile: null }); + const persisted = await this.persistIdentityUnderLock(pubky, this.pendingLocalStateCapture, async () => { + let grantSessionRecordId: string | null = null; + if (AuthApplication.isGrantSession(session)) { + // Inside the finalization lock: a sign-out since this QR started + // (any tab) bumped the epoch, and the key must not be saved back. + const epochAtStart = this.grantEpochAtStart.get(session); + if (epochAtStart === undefined || epochAtStart !== readAuthEpoch()) { + return false; + } + grantSessionRecordId = await AuthApplication.saveGrantSession(session); + } + authStore.init({ + session, + currentUserPubky: pubky, + hasProfile: null, + ...(grantSessionRecordId ? { grantSessionRecordId } : {}), + }); + return true; }); if (!persisted) { persistAborted = true; @@ -641,7 +674,7 @@ export class AuthController { private static async persistIdentityUnderLock( newPubky: string, captured: CapturedAuthIdentity | null, - persist: () => void, + persist: () => void | boolean | Promise, ): Promise { return await withAuthFinalizationLock(async () => { const currentPubky = nonEmptyPubky(useAuthStore.getState().currentUserPubky); @@ -655,7 +688,10 @@ export class AuthController { if (persistedPubky && persistedPubky !== newPubky) { clearPersistedAuthIdentity(); } - persist(); + if ((await persist()) === false) { + this.pendingLocalStateCapture = null; + return false; + } this.markLocalStateDirty(); this.pendingLocalStateCapture = null; return true; @@ -769,6 +805,27 @@ export class AuthController { return this.wrapDirectSignInCeremony({ preserveLocalState: false }); } + /** + * Bitkit sign-in: a grant QR (`pubkyauth://signin_grant`) beside the Ring + * cookie QR. The approved grant session skips the marketplace redeem (it + * carries no AuthToken) and is saved to BrowserSessionStore at completion. + */ + static async getGrantAuthUrl(): Promise { + const epochAtStart = readAuthEpoch(); + const result = await this.wrapAuthFlow(() => AuthApplication.generateGrantAuthUrl()); + return { + ...result, + awaitApproval: result.awaitApproval.then((session) => { + this.grantEpochAtStart.set(session, epochAtStart); + return session; + }), + }; + } + + static isGrantSignInAvailable(): boolean { + return AuthApplication.isGrantSignInAvailable(); + } + static async getStepUpAuthUrl(): Promise { if (!isSingleApprovalSignInEnabled()) { return this.wrapAuthFlow(() => AuthApplication.generateAuthUrl(), { preserveLocalState: true }); @@ -1105,27 +1162,35 @@ export class AuthController { authStore.setSessionRestoreDeferred(false); let session = authStore.session; + let signedOut = false; try { // Fresh loads can still have a persisted session export before the live session is restored. // Reuse the restore flow so /logout performs a real homeserver sign-out before local cleanup. - if (!session && (authStore.sessionExport || isVibeSessionConsumerEnabled())) { + if (!session && (authStore.sessionExport || authStore.grantSessionRecordId || isVibeSessionConsumerEnabled())) { + let restoreResult: TRestorePersistedSessionResult; try { - const restoreResult = await this.restorePersistedSession(); - if (restoreResult.status === 'signed-out') { - return; - } - if (restoreResult.status === 'deferred') { - Logger.warn('Homeserver logout failed, clearing local state anyway', { - error: 'Session restore deferred; homeserver sign-out could not run', - }); - await this.finalizeSignedOutUnderLock({ captured, preservePublicCache: false }); - return; - } + restoreResult = await this.restorePersistedSession(); } catch (error) { // restorePersistedSession already cleaned up local state; a wrong-environment // rejection needs no toast here — the user asked to log out anyway. Logger.warn('Persisted session restore during logout failed; local state already cleaned up', { error }); + await this.removeGrantKeysUnderLock(); + signedOut = true; + return; + } + if (restoreResult.status === 'signed-out') { + await this.removeGrantKeysUnderLock(); + signedOut = true; + return; + } + if (restoreResult.status === 'deferred') { + Logger.warn('Homeserver logout failed, clearing local state anyway', { + error: 'Session restore deferred; homeserver sign-out could not run', + }); + await this.removeGrantKeysUnderLock(); + await this.finalizeSignedOutUnderLock({ captured, preservePublicCache: false }); + signedOut = true; return; } authStore = useAuthStore.getState(); @@ -1140,11 +1205,21 @@ export class AuthController { } } + // After the homeserver sign-out (it needs the grant key) and before the + // record pointer is cleared: a key that cannot be removed keeps the + // pointer and fails the logout instead of reporting signed-out. + await this.removeGrantKeysUnderLock(); // Serialized with restore finalization and sign-in identity persists. // Cleanup is keyed to the identity captured at logout start: a // different live pubky means that sign-in now owns origin-scoped Dexie. await this.finalizeSignedOutUnderLock({ captured, preservePublicCache: false }); + signedOut = true; } finally { + if (signedOut) { + broadcastSignedOut(); + } else { + useAuthStore.getState().setIsLoggingOut(false); + } // The internal restore's init() clears the auto-restore suppression // set above. If the homeserver sign-out then failed, the marker must // not stay cleared: a later reload in consumer mode would @@ -1156,6 +1231,48 @@ export class AuthController { } } + /** + * Bumps the auth epoch and removes every stored grant session and key under + * the finalization lock, so no tab can save a grant key after this sign-out. + * Rejects while any stored record remains. + */ + private static async removeGrantKeysUnderLock(): Promise { + await withAuthFinalizationLock(async () => { + bumpAuthEpoch(); + await AuthApplication.clearGrantSessions(); + }); + } + + /** + * 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. + */ + static subscribeCrossTabSignOut(): () => void { + return subscribeSignedOut(() => void this.handleCrossTabSignOut()); + } + + static async handleCrossTabSignOut(): Promise { + const { session, grantSessionRecordId } = useAuthStore.getState(); + if (!AuthApplication.isGrantSession(session) || !session) return; + const captured = this.captureAuthIdentity(); + await AuthApplication.logout({ session }).catch((error) => { + Logger.warn('Cross-tab grant sign-out could not reach the homeserver', { error }); + }); + this.cancelActiveAuthFlow(); + if (grantSessionRecordId) { + try { + // Only this tab's record: a newer sign-in in another tab keeps its key. + await AuthApplication.removeGrantSession(grantSessionRecordId); + } catch (error) { + Logger.error('Cross-tab sign-out could not remove the grant key of this tab; keeping it signed in', { + error, + }); + return; + } + } + await this.finalizeSignedOutUnderLock({ captured, preservePublicCache: false }); + } + /** * Initializes the application bootstrap after profile creation. * Waits for Nexus to index the user's profile.json, then bootstraps notifications and data. diff --git a/src/core/services/homeserver/error.utils.ts b/src/core/services/homeserver/error.utils.ts index 54b0ffb8c4..f423355eef 100644 --- a/src/core/services/homeserver/error.utils.ts +++ b/src/core/services/homeserver/error.utils.ts @@ -1,8 +1,14 @@ import { AppError } from '@/libs/error/error'; -import { AuthErrorCode, NetworkErrorCode, ServerErrorCode, ValidationErrorCode } from '@/libs/error/error.codes'; +import { + AuthErrorCode, + DatabaseErrorCode, + NetworkErrorCode, + ServerErrorCode, + ValidationErrorCode, +} from '@/libs/error/error.codes'; import { Err } from '@/libs/error/error.factories'; import { httpStatusCodeToError } from '@/libs/error/error.http'; -import { ErrorService } from '@/libs/error/error.types'; +import { ErrorCategory, ErrorService } from '@/libs/error/error.types'; import { HttpStatusCode } from '@/libs/http/http.types'; import type { THandleErrorParams, @@ -15,6 +21,30 @@ import type { export const AUTH_FLOW_CANCELED_ERROR_NAME = 'AuthFlowCanceled'; +const GRANT_KEY_REMOVAL_OPERATION = 'removeGrantKeyMaterial'; + +/** + * A stored grant session record (and its delegated key) is still on this + * device after a delete. Callers keep their record pointer and must not + * report signed-out. + */ +export function grantKeyRemovalFailed(step: 'removeGrantSession' | 'clearGrantSessions', cause: unknown): AppError { + return Err.database( + DatabaseErrorCode.DELETE_FAILED, + 'This device still holds a Bitkit sign-in key that could not be removed.', + { service: ErrorService.Homeserver, operation: GRANT_KEY_REMOVAL_OPERATION, cause, context: { step } }, + ); +} + +export function isGrantKeyRemovalError(error: unknown): boolean { + return ( + error instanceof AppError && + error.category === ErrorCategory.Database && + error.code === DatabaseErrorCode.DELETE_FAILED && + error.operation === GRANT_KEY_REMOVAL_OPERATION + ); +} + /** Pubky SDK error names for type-safe error handling */ const PUBKY_ERROR_NAMES = { INVALID_INPUT: 'InvalidInput', diff --git a/src/core/services/homeserver/homeserver.test.ts b/src/core/services/homeserver/homeserver.test.ts index 64eb1bff53..8115f1ecbe 100644 --- a/src/core/services/homeserver/homeserver.test.ts +++ b/src/core/services/homeserver/homeserver.test.ts @@ -43,6 +43,11 @@ const mockState = vi.hoisted(() => ({ getHomeserverOf: vi.fn(), restoreSession: vi.fn(), sessionRestore: vi.fn(), + grantStartDelegated: vi.fn(), + grantStoreRemove: vi.fn(), + grantStoreClearAll: vi.fn(), + grantStoreList: vi.fn(), + grantStoreIsAvailable: vi.fn(), startAuthFlow: vi.fn(), authFlowKindSignin: vi.fn(), authTokenFromBytes: vi.fn(), @@ -96,6 +101,12 @@ vi.mock('@synonymdev/pubky', () => { client: { fetch: (...args: unknown[]) => mockState.clientFetch(...args), }, + browserSessionStore: { + remove: (...args: unknown[]) => mockState.grantStoreRemove(...args), + clearAll: (...args: unknown[]) => mockState.grantStoreClearAll(...args), + list: (...args: unknown[]) => mockState.grantStoreList(...args), + isAvailable: (...args: unknown[]) => mockState.grantStoreIsAvailable(...args), + }, publicStorage: { get: (...args: unknown[]) => mockState.publicStorageGet(...args), exists: (...args: unknown[]) => mockState.publicStorageExists(...args), @@ -119,6 +130,10 @@ vi.mock('@synonymdev/pubky', () => { Session: { restore: (...args: unknown[]) => mockState.sessionRestore(...args), }, + GrantAuthFlow: { + startDelegated: (...args: unknown[]) => mockState.grantStartDelegated(...args), + isDelegationAvailable: true, + }, PublicKey: { from: vi.fn().mockReturnValue({ z32: () => 'homeserver-public-key-z32', @@ -859,6 +874,100 @@ describe('HomeserverService', () => { }); }); + describe('generateGrantAuthUrl (Bitkit sign-in)', () => { + it('bitkit qr is signin_grant with shop caps and cid', async () => { + const free = vi.fn(); + mockState.grantStartDelegated.mockResolvedValue({ + authorizationUrl: 'pubkyauth://signin_grant?caps=x&relay=r&secret=s&cid=shop.pubky.app&cpk=k', + tryPollOnce: vi.fn().mockResolvedValue(undefined), + free, + }); + + const { authorizationUrl, awaitApproval, cancelAuthFlow } = await HomeserverService.generateGrantAuthUrl(); + awaitApproval.catch(() => undefined); + cancelAuthFlow(); + + expect(mockState.grantStartDelegated).toHaveBeenCalledWith(CAPABILITIES, 'signin-kind', { + clientId: 'shop.pubky.app', + relay: expect.any(String), + }); + expect(authorizationUrl.startsWith('pubkyauth://signin_grant')).toBe(true); + expect(free).toHaveBeenCalled(); + }); + + it('reports grant sign-in availability from the SDK', () => { + expect(HomeserverService.isGrantSignInAvailable()).toBe(true); + }); + }); + + describe('grant key removal (sign-out and expiry cleanup)', () => { + // After the module reset above: the error class must come from the same graph. + let isGrantKeyRemovalError: typeof import('./error.utils').isGrantKeyRemovalError; + + beforeEach(async () => { + ({ isGrantKeyRemovalError } = await import('./error.utils')); + mockState.grantStoreRemove.mockReset().mockResolvedValue(undefined); + mockState.grantStoreClearAll.mockReset().mockResolvedValue(undefined); + mockState.grantStoreList.mockReset().mockResolvedValue([]); + mockState.grantStoreIsAvailable.mockReset().mockResolvedValue(true); + }); + + it('remove rejects while the record is still stored, even when the SDK call resolved', async () => { + mockState.grantStoreList.mockResolvedValue([{ id: 'rec-1' }]); + + const failure = await HomeserverService.removeGrantSession('rec-1').catch((error: unknown) => error); + + expect(isGrantKeyRemovalError(failure)).toBe(true); + expect(mockState.grantStoreRemove).toHaveBeenCalledWith('rec-1'); + }); + + it('remove rejects when the SDK delete fails and the record is still stored', async () => { + mockState.grantStoreRemove.mockRejectedValue(new Error('IndexedDB delete failed')); + mockState.grantStoreList.mockResolvedValue([{ id: 'rec-1' }, { id: 'rec-2' }]); + + const failure = await HomeserverService.removeGrantSession('rec-1').catch((error: unknown) => error); + + expect(isGrantKeyRemovalError(failure)).toBe(true); + }); + + it('remove rejects when the store cannot be read back', async () => { + mockState.grantStoreList.mockRejectedValue(new Error('IndexedDB unavailable')); + + const failure = await HomeserverService.removeGrantSession('rec-1').catch((error: unknown) => error); + + expect(isGrantKeyRemovalError(failure)).toBe(true); + }); + + it('remove resolves once the record is gone, even if the SDK delete reported an error', async () => { + mockState.grantStoreRemove.mockRejectedValue(new Error('already deleted')); + mockState.grantStoreList.mockResolvedValue([{ id: 'rec-2' }]); + + await expect(HomeserverService.removeGrantSession('rec-1')).resolves.toBeUndefined(); + }); + + it('clearAll rejects while any record is still stored', async () => { + mockState.grantStoreClearAll.mockRejectedValue(new Error('IndexedDB clear failed')); + mockState.grantStoreList.mockResolvedValue([{ id: 'rec-1' }]); + + const failure = await HomeserverService.clearGrantSessions().catch((error: unknown) => error); + + expect(isGrantKeyRemovalError(failure)).toBe(true); + }); + + it('clearAll resolves when the store reads back empty', async () => { + await expect(HomeserverService.clearGrantSessions()).resolves.toBeUndefined(); + expect(mockState.grantStoreClearAll).toHaveBeenCalledTimes(1); + expect(mockState.grantStoreList).toHaveBeenCalledTimes(1); + }); + + it('clearAll is a no-op without IndexedDB persistence', async () => { + mockState.grantStoreIsAvailable.mockResolvedValue(false); + + await expect(HomeserverService.clearGrantSessions()).resolves.toBeUndefined(); + expect(mockState.grantStoreClearAll).not.toHaveBeenCalled(); + }); + }); + describe('restoreSession (cookie reload)', () => { it('reload restores cookie session via Session.restore', async () => { const restored = createMockSession(); diff --git a/src/core/services/homeserver/homeserver.ts b/src/core/services/homeserver/homeserver.ts index 43475fb696..b203cfd884 100644 --- a/src/core/services/homeserver/homeserver.ts +++ b/src/core/services/homeserver/homeserver.ts @@ -4,6 +4,7 @@ import { AuthToken, Capabilities, Client, + GrantAuthFlow, Keypair, Pubky, PublicKey, @@ -12,7 +13,7 @@ import { Signer, } from '@synonymdev/pubky'; import type { TKeypairParams } from '@/application/auth/auth.types'; -import { CAPABILITIES, capabilitiesMatchFullGrant } from '@/config/app'; +import { CAPABILITIES, capabilitiesMatchFullGrant, SHOP_GRANT_CLIENT_ID } from '@/config/app'; import { getDefaultHttpRelay, getDeployEnv, @@ -41,7 +42,7 @@ import type { TSignupTokenVerificationStatus, } from '@/services/homeserver/homeserver.types'; import { useAuthStore } from '@/stores/auth/auth.store'; -import { extractStatusCode, handleError } from './error.utils'; +import { extractStatusCode, grantKeyRemovalFailed, handleError } from './error.utils'; import type { TGenerateSignupAuthUrlParams, THomeserverFetchParams, @@ -650,6 +651,113 @@ export class HomeserverService { } } + /** + * Whether this browser can hold a grant key that JavaScript cannot read + * (secure context, IndexedDB, WebCrypto Ed25519). Without it the Shop does + * not offer the Bitkit sign-in. + */ + static isGrantSignInAvailable(): boolean { + try { + return GrantAuthFlow.isDelegationAvailable; + } catch { + return false; + } + } + + /** + * Starts a grant sign-in (`pubkyauth://signin_grant`) for signers that only + * accept grant URLs, such as Bitkit. The proof-of-possession key is a + * non-extractable WebCrypto key in IndexedDB; the approved session is + * grant-backed and never exported to JS-readable storage. + */ + static async generateGrantAuthUrl(): Promise { + try { + const flow = await GrantAuthFlow.startDelegated(CAPABILITIES, AuthFlowKind.signin(), { + clientId: SHOP_GRANT_CLIENT_ID, + relay: getDefaultHttpRelay(), + }); + const approval = createCancelableAuthApproval(flow); + return { + authorizationUrl: flow.authorizationUrl, + awaitApproval: approval.awaitApproval, + cancelAuthFlow: approval.cancel, + }; + } catch (error) { + return handleError({ error, additionalContext: { operation: 'generateGrantAuthUrl' } }); + } + } + + /** Whether a session is backed by a grant (Bitkit sign-in) rather than a homeserver cookie. */ + static isGrantSession(session: Session | null | undefined): boolean { + return Boolean(session && session.grant !== undefined); + } + + /** Persists a completed grant session in IndexedDB and returns its record id. */ + static async saveGrantSession(session: Session): Promise { + try { + const stored = await this.getPubkySdk().browserSessionStore.save(session); + return stored.id; + } catch (error) { + return handleError({ error, additionalContext: { operation: 'saveGrantSession' } }); + } + } + + /** Restores a grant session saved by {@link saveGrantSession}. Never saves. */ + static async restoreGrantSession(recordId: string): Promise { + try { + return await this.getPubkySdk().browserSessionStore.restore(recordId); + } catch (error) { + return handleError({ error, additionalContext: { operation: 'restoreGrantSession' } }); + } + } + + /** + * Drops one stored grant session record and its delegated key, then reads + * the store back. Rejects while the record is still listed, so a caller + * never drops its pointer to key material that is still on disk. + */ + static async removeGrantSession(recordId: string): Promise { + const store = this.getPubkySdk().browserSessionStore; + let removeError: unknown; + try { + await store.remove(recordId); + } catch (error) { + removeError = error; + } + let remaining: string[]; + try { + remaining = (await store.list()).map((record) => record.id); + } catch (error) { + throw grantKeyRemovalFailed('removeGrantSession', error); + } + if (remaining.includes(recordId)) throw grantKeyRemovalFailed('removeGrantSession', removeError); + if (removeError) Logger.warn('Grant session remove reported an error, but the record is gone', { removeError }); + } + + /** + * Drops every stored grant session record and delegated key for this + * origin, then reads the store back. Rejects while any record is still + * listed. A browser without IndexedDB persistence holds no records. + */ + static async clearGrantSessions(): Promise { + const store = this.getPubkySdk().browserSessionStore; + let clearError: unknown; + try { + if (!(await store.isAvailable())) return; + await store.clearAll(); + } catch (error) { + clearError = error; + } + let remaining: number; + try { + remaining = (await store.list()).length; + } catch (error) { + throw grantKeyRemovalFailed('clearGrantSessions', error); + } + if (remaining > 0) throw grantKeyRemovalFailed('clearGrantSessions', clearError); + if (clearError) Logger.warn('Grant session clear reported an error, but no record remains', { clearError }); + } + /** * Generates an authentication signup URL for the homeserver. * diff --git a/src/core/services/homeserver/homeserver.utils.ts b/src/core/services/homeserver/homeserver.utils.ts index f738e11fea..4c60cdc3a7 100644 --- a/src/core/services/homeserver/homeserver.utils.ts +++ b/src/core/services/homeserver/homeserver.utils.ts @@ -169,7 +169,7 @@ export const parseResponseOrUndefined = async ({ * @returns CancelableAuthApproval with awaitApproval promise and cancel function */ export const createCancelableAuthApproval = ( - flow: AuthFlow, + flow: Pick, options?: { pollIntervalMs?: number; maxPollAttempts?: number }, ): CancelableAuthApproval => { const pollIntervalMs = options?.pollIntervalMs ?? AUTH_POLL_INTERVAL_MS; diff --git a/src/core/stores/auth/auth.actions.ts b/src/core/stores/auth/auth.actions.ts index 626af25c58..e69fe12973 100644 --- a/src/core/stores/auth/auth.actions.ts +++ b/src/core/stores/auth/auth.actions.ts @@ -6,6 +6,9 @@ import { AuthActions, AuthActionTypes, authInitialState, AuthInitParams, AuthSto const safeSessionExport = (session: Session | null): string | null => { if (!session) return null; + // A grant session's export carries no key; reload restores it from + // BrowserSessionStore instead (see `grantSessionRecordId`). + if (session.grant !== undefined) return null; try { if (typeof session.export === 'function') { return session.export(); @@ -18,13 +21,14 @@ const safeSessionExport = (session: Session | null): string | null => { // Actions/Mutators - State modification functions export const createAuthActions = (set: ZustandSet): AuthActions => ({ - init: ({ session, currentUserPubky, hasProfile }: AuthInitParams) => { + init: ({ session, currentUserPubky, hasProfile, grantSessionRecordId = null }: AuthInitParams) => { clearVibeSessionAutoRestoreSuppressed(); set( (state) => ({ ...state, session, sessionExport: safeSessionExport(session), + grantSessionRecordId: session && session.grant !== undefined ? grantSessionRecordId : null, currentUserPubky, hasProfile, sessionRestoreDeferred: false, diff --git a/src/core/stores/auth/auth.store.test.ts b/src/core/stores/auth/auth.store.test.ts index ebf92f6eda..31a3139af5 100644 --- a/src/core/stores/auth/auth.store.test.ts +++ b/src/core/stores/auth/auth.store.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import * as vibeSessionAutoRestore from '@/libs/vibe-session/auto-restore'; import * as vibeSessionConfig from '@/libs/vibe-session/config'; import type { THomeserverSessionResult } from '@/services/homeserver/homeserver.types'; +import { asOpaque } from '@/test-utils/type-assertions'; import { useAuthStore } from './auth.store'; // Mock the logger @@ -77,6 +78,60 @@ describe('AuthStore', () => { }); }); + describe('Grant sessions (Bitkit sign-in)', () => { + const grantSession = (exportSpy: () => string) => + asOpaque({ + info: { publicKey: { z32: () => 'grant-pubky' } }, + grant: {}, + export: exportSpy, + }); + + it('persists the grant record id and never exports a grant session', () => { + const exportSpy = vi.fn(() => 'grant-export'); + + useAuthStore.getState().init({ + session: grantSession(exportSpy), + currentUserPubky: 'grant-pubky', + hasProfile: null, + grantSessionRecordId: 'rec-1', + }); + + const state = useAuthStore.getState(); + expect(exportSpy).not.toHaveBeenCalled(); + expect(state.sessionExport).toBeNull(); + expect(state.grantSessionRecordId).toBe('rec-1'); + const persisted = JSON.parse(localStorage.getItem('auth-store') ?? '{}') as { + state?: { sessionExport?: unknown; grantSessionRecordId?: unknown }; + }; + expect(persisted.state?.sessionExport).toBeNull(); + expect(persisted.state?.grantSessionRecordId).toBe('rec-1'); + }); + + it('bridge never carries grant session: a grant session leaves no export to hand off', () => { + useAuthStore.getState().setSession(grantSession(() => 'grant-export')); + + expect(useAuthStore.getState().sessionExport).toBeNull(); + }); + + it('a cookie session keeps its export and carries no grant record', () => { + const cookieSession = asOpaque({ + info: { publicKey: { z32: () => 'cookie-pubky' } }, + grant: undefined, + export: () => 'cookie-export', + }); + + useAuthStore.getState().init({ + session: cookieSession, + currentUserPubky: 'cookie-pubky', + hasProfile: null, + grantSessionRecordId: 'ignored', + }); + + expect(useAuthStore.getState().sessionExport).toBe('cookie-export'); + expect(useAuthStore.getState().grantSessionRecordId).toBeNull(); + }); + }); + describe('Authentication Management', () => { it('should set currentUserPubky without affecting authentication state', () => { const pubky = 'test-pubky-key'; diff --git a/src/core/stores/auth/auth.store.ts b/src/core/stores/auth/auth.store.ts index b5f348e961..335d30e340 100644 --- a/src/core/stores/auth/auth.store.ts +++ b/src/core/stores/auth/auth.store.ts @@ -24,6 +24,7 @@ export function createAuthStore() { partialize: (state) => ({ currentUserPubky: state.currentUserPubky, sessionExport: state.sessionExport, + grantSessionRecordId: state.grantSessionRecordId, hasProfile: state.hasProfile, hasHydrated: false, // Will be set by rehydration handler }), @@ -32,7 +33,10 @@ export function createAuthStore() { onRehydrateStorage: (state) => (rehydratedState) => { const resolvedState = rehydratedState ?? state; resolvedState.setHasHydrated(true); - if (shouldAttemptSessionRestore(rehydratedState?.sessionExport)) { + if ( + shouldAttemptSessionRestore(rehydratedState?.sessionExport) || + Boolean(rehydratedState?.grantSessionRecordId) + ) { resolvedState.setIsRestoringSession(true); } }, diff --git a/src/core/stores/auth/auth.types.ts b/src/core/stores/auth/auth.types.ts index 5518f6df21..5b8291bb30 100644 --- a/src/core/stores/auth/auth.types.ts +++ b/src/core/stores/auth/auth.types.ts @@ -6,10 +6,16 @@ export interface AuthInitParams { session: Session | null; /** null = unknown/undetermined, false = no profile, true = has profile */ hasProfile: boolean | null; + /** + * `BrowserSessionStore` record of a grant-backed session (Bitkit sign-in). + * Grant sessions are never exported; reload restores from this record. + */ + grantSessionRecordId?: string | null; } export interface AuthState extends AuthInitParams { sessionExport: string | null; + grantSessionRecordId: string | null; hasHydrated: boolean; isRestoringSession: boolean; /** Whether the sign-in dialog is open (for unauthenticated users) */ @@ -51,6 +57,7 @@ export const authInitialState: AuthState = { currentUserPubky: null, session: null, sessionExport: null, + grantSessionRecordId: null, hasProfile: null, hasHydrated: false, isRestoringSession: false, diff --git a/src/hooks/useAuthStatus/useAuthStatus.tsx b/src/hooks/useAuthStatus/useAuthStatus.tsx index b9cea9c49c..8774d0d370 100644 --- a/src/hooks/useAuthStatus/useAuthStatus.tsx +++ b/src/hooks/useAuthStatus/useAuthStatus.tsx @@ -13,7 +13,9 @@ export function useAuthStatus(): AuthStatusResult { // before session (live auth object) is recreated. This flag prevents premature // redirects by keeping isLoading true until session restoration is completed. const isSessionRestorePending = - authStore.sessionExport !== null && authStore.session === null && !authStore.sessionRestoreDeferred; + (authStore.sessionExport !== null || Boolean(authStore.grantSessionRecordId)) && + authStore.session === null && + !authStore.sessionRestoreDeferred; const isLoading = !onboardingStore.hasHydrated || !authStore.hasHydrated || authStore.isRestoringSession || isSessionRestorePending; @@ -52,6 +54,7 @@ export function useAuthStatus(): AuthStatusResult { authStore.hasHydrated, authStore.isRestoringSession, authStore.sessionExport, + authStore.grantSessionRecordId, authStore.session, authStore.sessionRestoreDeferred, authStore.hasProfile, diff --git a/src/hooks/useAuthUrl/useAuthUrl.tsx b/src/hooks/useAuthUrl/useAuthUrl.tsx index bbab620b7b..9e80fa1c6e 100644 --- a/src/hooks/useAuthUrl/useAuthUrl.tsx +++ b/src/hooks/useAuthUrl/useAuthUrl.tsx @@ -47,7 +47,11 @@ export function useAuthUrl(options: UseAuthUrlOptions = {}): UseAuthUrlReturn { try { // Request auth URL from controller const { authorizationUrl, awaitApproval } = - type === 'signup' ? await AuthController.getSignupAuthUrl(inviteCode) : await AuthController.getAuthUrl(); + type === 'signup' + ? await AuthController.getSignupAuthUrl(inviteCode) + : type === 'grant' + ? await AuthController.getGrantAuthUrl() + : await AuthController.getAuthUrl(); awaitApproval .then(async (session: Session) => { diff --git a/src/hooks/useAuthUrl/useAuthUrl.types.ts b/src/hooks/useAuthUrl/useAuthUrl.types.ts index 8231097040..5fd5af8872 100644 --- a/src/hooks/useAuthUrl/useAuthUrl.types.ts +++ b/src/hooks/useAuthUrl/useAuthUrl.types.ts @@ -6,6 +6,12 @@ export type UseAuthUrlOptions = /** The type of auth URL to generate. @default 'signin' */ type?: 'signin'; } + | { + /** Whether to automatically fetch the auth URL on mount. @default true */ + autoFetch?: boolean; + /** Grant sign-in (`pubkyauth://signin_grant`) for signers such as Bitkit */ + type: 'grant'; + } | { /** Whether to automatically fetch the auth URL on mount. @default true */ autoFetch?: boolean; diff --git a/src/hooks/useGrantSignInAvailable/useGrantSignInAvailable.ts b/src/hooks/useGrantSignInAvailable/useGrantSignInAvailable.ts new file mode 100644 index 0000000000..87e89b5efa --- /dev/null +++ b/src/hooks/useGrantSignInAvailable/useGrantSignInAvailable.ts @@ -0,0 +1,16 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { AuthController } from '@/controllers/auth/auth'; + +/** + * Whether this browser can offer the Bitkit grant sign-in. Read after mount: + * the check needs `window` (secure context, IndexedDB, WebCrypto). + */ +export function useGrantSignInAvailable(): boolean { + const [isAvailable, setIsAvailable] = useState(false); + useEffect(() => { + setIsAvailable(AuthController.isGrantSignInAvailable()); + }, []); + return isAvailable; +} diff --git a/src/hooks/useIsGrantSession/useIsGrantSession.ts b/src/hooks/useIsGrantSession/useIsGrantSession.ts new file mode 100644 index 0000000000..3b6f8b614b --- /dev/null +++ b/src/hooks/useIsGrantSession/useIsGrantSession.ts @@ -0,0 +1,12 @@ +'use client'; + +import { useAuthStore } from '@/stores/auth/auth.store'; + +/** + * Whether the signed-in session is grant-backed (Bitkit sign-in). Such a + * session carries no AuthToken and no homeserver cookie, so the Shop's + * classic Pubky Ring approvals cannot run for it. + */ +export function useIsGrantSession(): boolean { + return useAuthStore((state) => state.session != null && state.session.grant !== undefined); +} diff --git a/src/providers/RouteGuardProvider/RouteGuardProvider.suppressed-reload.test.tsx b/src/providers/RouteGuardProvider/RouteGuardProvider.suppressed-reload.test.tsx index ba68f1c662..545c5b9902 100644 --- a/src/providers/RouteGuardProvider/RouteGuardProvider.suppressed-reload.test.tsx +++ b/src/providers/RouteGuardProvider/RouteGuardProvider.suppressed-reload.test.tsx @@ -12,6 +12,7 @@ import { RouteGuardProvider } from './RouteGuardProvider'; const mocks = vi.hoisted(() => ({ mockRouterPush: vi.fn(), + subscribeCrossTabSignOut: vi.fn(() => () => {}), restorePersistedSession: vi.fn().mockResolvedValue({ status: 'signed-out' }), pathname: '/home', })); @@ -61,6 +62,7 @@ vi.mock('@/stores/migration/migration.store', () => ({ vi.mock('@/controllers/auth/auth', () => ({ AuthController: { restorePersistedSession: mocks.restorePersistedSession, + subscribeCrossTabSignOut: mocks.subscribeCrossTabSignOut, }, })); diff --git a/src/providers/RouteGuardProvider/RouteGuardProvider.test.tsx b/src/providers/RouteGuardProvider/RouteGuardProvider.test.tsx index 2ee92447f3..19fb2d4664 100644 --- a/src/providers/RouteGuardProvider/RouteGuardProvider.test.tsx +++ b/src/providers/RouteGuardProvider/RouteGuardProvider.test.tsx @@ -10,6 +10,8 @@ import { RouteGuardProvider } from './RouteGuardProvider'; // Hoisted mocks const mocks = vi.hoisted(() => { + const unsubscribeCrossTabSignOut = vi.fn(); + const subscribeCrossTabSignOut = vi.fn(() => unsubscribeCrossTabSignOut); const mockRouterPush = vi.fn(); const mockRouterRefresh = vi.fn(); const mockResync = vi.fn(); @@ -19,6 +21,8 @@ const mocks = vi.hoisted(() => { const restorePersistedSession = vi.fn().mockResolvedValue(true); return { + subscribeCrossTabSignOut, + unsubscribeCrossTabSignOut, mockRouterPush, mockRouterRefresh, mockResync, @@ -149,6 +153,7 @@ vi.mock('@/libs/vibe-session/auto-restore', async (importOriginal) => { vi.mock('@/controllers/auth/auth', () => ({ AuthController: { restorePersistedSession: mocks.restorePersistedSession, + subscribeCrossTabSignOut: mocks.subscribeCrossTabSignOut, }, })); vi.mock('@/controllers/migration/migration', () => ({ diff --git a/src/providers/RouteGuardProvider/RouteGuardProvider.tsx b/src/providers/RouteGuardProvider/RouteGuardProvider.tsx index 5e02bab67f..d361533083 100644 --- a/src/providers/RouteGuardProvider/RouteGuardProvider.tsx +++ b/src/providers/RouteGuardProvider/RouteGuardProvider.tsx @@ -53,6 +53,7 @@ export function RouteGuardProvider({ children }: RouteGuardProviderProps) { const hasHydrated = useAuthStore((state) => state.hasHydrated); const session = useAuthStore((state) => state.session); const sessionExport = useAuthStore((state) => state.sessionExport); + const grantSessionRecordId = useAuthStore((state) => state.grantSessionRecordId); const isRestoringSession = useAuthStore((state) => state.isRestoringSession); const currentUserPubky = useAuthStore((state) => state.currentUserPubky); const wasDbReset = useMigrationStore((state) => state.wasDbReset); @@ -64,6 +65,9 @@ export function RouteGuardProvider({ children }: RouteGuardProviderProps) { // still running; skip so a second restore cannot race the first init. const isSessionRestoreInFlightRef = useRef(false); + // Another tab signed out: a grant session held in this tab lets go too. + useEffect(() => AuthController.subscribeCrossTabSignOut(), []); + // Attempt to restore an existing session snapshot on fresh loads. useEffect(() => { if (!hasHydrated) return; @@ -72,7 +76,7 @@ export function RouteGuardProvider({ children }: RouteGuardProviderProps) { // Shared with auth-store rehydrate so `isRestoringSession` is only set when // this effect will actually run restore (persist, or consumer + not suppressed // / pending `#s=`). After logout, suppression + empty persist skips both. - if (!shouldAttemptSessionRestore(sessionExport)) { + if (!grantSessionRecordId && !shouldAttemptSessionRestore(sessionExport)) { // Rehydrate set isRestoringSession when the predicate read true; if the // situation changed before this effect ran (e.g. logout suppression // landed in between), no restore will run — clear the flag so it cannot @@ -97,7 +101,7 @@ export function RouteGuardProvider({ children }: RouteGuardProviderProps) { .finally(() => { isSessionRestoreInFlightRef.current = false; }); - }, [hasHydrated, session, sessionExport, isRestoringSession]); + }, [hasHydrated, session, sessionExport, grantSessionRecordId, isRestoringSession]); // Post-migration re-sync: fetch critical homeserver data after DB recreation // TODO: Consider using BroadcastChannel to notify other browser tabs when DB was recreated / resync completed