Skip to content
Merged
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
58 changes: 58 additions & 0 deletions apps/web/src/auth/useAuth.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { EngineHeldElsewhereError } from '@cipherbox/client';
import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it } from 'vitest';
import { authStore } from '../stores/auth.store';
Expand Down Expand Up @@ -341,3 +342,60 @@ describe('useAuth', () => {
expect(rendered).not.toContain([...SECRET_BYTES].join(','));
});
});

describe('useAuth against an engine another account holds', () => {
beforeEach(() => authStore.signedOut());

it('renders the refusal as a signed-in-elsewhere state naming the holder', async () => {
const engine = fakeEngineClient({
start: () => Promise.reject(new EngineHeldElsewhereError('other-account')),
});
const coreKit = fakeCoreKitSession();
const { result } = mount(engine, coreKit);
await waitFor(() => expect(result.current.auth.isReady).toBe(true));

await act(async () => {
await expect(result.current.auth.loginWithGoogle(GOOGLE_ID_TOKEN)).rejects.toThrow();
});

expect(result.current.auth.heldElsewhere).toEqual({ heldBy: 'other-account' });
// A one-line banner cannot say what to do about it, so none is rendered.
expect(result.current.auth.error).toBeNull();
expect(authStore.getState().isAuthenticated).toBe(false);
// The credential the engine refused does not outlive the refusal.
expect(coreKit.calls.logouts).toBe(1);
});

it('reports a tab hosting the engine that has started none as holding no account', async () => {
const engine = fakeEngineClient({
start: () => Promise.reject(new EngineHeldElsewhereError(null)),
});
const { result } = mount(engine, fakeCoreKitSession());
await waitFor(() => expect(result.current.auth.isReady).toBe(true));

await act(async () => {
await expect(result.current.auth.loginWithGoogle(GOOGLE_ID_TOKEN)).rejects.toThrow();
});

expect(result.current.auth.heldElsewhere).toEqual({ heldBy: null });
});

it('clears the state when the next attempt begins', async () => {
let refuse = true;
const engine = fakeEngineClient({
start: () =>
refuse ? Promise.reject(new EngineHeldElsewhereError(null)) : Promise.resolve(),
});
const { result } = mount(engine, fakeCoreKitSession());
await waitFor(() => expect(result.current.auth.isReady).toBe(true));
await act(async () => {
await expect(result.current.auth.loginWithGoogle(GOOGLE_ID_TOKEN)).rejects.toThrow();
});

refuse = false;
await act(() => result.current.auth.loginWithGoogle(GOOGLE_ID_TOKEN));

expect(result.current.auth.heldElsewhere).toBeNull();
expect(authStore.getState()).toMatchObject({ isAuthenticated: true });
});
});
21 changes: 20 additions & 1 deletion apps/web/src/auth/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import { useCallback, useEffect, useMemo, useState } from 'react';
import { EngineHeldElsewhereError } from '@cipherbox/client';
import { createLoginFlow, RecoveryRequiredError, type LoginProgress } from '@cipherbox/login';
import { errorMessage } from '../lib/errorMessage';
import { authStore, useAuthState } from '../stores/auth.store';
Expand All @@ -15,6 +16,11 @@ import { useCoreKit } from './CoreKitProvider';
import { useIdentity } from './IdentityProvider';
import type { WebCollected } from './webCollector';

/** The origin's engine belongs to another account; `heldBy` names it. */
export interface HeldElsewhere {
heldBy: string | null;
}

