From 4a6bae95001096ae565028600ac158f6fc350b1a Mon Sep 17 00:00:00 2001 From: Bitcoin Error Log <18273620+BitcoinErrorLog@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:09:15 +0100 Subject: [PATCH 1/6] feat(auth): Bitkit sign-in through a grant QR beside the Ring QR Bitkit accepts only pubkyauth://signin_grant. The sign-in page now offers a Bitkit grant QR (GrantAuthFlow.startDelegated, client id shop.pubky.app, the Shop capabilities) next to the unchanged Ring cookie QR. A grant session is never exported: it is saved to BrowserSessionStore at sign-in completion, inside the auth finalization lock and only if the cross-tab auth epoch is unchanged since the QR started, and reload restores it from its record id. Sign-out, warm or cold, signs the session out, then bumps the epoch and clears BrowserSessionStore under the lock, and tells other tabs to drop a live grant session. Grant sessions carry no AuthToken, so the classic Pubky Ring approvals (purchases, step-up, inventory, messaging, manual Paykit claim) show a refusal instead of a QR. Lock Server connect stays open: its page decides which QR it offers. --- .../GrantSessionRefusal.tsx | 16 ++ .../molecules/QrCodeSlot/QrCodeSlot.tsx | 23 ++- .../molecules/QrCodeSlot/QrCodeSlot.types.ts | 2 + .../MarketplaceGetPaidSettings.tsx | 9 +- .../MarketplaceInventoryGrantDialog.tsx | 11 +- .../MarketplaceMessagingEnableDialog.tsx | 10 +- .../Marketplace/MarketplaceReauthDialog.tsx | 11 +- .../MarketplaceSessionConnectDialog.tsx | 11 +- src/components/organisms/SignIn/SignIn.tsx | 158 ++++++++++++++-- .../templates/Auth/Logout/Logout.tsx | 3 +- src/config/app.ts | 3 + src/core/application/auth/auth.ts | 66 +++++++ src/core/controllers/auth/auth-epoch.ts | 49 +++++ .../controllers/auth/auth-identity-guard.ts | 7 +- src/core/controllers/auth/auth.test.ts | 174 ++++++++++++++++++ src/core/controllers/auth/auth.ts | 87 ++++++++- src/core/services/homeserver/homeserver.ts | 86 ++++++++- .../services/homeserver/homeserver.utils.ts | 2 +- src/core/stores/auth/auth.actions.ts | 6 +- src/core/stores/auth/auth.store.test.ts | 55 ++++++ src/core/stores/auth/auth.store.ts | 6 +- src/core/stores/auth/auth.types.ts | 7 + src/hooks/useAuthStatus/useAuthStatus.tsx | 5 +- src/hooks/useAuthUrl/useAuthUrl.tsx | 6 +- src/hooks/useAuthUrl/useAuthUrl.types.ts | 6 + .../useGrantSignInAvailable.ts | 16 ++ .../useIsGrantSession/useIsGrantSession.ts | 12 ++ .../RouteGuardProvider/RouteGuardProvider.tsx | 8 +- 28 files changed, 801 insertions(+), 54 deletions(-) create mode 100644 src/components/molecules/GrantSessionRefusal/GrantSessionRefusal.tsx create mode 100644 src/core/controllers/auth/auth-epoch.ts create mode 100644 src/hooks/useGrantSignInAvailable/useGrantSignInAvailable.ts create mode 100644 src/hooks/useIsGrantSession/useIsGrantSession.ts 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/MarketplaceGetPaidSettings.tsx b/src/components/organisms/Marketplace/MarketplaceGetPaidSettings.tsx index c6a58962e3..4383b553f7 100644 --- a/src/components/organisms/Marketplace/MarketplaceGetPaidSettings.tsx +++ b/src/components/organisms/Marketplace/MarketplaceGetPaidSettings.tsx @@ -26,10 +26,12 @@ import { Switch } from '@/atoms/Switch/Switch'; import { Typography } from '@/atoms/Typography/Typography'; import { getLocksUrl } from '@/config/commerce'; import { CommerceController } from '@/controllers/commerce/commerce'; +import { useIsGrantSession } from '@/hooks/useIsGrantSession/useIsGrantSession'; import { useMarketplaceSellerPaymentConfig } from '@/hooks/useMarketplaceSellerPaymentConfig/useMarketplaceSellerPaymentConfig'; import { type SellerPaymentConfigOwnView } from '@/libs/commerce/payment-methods'; import { Logger } from '@/libs/logger/logger'; import { copyToClipboard } from '@/libs/utils/utils'; +import { GrantSessionRefusal } from '@/molecules/GrantSessionRefusal/GrantSessionRefusal'; import { QrCodeSlot } from '@/molecules/QrCodeSlot/QrCodeSlot'; import { toast } from '@/molecules/Toaster/use-toast'; import { MarketplaceSessionConnectDialog } from '@/organisms/Marketplace/MarketplaceSessionConnectDialog'; @@ -147,6 +149,7 @@ export function MarketplaceGetPaidSettings({ locksConnect, onSaved }: Marketplac } = locksConnect; const marketplaceSession = useCommerceStore((state) => state.marketplaceSession); const currentUserPubky = useAuthStore((state) => state.currentUserPubky); + const isGrantSession = useIsGrantSession(); const payments = useMarketplaceSellerPaymentConfig(); const refreshPaymentConfig = payments.refresh; const paykitIframeRef = useRef(null); @@ -258,7 +261,7 @@ export function MarketplaceGetPaidSettings({ locksConnect, onSaved }: Marketplac const onStartClaim = () => { setClaimDialogOpen(true); - payments.startClaim(xpubInput); + if (!isGrantSession) payments.startClaim(xpubInput); }; const onCloseClaimDialog = (open: boolean) => { @@ -691,7 +694,9 @@ export function MarketplaceGetPaidSettings({ locksConnect, onSaved }: Marketplac Approving on your signer registers the pasted account xpub with the Paykit server — exactly what Bitkit’s setup does. The approval is scoped to the Paykit receiver path and grants nothing else. - {payments.claimStatus === 'error' ? ( + {isGrantSession ? ( + + ) : payments.claimStatus === 'error' ? (
{payments.claimError} 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.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.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.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..f000c0a13c 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 === null; 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.ts b/src/core/application/auth/auth.ts index a572a94582..2cbe986ecb 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,37 @@ 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. 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 HomeserverService.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 }); + await HomeserverService.removeGrantSession(recordId); + return { status: 'signed-out' }; + } + } + private static async restoreSessionFromExport( sessionExport: string, ): Promise<{ session: Session; lastError?: undefined } | { session: null; lastError: unknown }> { @@ -302,6 +347,27 @@ 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); + } + + static async clearGrantSessions(): Promise { + await HomeserverService.clearGrantSessions(); + } + 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..81529b8d15 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'; @@ -2388,4 +2390,176 @@ 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(() => { + 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); + }); + + 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..6d5081a1cb 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, @@ -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,7 @@ export class AuthController { session, currentUserPubky: pubky, hasProfile, + grantSessionRecordId: AuthApplication.isGrantSession(session) ? restoredGrantRecordId : null, }); }); if (!persisted) { @@ -454,8 +462,19 @@ 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 }); + return true; }); if (!persisted) { persistAborted = true; @@ -641,7 +660,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 +674,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 +791,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 }); @@ -1109,7 +1152,7 @@ export class AuthController { 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())) { try { const restoreResult = await this.restorePersistedSession(); if (restoreResult.status === 'signed-out') { @@ -1145,6 +1188,10 @@ export class AuthController { // different live pubky means that sign-in now owns origin-scoped Dexie. await this.finalizeSignedOutUnderLock({ captured, preservePublicCache: false }); } finally { + // After the homeserver sign-out (or a failed cold restore): drop every + // stored grant key and fence other tabs, then tell them to let go. + await this.clearGrantPersistenceUnderLock(); + broadcastSignedOut(); // 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 +1203,36 @@ export class AuthController { } } + /** + * Bumps the auth epoch and clears BrowserSessionStore under the finalization + * lock, so no tab can save a grant key after this sign-out. + */ + private static async clearGrantPersistenceUnderLock(): 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 = useAuthStore.getState().session; + 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(); + 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/homeserver.ts b/src/core/services/homeserver/homeserver.ts index 43475fb696..257c0c8bb4 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, @@ -650,6 +651,89 @@ 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); + } + + /** Whether the current in-memory session is grant-backed. */ + static currentSessionIsGrant(): 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 { + 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 key. */ + static async removeGrantSession(recordId: string): Promise { + try { + await this.getPubkySdk().browserSessionStore.remove(recordId); + } catch (error) { + Logger.warn('Failed to remove a stored grant session', { error }); + } + } + + /** Drops every stored grant session record and delegated key for this origin. */ + static async clearGrantSessions(): Promise { + try { + await this.getPubkySdk().browserSessionStore.clearAll(); + } catch (error) { + Logger.warn('Failed to clear stored grant sessions', { error }); + } + } + /** * 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..225a4b197c 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 || authStore.grantSessionRecordId !== null) && + 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..7078db955c --- /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.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 From c660118be3633f5f837f856a0c4c52331447470f Mon Sep 17 00:00:00 2001 From: Bitcoin Error Log <18273620+BitcoinErrorLog@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:11:10 +0100 Subject: [PATCH 2/6] test(auth): stub the cross-tab sign-out subscription in RouteGuard tests --- .../RouteGuardProvider.suppressed-reload.test.tsx | 2 ++ src/providers/RouteGuardProvider/RouteGuardProvider.test.tsx | 5 +++++ 2 files changed, 7 insertions(+) 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', () => ({ From 27adf72f659f7c33a2712ec2a182f95bb14e1ff8 Mon Sep 17 00:00:00 2001 From: Bitcoin Error Log <18273620+BitcoinErrorLog@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:21:10 +0100 Subject: [PATCH 3/6] test(auth): pin the Bitkit sign-in contract rows Grant URL arguments, delegation-gated Bitkit switch, BrowserSessionStore restore without save, expiry and transient restore outcomes, consumer mode skipping the bridge for a grant record, and the refusal on the classic purchase, step-up, inventory and messaging approvals. --- .../MarketplaceGrantSessionRefusal.test.tsx | 79 +++++++++++++++++++ .../MarketplaceReauthDialog.test.tsx | 23 +++++- .../MarketplaceSessionConnectDialog.test.tsx | 30 ++++++- .../organisms/SignIn/SignIn.test.tsx | 54 ++++++++++++- src/core/application/auth/auth.test.ts | 77 ++++++++++++++++++ .../services/homeserver/homeserver.test.ts | 31 ++++++++ tsconfig.json | 7 +- 7 files changed, 296 insertions(+), 5 deletions(-) create mode 100644 src/components/organisms/Marketplace/MarketplaceGrantSessionRefusal.test.tsx 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/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/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/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/core/application/auth/auth.test.ts b/src/core/application/auth/auth.test.ts index b908e199bb..43eaa01b38 100644 --- a/src/core/application/auth/auth.test.ts +++ b/src/core/application/auth/auth.test.ts @@ -266,6 +266,83 @@ 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('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/services/homeserver/homeserver.test.ts b/src/core/services/homeserver/homeserver.test.ts index 64eb1bff53..2f82c25ee1 100644 --- a/src/core/services/homeserver/homeserver.test.ts +++ b/src/core/services/homeserver/homeserver.test.ts @@ -43,6 +43,7 @@ const mockState = vi.hoisted(() => ({ getHomeserverOf: vi.fn(), restoreSession: vi.fn(), sessionRestore: vi.fn(), + grantStartDelegated: vi.fn(), startAuthFlow: vi.fn(), authFlowKindSignin: vi.fn(), authTokenFromBytes: vi.fn(), @@ -119,6 +120,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 +864,32 @@ 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('restoreSession (cookie reload)', () => { it('reload restores cookie session via Session.restore', async () => { const restored = createMockSession(); diff --git a/tsconfig.json b/tsconfig.json index c9d631025f..1c4710d1e8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -85,7 +85,10 @@ "**/*.tsx", ".next/types/**/*.ts", "src/__tests__/page.test.jsx", - ".next/dev/types/**/*.ts" + ".next/dev/types/**/*.ts", + ".next/dev/dev/types/**/*.ts" ], - "exclude": ["node_modules"] + "exclude": [ + "node_modules" + ] } From ecec0c371f97630b62defb01f8bb5c7c3f883eaa Mon Sep 17 00:00:00 2001 From: Bitcoin Error Log <18273620+BitcoinErrorLog@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:21:22 +0100 Subject: [PATCH 4/6] chore: keep tsconfig.json as on the release branch --- tsconfig.json | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tsconfig.json b/tsconfig.json index 1c4710d1e8..c9d631025f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -85,10 +85,7 @@ "**/*.tsx", ".next/types/**/*.ts", "src/__tests__/page.test.jsx", - ".next/dev/types/**/*.ts", - ".next/dev/dev/types/**/*.ts" + ".next/dev/types/**/*.ts" ], - "exclude": [ - "node_modules" - ] + "exclude": ["node_modules"] } From c0107fa77b0f31584cf44cfa17108be1692f442c Mon Sep 17 00:00:00 2001 From: Bitcoin Error Log <18273620+BitcoinErrorLog@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:42:51 +0100 Subject: [PATCH 5/6] fix(auth): treat a missing grant record like none, and init only grant sessions with a record id --- src/components/templates/Auth/Logout/Logout.tsx | 2 +- src/core/controllers/auth/auth.ts | 11 +++++++++-- src/hooks/useAuthStatus/useAuthStatus.tsx | 2 +- src/hooks/useIsGrantSession/useIsGrantSession.ts | 2 +- 4 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/components/templates/Auth/Logout/Logout.tsx b/src/components/templates/Auth/Logout/Logout.tsx index f000c0a13c..c3848c58f6 100644 --- a/src/components/templates/Auth/Logout/Logout.tsx +++ b/src/components/templates/Auth/Logout/Logout.tsx @@ -42,7 +42,7 @@ export function Logout() { const [viewState, setViewState] = useState('idle'); const isHydrated = onboardingHasHydrated && authHasHydrated; - const isSignedOut = session === null && sessionExport === null && grantSessionRecordId === null; + const isSignedOut = session === null && sessionExport === null && !grantSessionRecordId; useEffect(() => { if (!isHydrated) return; diff --git a/src/core/controllers/auth/auth.ts b/src/core/controllers/auth/auth.ts index 6d5081a1cb..2caa1a1387 100644 --- a/src/core/controllers/auth/auth.ts +++ b/src/core/controllers/auth/auth.ts @@ -251,7 +251,9 @@ export class AuthController { session, currentUserPubky: pubky, hasProfile, - grantSessionRecordId: AuthApplication.isGrantSession(session) ? restoredGrantRecordId : null, + ...(AuthApplication.isGrantSession(session) && restoredGrantRecordId + ? { grantSessionRecordId: restoredGrantRecordId } + : {}), }); }); if (!persisted) { @@ -473,7 +475,12 @@ export class AuthController { } grantSessionRecordId = await AuthApplication.saveGrantSession(session); } - authStore.init({ session, currentUserPubky: pubky, hasProfile: null, grantSessionRecordId }); + authStore.init({ + session, + currentUserPubky: pubky, + hasProfile: null, + ...(grantSessionRecordId ? { grantSessionRecordId } : {}), + }); return true; }); if (!persisted) { diff --git a/src/hooks/useAuthStatus/useAuthStatus.tsx b/src/hooks/useAuthStatus/useAuthStatus.tsx index 225a4b197c..8774d0d370 100644 --- a/src/hooks/useAuthStatus/useAuthStatus.tsx +++ b/src/hooks/useAuthStatus/useAuthStatus.tsx @@ -13,7 +13,7 @@ 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.grantSessionRecordId !== null) && + (authStore.sessionExport !== null || Boolean(authStore.grantSessionRecordId)) && authStore.session === null && !authStore.sessionRestoreDeferred; diff --git a/src/hooks/useIsGrantSession/useIsGrantSession.ts b/src/hooks/useIsGrantSession/useIsGrantSession.ts index 7078db955c..3b6f8b614b 100644 --- a/src/hooks/useIsGrantSession/useIsGrantSession.ts +++ b/src/hooks/useIsGrantSession/useIsGrantSession.ts @@ -8,5 +8,5 @@ import { useAuthStore } from '@/stores/auth/auth.store'; * classic Pubky Ring approvals cannot run for it. */ export function useIsGrantSession(): boolean { - return useAuthStore((state) => state.session !== null && state.session.grant !== undefined); + return useAuthStore((state) => state.session != null && state.session.grant !== undefined); } From b18fb084dd933deb4fb3967e40abe92eebf2f76b Mon Sep 17 00:00:00 2001 From: Bitcoin Error Log <18273620+BitcoinErrorLog@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:18:49 +0100 Subject: [PATCH 6/6] fix(auth): never report signed-out while a grant key is still stored BrowserSessionStore remove() and clearAll() failures were logged and swallowed after the record pointer had already been cleared, so logout could succeed while a delegated key stayed in IndexedDB with no pointer left to retry the delete. Removal now reads the store back and rejects while the record (or any record, for clearAll) is still listed, with two retries in the application layer. Every path removes the key before it clears the pointer: warm and cold logout run the removal after the homeserver sign-out and before local cleanup, and fail without broadcasting when it cannot finish; an expired-grant restore keeps its record and reports deferred; a second tab removes its own record before it drops its session. The unused HomeserverService.currentSessionIsGrant is removed. --- src/core/application/auth/auth.test.ts | 29 +++++ src/core/application/auth/auth.ts | 32 ++++- src/core/controllers/auth/auth.test.ts | 121 ++++++++++++++++++ src/core/controllers/auth/auth.ts | 73 ++++++++--- src/core/services/homeserver/error.utils.ts | 34 ++++- .../services/homeserver/homeserver.test.ts | 78 +++++++++++ src/core/services/homeserver/homeserver.ts | 48 +++++-- 7 files changed, 377 insertions(+), 38 deletions(-) diff --git a/src/core/application/auth/auth.test.ts b/src/core/application/auth/auth.test.ts index 43eaa01b38..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'; @@ -311,6 +312,34 @@ describe('AuthApplication', () => { 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); diff --git a/src/core/application/auth/auth.ts b/src/core/application/auth/auth.ts index 2cbe986ecb..e1a603e4a2 100644 --- a/src/core/application/auth/auth.ts +++ b/src/core/application/auth/auth.ts @@ -186,7 +186,8 @@ export class AuthApplication { /** * Restores a grant session from its BrowserSessionStore record. A transient * failure keeps the record (deferred); a definitive one removes it and signs - * out. There is no cookie fallback for a grant sign-in. + * 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; @@ -201,7 +202,7 @@ export class AuthApplication { Logger.warn('Failed to sign out wrong-environment grant session', { logoutError }); }); } - await HomeserverService.removeGrantSession(recordId); + await this.removeGrantSession(recordId); throw error; } if (session === null && !isDefinitiveSessionAuthFailure(error) && isAppError(error) && isRetryable(error)) { @@ -209,7 +210,8 @@ export class AuthApplication { return { status: 'deferred' }; } Logger.info('Grant session could not be restored; removing its record', { error }); - await HomeserverService.removeGrantSession(recordId); + // Rejects while the record is still stored: the caller keeps the pointer. + await this.removeGrantSession(recordId); return { status: 'signed-out' }; } } @@ -364,8 +366,30 @@ export class AuthApplication { 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 HomeserverService.clearGrantSessions(); + 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 { diff --git a/src/core/controllers/auth/auth.test.ts b/src/core/controllers/auth/auth.test.ts index 81529b8d15..d11513c19b 100644 --- a/src/core/controllers/auth/auth.test.ts +++ b/src/core/controllers/auth/auth.test.ts @@ -28,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'; @@ -351,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. @@ -2411,6 +2415,7 @@ describe('AuthController', () => { }); beforeEach(() => { + storeMocks.resetAuthStore.mockReset(); localStorage.removeItem(AUTH_EPOCH_KEY); Object.defineProperty(document, 'cookie', { writable: true, value: '' }); mockClearDatabase.mockResolvedValue(undefined); @@ -2526,6 +2531,122 @@ describe('AuthController', () => { 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); diff --git a/src/core/controllers/auth/auth.ts b/src/core/controllers/auth/auth.ts index 2caa1a1387..f7582eeec7 100644 --- a/src/core/controllers/auth/auth.ts +++ b/src/core/controllers/auth/auth.ts @@ -48,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 { @@ -283,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({ @@ -1155,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 || 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(); @@ -1190,15 +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 { - // After the homeserver sign-out (or a failed cold restore): drop every - // stored grant key and fence other tabs, then tell them to let go. - await this.clearGrantPersistenceUnderLock(); - broadcastSignedOut(); + 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 @@ -1211,10 +1232,11 @@ export class AuthController { } /** - * Bumps the auth epoch and clears BrowserSessionStore under the finalization - * lock, so no tab can save a grant key after this sign-out. + * 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 clearGrantPersistenceUnderLock(): Promise { + private static async removeGrantKeysUnderLock(): Promise { await withAuthFinalizationLock(async () => { bumpAuthEpoch(); await AuthApplication.clearGrantSessions(); @@ -1230,13 +1252,24 @@ export class AuthController { } static async handleCrossTabSignOut(): Promise { - const session = useAuthStore.getState().session; + 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 }); } 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 2f82c25ee1..8115f1ecbe 100644 --- a/src/core/services/homeserver/homeserver.test.ts +++ b/src/core/services/homeserver/homeserver.test.ts @@ -44,6 +44,10 @@ const mockState = vi.hoisted(() => ({ 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(), @@ -97,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), @@ -890,6 +900,74 @@ describe('HomeserverService', () => { }); }); + 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 257c0c8bb4..b203cfd884 100644 --- a/src/core/services/homeserver/homeserver.ts +++ b/src/core/services/homeserver/homeserver.ts @@ -42,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, @@ -692,11 +692,6 @@ export class HomeserverService { return Boolean(session && session.grant !== undefined); } - /** Whether the current in-memory session is grant-backed. */ - static currentSessionIsGrant(): 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 { @@ -716,22 +711,51 @@ export class HomeserverService { } } - /** Drops one stored grant session record and its key. */ + /** + * 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 { - await this.getPubkySdk().browserSessionStore.remove(recordId); + remaining = (await store.list()).map((record) => record.id); } catch (error) { - Logger.warn('Failed to remove a stored grant session', { 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. */ + /** + * 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 { - await this.getPubkySdk().browserSessionStore.clearAll(); + remaining = (await store.list()).length; } catch (error) { - Logger.warn('Failed to clear stored grant sessions', { 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 }); } /**