Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions src/components/auth/LoginDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,100 @@ describe('LoginDialog', () => {
expect(onClose).not.toHaveBeenCalled();
});

// divine-web#485: a NIP-46 signer can answer with an auth challenge instead
// of a result. We open it in a tab, but that fires after an await so popup
// blockers routinely eat it — the dialog has to offer the link.
it('shows the auth challenge link when the browser blocks the popup', async () => {
const user = userEvent.setup();
mockLoginActions.bunker.mockImplementation(
async (_uri: string, options?: { onAuthChallenge?: (c: { url: string; opened: boolean }) => void }) => {
options?.onAuthChallenge?.({ url: 'https://signer.example/approve', opened: false });
return new Promise(() => {}); // still waiting on the signer
}
);

render(<LoginDialog isOpen onClose={vi.fn()} onLogin={vi.fn()} />);

await user.click(await screen.findByRole('tab', { name: /^Sign in$/i }));
await user.click(screen.getByRole('button', { name: /Use Nostr instead/i }));
await user.click(await screen.findByRole('tab', { name: /Bunker/i }));
fireEvent.change(screen.getByLabelText(/Bunker URI/i), {
target: { value: 'bunker://remote-signer.example?relay=wss%3A%2F%2Frelay.example' },
});
await user.click(screen.getByRole('button', { name: /Login with Bunker/i }));

const link = await screen.findByRole('link', { name: /Approve in your signer/i });
expect(link).toHaveAttribute('href', 'https://signer.example/approve');
// The signer picks this URL, so the destination must be visible rather
// than hidden behind our own localized label.
expect(screen.getByText('(signer.example)')).toBeInTheDocument();
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
});

it('stays quiet when the challenge tab opened successfully', async () => {
const user = userEvent.setup();
mockLoginActions.bunker.mockImplementation(
async (_uri: string, options?: { onAuthChallenge?: (c: { url: string; opened: boolean }) => void }) => {
options?.onAuthChallenge?.({ url: 'https://signer.example/approve', opened: true });
return new Promise(() => {});
}
);

render(<LoginDialog isOpen onClose={vi.fn()} onLogin={vi.fn()} />);

await user.click(await screen.findByRole('tab', { name: /^Sign in$/i }));
await user.click(screen.getByRole('button', { name: /Use Nostr instead/i }));
await user.click(await screen.findByRole('tab', { name: /Bunker/i }));
fireEvent.change(screen.getByLabelText(/Bunker URI/i), {
target: { value: 'bunker://remote-signer.example?relay=wss%3A%2F%2Frelay.example' },
});
await user.click(screen.getByRole('button', { name: /Login with Bunker/i }));

expect(screen.queryByRole('link', { name: /Approve in your signer/i })).toBeNull();
});

// LoginArea renders this dialog unconditionally and only toggles `isOpen`,
// so dismissing it leaves the NIP-46 handshake running. A signer approval
// that lands afterwards must not log the user in behind their back.
it('refuses to commit a bunker login the user dismissed while it was pending', async () => {
const user = userEvent.setup();
let capturedBeforeCommit: (() => boolean) | undefined;
mockLoginActions.bunker.mockImplementation(
async (_uri: string, options?: { beforeCommit?: () => boolean }) => {
capturedBeforeCommit = options?.beforeCommit;
return new Promise(() => {}); // signer still waiting on out-of-band approval
}
);

const { rerender } = render(
<LoginDialog isOpen onClose={vi.fn()} onLogin={vi.fn()} />
);

await user.click(await screen.findByRole('tab', { name: /^Sign in$/i }));
await user.click(screen.getByRole('button', { name: /Use Nostr instead/i }));
await user.click(await screen.findByRole('tab', { name: /Bunker/i }));
fireEvent.change(screen.getByLabelText(/Bunker URI/i), {
target: { value: 'bunker://remote-signer.example?relay=wss%3A%2F%2Frelay.example' },
});
await user.click(screen.getByRole('button', { name: /Login with Bunker/i }));

expect(capturedBeforeCommit?.()).toBe(true);

// User gives up and closes the dialog; the component stays mounted.
rerender(<LoginDialog isOpen={false} onClose={vi.fn()} onLogin={vi.fn()} />);

expect(capturedBeforeCommit?.()).toBe(false);

// Reopening must not re-arm the guard. Anything that calls
// openLoginDialog() later, a like button for instance, reopens this same
// mounted instance, and the abandoned approval can still be in flight
// because nothing bounds how long the signer takes.
rerender(<LoginDialog isOpen onClose={vi.fn()} onLogin={vi.fn()} />);

expect(capturedBeforeCommit?.()).toBe(false);
});