export interface Auth {
isAuthenticated: boolean;
/** True while the tab is still assembling its engine or Core Kit session. */
Expand All @@ -28,6 +34,8 @@ export interface Auth {
isBusy: boolean;
/** The last failure, already stripped of anything secret-shaped. */
error: string | null;
/** Set when this tab was refused rather than served another account's vault. */
heldElsewhere: HeldElsewhere | null;
/** Exchanges a Google ID token collected on this host. */
loginWithGoogle(idToken: string): Promise<void>;
/** Asks CipherBox to deliver a verification code. */
Expand Down Expand Up @@ -60,6 +68,7 @@ export function useAuth(): Auth {

const [isBusy, setIsBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [heldElsewhere, setHeldElsewhere] = useState<HeldElsewhere | null>(null);

const isReady = client !== null && session !== null && status === 'ready';
const isSignedOut = !isAuthenticated && (isReady || status === 'unavailable');
Expand All @@ -69,8 +78,17 @@ export function useAuth(): Auth {
begin: () => {
setIsBusy(true);
setError(null);
setHeldElsewhere(null);
},
// A refusal by account is a state the front door renders in full, not a
// one-line failure: its message alone cannot say what to do about it.
failed: (failure) => {
if (failure instanceof EngineHeldElsewhereError) {
setHeldElsewhere({ heldBy: failure.heldBy });
return;
}
setError(errorMessage(failure));
},
failed: (failure) => setError(errorMessage(failure)),
end: () => setIsBusy(false),
}),
[]
Expand Down Expand Up @@ -166,6 +184,7 @@ export function useAuth(): Auth {
isSignedOut,
isBusy,
error: error ?? coreKitError,
heldElsewhere,
loginWithGoogle,
sendEmailCode: flow.sendEmailCode,
loginWithEmailCode,
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/components/auth/LoginError.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { ReactNode } from 'react';

/** The one error banner the login page and its methods both render. */
export function LoginError({ message }: { message: string }) {
export function LoginError({ message }: { message: ReactNode }) {
return (
<div className="login-error" role="alert" aria-live="polite">
{message}
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/components/auth/SignedInElsewhere.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { LoginError } from './LoginError';
import { shortAccountId } from '../../utils/format';

/**
* What a sign-in refused by the origin's one engine shows (`PortRequest`). Only
* the tab holding that engine can give it up, so the way out is stated here
* rather than offered as a button that cannot reach it.
*/
Comment thread
FSM1 marked this conversation as resolved.
export function SignedInElsewhere({ heldBy }: { heldBy: string | null }) {
return (
<LoginError
message={
<>
<p>
{heldBy === null
? 'another tab in this browser is running CipherBox and is not signed in.'
: `another account is already signed in to CipherBox in this browser: ${shortAccountId(heldBy)}.`}
</p>
<p>sign out in that tab, or close it, then sign in again here.</p>
</>
}
/>
);
}
3 changes: 3 additions & 0 deletions apps/web/src/routes/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { EmailLoginForm } from '../components/auth/EmailLoginForm';
import { GoogleLoginButton } from '../components/auth/GoogleLoginButton';
import { LoginError } from '../components/auth/LoginError';
import { RecoveryPhraseLogin } from '../components/auth/RecoveryPhraseLogin';
import { SignedInElsewhere } from '../components/auth/SignedInElsewhere';
import { WalletLoginButton } from '../components/auth/WalletLoginButton';
import { MatrixBackground } from '../components/MatrixBackground';
import { StagingBanner } from '../components/StagingBanner';
Expand All @@ -20,6 +21,7 @@ export function LoginPage() {
isReady,
isBusy,
error,
heldElsewhere,
loginWithGoogle,
sendEmailCode,
loginWithEmailCode,
Expand Down Expand Up @@ -86,6 +88,7 @@ export function LoginPage() {
</div>
)}

{heldElsewhere && <SignedInElsewhere heldBy={heldElsewhere.heldBy} />}
{error && !recoveryRequired && <LoginError message={error} />}
</div>
<footer className="login-footer">
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/styles/login.css
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,14 @@
text-align: center;
}

.login-error p {
margin: 0;
}

.login-error p + p {
margin-top: var(--spacing-xs);
}

.login-footer {
position: absolute;
bottom: 0;
Expand Down
18 changes: 18 additions & 0 deletions apps/web/src/utils/format.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';

import { shortAccountId } from './format';

describe('shortAccountId', () => {
it('takes both ends of a real account id, so two are told apart', () => {
const shared = 'ab'.repeat(32);
const first = shortAccountId(`${shared}-${'cd'.repeat(32)}`);
const second = shortAccountId(`${shared}-${'ef'.repeat(32)}`);

expect(first).toBe('ababab…cdcd');
expect(first).not.toBe(second);
});

it('leaves an id no longer than the elision it would apply', () => {
expect(shortAccountId('acct01')).toBe('acct01');
});
});
14 changes: 14 additions & 0 deletions apps/web/src/utils/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,17 @@ export function formatDate(timestampMillis: number): string {
day: 'numeric',
}).format(new Date(timestampMillis));
}

/**
* An account id is a secp256k1 public point written as two hex coordinates
* (`packages/login` `accountIdFromTssPoint`) — 129 characters, unreadable in a
* banner. This is the form a user can compare against another tab's, taken from
* both ends so two accounts sharing a leading run still read apart.
*/
const HEAD = 6;
const TAIL = 4;

export function shortAccountId(accountId: string): string {
if (accountId.length <= HEAD + TAIL + 1) return accountId;
return `${accountId.slice(0, HEAD)}…${accountId.slice(-TAIL)}`;
}
19 changes: 16 additions & 3 deletions packages/client/src/broadcast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
* nothing else. Every exchange that names or measures vault content, in either
* direction, rides the follower's private port: command arguments and upload
* chunks up, snapshot projections, file plaintext and the engine event stream
* down. The channel exists to rendezvous that port.
* down. The account each side holds rides it too, so the origin-wide channel
* never names one. The channel exists to rendezvous that port.
*
* Security shape, structural not by discipline:
* - The login **secret never crosses** — the keyless follower transport takes no
Expand Down Expand Up @@ -67,8 +68,13 @@ export type WireWrite =
* boundary.
*/
export type PortRequest =
/** Names the sender, binding the port to a client the leader can reclaim it for. */
| { type: 'cb:portHello'; clientId: string }
/**
* Names the sender and the account it is starting for, binding the port to a
* client the leader can reclaim it for. `accountId` is `null` before this tab
* has a session; the leader adopts the port only when it names the account its
* own engine holds, so one origin's single engine is never shared across two.
*/
| { type: 'cb:portHello'; clientId: string; accountId: string | null }
/** This tab's currently open folder (for the leader's focus-window union). */
| { type: 'cb:portFocus'; node: Uint8Array | null }
/** A correlated read; the leader answers with a matching `cb:portResult`. */
Expand All @@ -92,6 +98,13 @@ export type PortRequest =
export type PortResponse =
/** The leader adopted this port, naming the leadership that answers on it. */
| { type: 'cb:portReady'; token: string }
/**
* The leader will not serve this port: the greeting named an account other
* than the one its engine holds. `accountId` names that account, or is `null`
* when the hosting tab has started no engine — the follower needs it to say
* where the origin's engine went rather than only that it cannot have it.
*/
| { type: 'cb:portRefused'; token: string; accountId: string | null }
/** The leader is dropping this port. A closed `MessagePort` fires no event on
* the far side, so without this a read would wait on a wire that is gone. */
| { type: 'cb:portClosed' }
Expand Down
Loading