// fireEvent (not userEvent) so the local clipboard spy stays attached;
// userEvent.setup() installs its own navigator.clipboard stub.
it('aborts an in-flight nsec backup when the restriction engages mid-flight (real parent path)', async () => {
Expand Down
75 changes: 72 additions & 3 deletions src/components/auth/LoginDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,22 @@
const validateNsec = (nsec: string) => /^nsec1[a-zA-Z0-9]{58}$/.test(nsec);
const validateBunkerUri = (uri: string) => uri.startsWith('bunker://');

/** Host of a NIP-46 challenge URL, so the user can see where a link they were
* handed by a remote signer actually leads. Null if it will not parse. */
const getChallengeHost = (url: string): string | null => {
try {
return new URL(url).host;
} catch {
return null;
}
};

const LoginDialog: React.FC<LoginDialogProps> = ({ isOpen, onClose, onLogin }) => {
const { t } = useTranslation();
const [advancedOpen, setAdvancedOpen] = useState(false);
const [bunkerError, setBunkerError] = useState<string | null>(null);
const [bunkerUri, setBunkerUri] = useState('');
const [bunkerAuthUrl, setBunkerAuthUrl] = useState<string | null>(null);
const [generalError, setGeneralError] = useState<string | null>(null);
const [inviteConfigError, setInviteConfigError] = useState<string | null>(null);
const [inviteCode, setInviteCode] = useState('');
Expand Down Expand Up @@ -75,6 +86,16 @@
// check can resolve `protected` mid-interaction. The ref always holds the
// latest verdict so each signer-swap re-checks when it actually runs.
const keyHandoverRestrictedRef = useRef(false);
// LoginArea renders this dialog unconditionally and only toggles `isOpen`, so
// dismissing it never unmounts the component and never settles an in-flight
// bunker handshake. These track whether the attempt that is resolving is
// still the one the user is waiting on.
const isOpenRef = useRef(isOpen);
const bunkerAttemptRef = useRef(0);

useEffect(() => {
isOpenRef.current = isOpen;
}, [isOpen]);
keyHandoverRestrictedRef.current = keyHandoverRestricted;

// The render-side gates below stay closed synchronously; this only clears the
Expand All @@ -88,12 +109,20 @@

useEffect(() => {
if (!isOpen) {
// Retire any in-flight bunker attempt on close. Checking `isOpenRef`
// alone is not enough: this effect re-arms it when the dialog is reopened
// later, so an approval the user abandoned would land against a guard
// that passes again. Bumping the counter means a superseded attempt can
// never become current, whatever happens to `isOpen` afterwards, and it
// also stops a stale challenge writing into the fresh dialog's state.
bunkerAttemptRef.current++;
return;
}

setAdvancedOpen(false);
setBunkerError(null);
setBunkerUri('');
setBunkerAuthUrl(null);
setGeneralError(null);
setInviteConfigError(null);
setInviteCode('');
Expand Down Expand Up @@ -136,7 +165,7 @@
return () => {
isCancelled = true;
};
}, [isOpen]);

Check warning on line 168 in src/components/auth/LoginDialog.tsx

View workflow job for this annotation

GitHub Actions / test (20.x)

React Hook useEffect has a missing dependency: 't'. Either include it or remove the dependency array

const handleExtensionLogin = async () => {
if (keyHandoverRestrictedRef.current) return;
Expand Down Expand Up @@ -212,21 +241,38 @@
if (keyHandoverRestrictedRef.current) return;
setIsLoginLoading(true);
setBunkerError(null);
setBunkerAuthUrl(null);

const attempt = ++bunkerAttemptRef.current;
const isCurrentAttempt = () => attempt === bunkerAttemptRef.current;

try {
// Same commit-boundary re-check as the extension path: the pre-click
// check goes stale while the bunker connect is pending.
const committed = await login.bunker(bunkerUri, {
beforeCommit: () => !keyHandoverRestrictedRef.current,
// A NIP-46 handshake can sit unresolved indefinitely while the user
// approves out of band, and the dialog stays mounted the whole time. If
// they gave up and closed it, or started a fresh attempt, this one must
// not silently log them in and set the cross-subdomain cookie.
beforeCommit: () =>
!keyHandoverRestrictedRef.current && isOpenRef.current && isCurrentAttempt(),
// The signer wants the user to approve in its own UI. We open a tab
// for them; keep the URL around in case the popup was blocked.
onAuthChallenge: ({ url, opened }) => {
if (url && !opened && isCurrentAttempt()) setBunkerAuthUrl(url);
},
});
if (!committed) return;
onLogin();
onClose();
setBunkerUri('');
setBunkerAuthUrl(null);
} catch {
setBunkerError(t('loginDialog.errorBunkerConnectFailed'));
if (isCurrentAttempt()) setBunkerError(t('loginDialog.errorBunkerConnectFailed'));
} finally {
setIsLoginLoading(false);
// A superseded attempt settling must not clear the spinner belonging to
// the attempt the user is actually waiting on.
if (isCurrentAttempt()) setIsLoginLoading(false);
}
};

Expand Down Expand Up @@ -496,6 +542,29 @@
value={bunkerUri}
/>
{bunkerError ? <p className="text-sm text-red-500">{bunkerError}</p> : null}
{bunkerAuthUrl ? (
<div className="space-y-1 text-sm">
<p className="text-muted-foreground">{t('loginDialog.bunkerAuthPrompt')}</p>
<a
className="font-medium underline underline-offset-2"
href={bunkerAuthUrl}
rel="noopener noreferrer"
target="_blank"
>
{t('loginDialog.bunkerAuthLink')}
</a>
{/* The remote signer chooses this URL, so show where
the link actually goes rather than hiding an
arbitrary destination behind our own wording.
A hostname is data, not copy, so no locale
needs updating. */}
{getChallengeHost(bunkerAuthUrl) ? (
<span className="ml-1 text-muted-foreground break-all">
({getChallengeHost(bunkerAuthUrl)})
</span>
) : null}
</div>
) : null}
</div>

<Button
Expand Down
38 changes: 38 additions & 0 deletions src/hooks/useCurrentUser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const {
mockGetValidToken,
mockJwtSigner,
mockLogins,
mockReleaseBunkerSignersExcept,
mockUseAuthor,
} = vi.hoisted(() => ({
mockGetValidToken: vi.fn<() => string | null>(() => null),
Expand All @@ -16,9 +17,15 @@ const {
signEvent: vi.fn(),
},
mockLogins: [] as NLoginType[],
mockReleaseBunkerSignersExcept: vi.fn<(ids: Iterable<string>) => void>(),
mockUseAuthor: vi.fn<(pubkey?: string) => { data: Record<string, never> }>(() => ({ data: {} })),
}));

vi.mock('@/lib/bunkerSignerRegistry', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/bunkerSignerRegistry')>()),
releaseBunkerSignersExcept: (ids: Iterable<string>) => mockReleaseBunkerSignersExcept(ids),
}));

vi.mock('@nostrify/react', () => ({
useNostr: () => ({ nostr: {} }),
}));
Expand Down Expand Up @@ -74,9 +81,40 @@ describe('useCurrentUser', () => {
mockJwtSigner.getPublicKey.mockReset();
mockJwtSigner.signEvent.mockReset();
mockUseAuthor.mockClear();
mockReleaseBunkerSignersExcept.mockClear();
resetNostrProvider();
});

// A removed login's signer is the last thing holding its sockets open, and
// nothing else releases it.
describe('bunker signer release', () => {
const bunkerLogin = (id: string): NLoginType => ({
id,
type: 'bunker',
pubkey: id.slice(-64).padStart(64, 'd'),
createdAt: '2026-03-10T00:00:00.000Z',
data: {
bunkerPubkey: 'b'.repeat(64),
clientNsec: 'nsec1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq',
relays: ['wss://relay.example'],
},
});

it('keeps the signers of every surviving login', () => {
mockLogins.push(bunkerLogin('bunker:one'), bunkerLogin('bunker:two'));

renderHook(() => useCurrentUser());

expect(mockReleaseBunkerSignersExcept).toHaveBeenCalledWith(['bunker:one', 'bunker:two']);
});

it('releases everything once the last login is gone', () => {
renderHook(() => useCurrentUser());

expect(mockReleaseBunkerSignersExcept).toHaveBeenCalledWith([]);
});
});

afterEach(() => {
vi.restoreAllMocks();
resetNostrProvider();
Expand Down
16 changes: 12 additions & 4 deletions src/hooks/useCurrentUser.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { type NLoginType, NUser, useNostrLogin } from '@nostrify/react/login';
import { useNostr } from '@nostrify/react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { NostrSigner } from '@nostrify/nostrify';
import { DivineJWTSigner } from '@/lib/DivineJWTSigner';
import { createUserFromLogin, getSafeUserSigner } from '@/lib/nostrLogin';
import { releaseBunkerSignersExcept } from '@/lib/bunkerSignerRegistry';
import { selectCurrentUsers, isJwtResolving } from '@/lib/selectCurrentUsers';

import { useAuthor } from './useAuthor.ts';
Expand All @@ -23,7 +23,6 @@ type JwtResolution =
| { token: string; error: true };

export function useCurrentUser() {
const { nostr } = useNostr();
const { logins } = useNostrLogin();
const { getValidToken } = useDivineSession();
const token = getValidToken();
Expand All @@ -33,8 +32,8 @@ export function useCurrentUser() {
), [token]);

const loginToUser = useCallback((login: NLoginType): NUser => {
return createUserFromLogin(login, nostr);
}, [nostr]);
return createUserFromLogin(login);
}, []);

useEffect(() => {
let isCancelled = false;
Expand Down Expand Up @@ -74,6 +73,15 @@ export function useCurrentUser() {
const hasExtensionLogin = logins.some((login) => login.type === 'extension');
const nip07Status = useNip07Availability(hasExtensionLogin);

const loginIds = logins.map((login) => login.id).join(';');

// A removed login's signer is the last thing holding its sockets open.
// Reconciling against the surviving ids covers every logout path, including
// account switching and a login dropped as invalid.
useEffect(() => {
releaseBunkerSignersExcept(loginIds ? loginIds.split(';') : []);
}, [loginIds]);

const manualUsers = useMemo(() => {
const users: CurrentUser[] = [];

Expand Down
13 changes: 5 additions & 8 deletions src/hooks/useLoggedInAccounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useNostrLogin } from '@nostrify/react/login';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { NSchema as n, NostrEvent, NostrMetadata } from '@nostrify/nostrify';
import { createUserFromLogin } from '@/lib/nostrLogin';
import { canCreateUserFromLogin } from '@/lib/nostrLogin';
import { useCurrentUser } from './useCurrentUser';
import { useDivineSession } from './useDivineSession';
import { useNip07Availability } from './useNip07Availability';
Expand Down Expand Up @@ -31,14 +31,11 @@ export function useLoggedInAccounts() {
return false;
}

try {
createUserFromLogin(login, nostr);
return true;
} catch {
return false;
}
// Validity check only — the built user was always discarded here, and
// for a bunker login building one opens a connection.
return canCreateUserFromLogin(login);
}),
[logins, nostr, nip07Status],
[logins, nip07Status],
);

const jwtCurrentUser = useMemo<Account | undefined>(() => {
Expand Down
Loading
Loading