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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ jobs:
web:
- 'apps/web/**'
- 'packages/client/**'
- 'packages/login/**'
- 'crates/**'
- 'Cargo.toml'
- 'Cargo.lock'
Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
},
"dependencies": {
"@cipherbox/client": "workspace:*",
"@cipherbox/login": "workspace:*",
"@tanstack/react-query": "^5.90.2",
"@toruslabs/tss-dkls-lib": "^4.1.0",
"@web3auth/mpc-core-kit": "^3.5.0",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/auth/CoreKitProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
import { errorMessage } from '../lib/errorMessage';
import type { CoreKitSession } from './coreKit';
import type { CoreKitSession } from '@cipherbox/login';

/** Whether this tab knows if it has a session. */
export type CoreKitStatus = 'checking' | 'ready' | 'unavailable';
Expand Down
19 changes: 14 additions & 5 deletions apps/web/src/auth/IdentityProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createContext, useContext, useMemo, type ReactNode } from 'react';
import type { IdentityExchange } from './identityExchange';
import type { CredentialCollector, IdentityExchange } from '@cipherbox/login';
import { webCollector, type WebCollected } from './webCollector';

export interface IdentityContextValue {
exchange: IdentityExchange;
Expand All @@ -8,20 +9,28 @@ export interface IdentityContextValue {
* when the build carries none, which leaves that method unavailable.
*/
googleClientId: string | undefined;
/** What this host collects; a method absent here is one web does not offer. */
collector: CredentialCollector<WebCollected>;
}

const IdentityContext = createContext<IdentityContextValue | undefined>(undefined);

export interface IdentityProviderProps extends IdentityContextValue {
export interface IdentityProviderProps {
exchange: IdentityExchange;
googleClientId: string | undefined;
children: ReactNode;
}

/**
* Holds the identity exchange so the login flow reaches the API without
* importing a transport — which is what keeps `useAuth` host-agnostic.
* Holds the identity exchange and this host's collector so the login flow
* reaches the API without importing a transport — which is what keeps the
* sequencing host-agnostic.
*/
export function IdentityProvider({ exchange, googleClientId, children }: IdentityProviderProps) {
const value = useMemo(() => ({ exchange, googleClientId }), [exchange, googleClientId]);
const value = useMemo(
() => ({ exchange, googleClientId, collector: webCollector(googleClientId) }),
[exchange, googleClientId]
);
return <IdentityContext.Provider value={value}>{children}</IdentityContext.Provider>;
}

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/auth/coreKit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MemoryKeys, sealedTestStore } from '../test/storeFakes';
import type { SealedStore } from './sealedStore';
import { createCoreKitSession } from './coreKit';
import type { IdentityCredential } from './identityExchange';
import type { IdentityCredential } from '@cipherbox/login';

const STORE_KEY = 'corekit_store';

Expand Down
30 changes: 8 additions & 22 deletions apps/web/src/auth/coreKit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,21 @@
* Web3Auth Core Kit on the UI thread: it owns its own popup and redirect flows,
* so it cannot live in the engine worker (blueprint/web-client.md "Login and
* identity"). The one thing it produces that the vault cares about is the login
* secret, which `engine/loginHandoff` transfers to the engine.
* secret, which the login flow transfers to the engine.
*/

import { COREKIT_STATUS, WEB3AUTH_NETWORK, Web3AuthMPCCoreKit } from '@web3auth/mpc-core-kit';
import { tssLib } from '@toruslabs/tss-dkls-lib';
import {
isIdentityMethod,
type CoreKitSession,
type IdentityCredential,
type IdentityMethod,
} from '@cipherbox/login';
import { environment, loginEnv } from '../engine/config';
import type { LoginSecretExporter } from '../engine/loginHandoff';
import { type IdentityCredential, type IdentityMethod, isIdentityMethod } from './identityExchange';
import { indexedDbWrappingKeys, SealedStore } from './sealedStore';

/**
* The Core Kit surface the login flow drives. Narrow by construction: the hook
* never sees a Web3Auth parameter shape, and a test substitutes a plain object.
*/
export interface CoreKitSession extends LoginSecretExporter {
/** Restores a prior session, if the SDK has one on this device. */
restore(): Promise<void>;
/** True once a login (or a restore) has completed on this device. */
isLoggedIn(): boolean;
/** Redeems a CipherBox identity token for this device's share of the key. */
login(credential: IdentityCredential): Promise<void>;
/** How the live session was established; unknown after a bare restore. */
method(): IdentityMethod | null;
/** The signed-in user's email, when the method carries one. */
email(): string | null;
logout(): Promise<void>;
}

/** Adapts the Web3Auth SDK to the narrow session seam above. */
/** Adapts the Web3Auth SDK to the narrow session seam the login flow drives. */
class Web3AuthSession implements CoreKitSession {
/** The address the exchange reported; the token deliberately carries no PII. */
private signedInEmail: string | null = null;
Expand Down
160 changes: 43 additions & 117 deletions apps/web/src/auth/useAuth.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,18 @@
/**
* The login flow, rewired onto the facade (blueprint/web-client.md "Login and
* identity"). CipherBox verifies the provider credential and mints the token
* the Core Kit redeems (ADR 0008 D1); the only thing that crosses into the
* vault is the login secret, transferred once by `handOffLoginSecret`.
*
* Credential collection is per-host and stays outside this hook — it takes
* already-collected credentials and drives the sequencing.
* React's binding to the shared login flow (ADR 0008 D3). The sequencing lives
* in `@cipherbox/login`; this hook supplies the web host's parts — the facade
* the client wraps, the Core Kit session, the collector, the auth chrome — and
* renders the transitions as component state.
*/

import { useCallback, useEffect, useState } from 'react';
import { handOffLoginSecret } from '../engine/loginHandoff';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { createLoginFlow, type LoginProgress } from '@cipherbox/login';
import { errorMessage } from '../lib/errorMessage';
import { authStore, useAuthState } from '../stores/auth.store';
import { useEngine, useLoginSecretSource, useRebuildEngine } from '../providers/EngineProvider';
import { useCoreKit } from './CoreKitProvider';
import { useIdentity } from './IdentityProvider';
import type { CoreKitSession } from './coreKit';
import type { IdentityCredential } from './identityExchange';
import type { WebCollected } from './webCollector';

export interface Auth {
isAuthenticated: boolean;
Expand All @@ -43,20 +39,12 @@ export interface Auth {
logout(): Promise<void>;
}

/**
* There is one engine per origin and one cold start per tab, so these guards
* are module-scoped: every `useAuth()` consumer drives the same transitions,
* and a second one must not start a second login.
*/
let inFlight = false;
let restoredFor: CoreKitSession | null = null;

export function useAuth(): Auth {
const client = useEngine();
const secrets = useLoginSecretSource();
const rebuildEngine = useRebuildEngine();
const { session, status, error: coreKitError } = useCoreKit();
const { exchange } = useIdentity();
const { exchange, collector } = useIdentity();
const { isAuthenticated } = useAuthState();

const [isBusy, setIsBusy] = useState(false);
Expand All @@ -65,124 +53,62 @@ export function useAuth(): Auth {
const isReady = client !== null && session !== null && status === 'ready';
const isSignedOut = !isAuthenticated && (isReady || status === 'unavailable');

/** Serializes the auth transitions; a collision rejects rather than no-ops. */
const exclusively = useCallback(async (step: () => Promise<void>): Promise<void> => {
if (inFlight) throw new Error('another sign-in is already in progress');
inFlight = true;
setIsBusy(true);
setError(null);
try {
await step();
} catch (failure) {
setError(errorMessage(failure));
throw failure;
} finally {
inFlight = false;
setIsBusy(false);
}
}, []);

/**
* The Core Kit → engine handoff. The secret source is armed first so a
* leadership failover mid-start can re-export it; every step after that stays
* inside the failure envelope, so nothing can leave it armed over a UI that
* renders signed out.
*/
const handOff = useCallback(async (): Promise<void> => {
if (!client || !session) throw new Error('the engine is not ready to accept a login');
const method = session.method();
const email = session.email();

secrets?.use(session);
try {
await handOffLoginSecret(client, session);
authStore.signedIn(method, email);
} catch (failure) {
secrets?.use(null);
// A Core Kit session the engine refused is a live credential on this
// device that nothing in the UI can reach; end it here.
await session.logout().catch(() => undefined);
throw failure;
}
}, [client, secrets, session]);

/**
* One sequencing for every method: exchange the collected credential, redeem
* it with the Core Kit, then hand the engine its secret.
*/
const login = useCallback(
(collect: () => Promise<IdentityCredential>) =>
exclusively(async () => {
if (!session) throw new Error('the login provider is not ready');
await session.login(await collect());
await handOff();
}),
[exclusively, handOff, session]
);

const loginWithGoogle = useCallback(
(idToken: string) => login(() => exchange.fromGoogleToken(idToken)),
[exchange, login]
const progress = useMemo<LoginProgress>(
() => ({
begin: () => {
setIsBusy(true);
setError(null);
},
failed: (failure) => setError(errorMessage(failure)),
end: () => setIsBusy(false),
}),
[]
);

const sendEmailCode = useCallback(
(email: string) => exclusively(() => exchange.sendEmailCode(email)),
[exchange, exclusively]
const flow = useMemo(
() =>
createLoginFlow<WebCollected>({
exchange,
collector,
session,
facade: client?.facade ?? null,
secrets: secrets ?? null,
account: authStore,
progress,
// `facade.logout` closes the client for good, so the tab needs a new one.
afterLogout: rebuildEngine,
}),
[client, collector, exchange, progress, rebuildEngine, secrets, session]
);

const loginWithEmailCode = useCallback(
(email: string, code: string) => login(() => exchange.fromEmailCode(email, code)),
[exchange, login]
(email: string, code: string) => flow.loginWithEmailCode({ email, code }),
[flow]
);

const walletNonce = useCallback(() => exchange.walletNonce(), [exchange]);

const loginWithWallet = useCallback(
(message: string, signature: string) =>
login(() => exchange.fromWalletSignature(message, signature)),
[exchange, login]
);

const logout = useCallback(
() =>
exclusively(async () => {
// Every leg runs: a refused engine zeroize must not strand the Core Kit
// session, and a failed Core Kit logout must not leave the UI signed in.
const outcomes = await Promise.allSettled([
client?.facade.logout() ?? Promise.resolve(),
session?.logout() ?? Promise.resolve(),
]);
secrets?.use(null);
restoredFor = null;
authStore.signedOut();
rebuildEngine();
const failed = outcomes.find((outcome) => outcome.status === 'rejected');
if (failed) throw failed.reason as Error;
}),
[client, exclusively, rebuildEngine, secrets, session]
(message: string, signature: string) => flow.loginWithWallet({ message, signature }),
[flow]
);

// A Core Kit session that survived the reload still has to hand the engine its
// secret; without this the tab renders logged-out over a live login.
useEffect(() => {
if (!isReady || isAuthenticated || restoredFor === session || !session?.isLoggedIn()) return;
restoredFor = session;
exclusively(handOff).catch(() => {
restoredFor = null;
});
}, [exclusively, handOff, isAuthenticated, isReady, session]);
if (!isReady || isAuthenticated) return;
void flow.resume();
}, [flow, isAuthenticated, isReady]);

return {
isAuthenticated,
isReady,
isSignedOut,
isBusy,
error: error ?? coreKitError,
loginWithGoogle,
sendEmailCode,
loginWithGoogle: flow.loginWithGoogle,
sendEmailCode: flow.sendEmailCode,
loginWithEmailCode,
walletNonce,
walletNonce: flow.walletNonce,
loginWithWallet,
logout,
logout: flow.logout,
};
}
29 changes: 29 additions & 0 deletions apps/web/src/auth/webCollector.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { collectedMethods } from '@cipherbox/login';
import { describe, expect, it } from 'vitest';
import { webCollector } from './webCollector';

describe('web credential collection', () => {
it('offers every method a browser can collect', () => {
expect(collectedMethods(webCollector('google-client-id'))).toEqual([
'google',
'email',
'wallet',
]);
});

// The missing variable disables one method, never the session: a build
// without it still logs in by email and wallet (`engine/config`).
it('drops only google when the build carries no client ID', () => {
expect(collectedMethods(webCollector(undefined))).toEqual(['email', 'wallet']);
});

it('passes the material the page already collected straight through', async () => {
const collector = webCollector('google-client-id');
const proof = { message: 'siwe-message', signature: '0xabc' };
const answer = { email: 'user@example.com', code: '123456' };

await expect(collector.google?.('google.id.token')).resolves.toBe('google.id.token');
await expect(collector.email?.(answer)).resolves.toBe(answer);
await expect(collector.wallet?.(proof)).resolves.toBe(proof);
});
Comment thread
FSM1 marked this conversation as resolved.
});
32 changes: 32 additions & 0 deletions apps/web/src/auth/webCollector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Web's credential collection (ADR 0008 D3). Collection happens in the DOM
* before the flow is called — Google Identity Services renders its own button
* and hands the page a token, and wagmi signs in the wallet — so what the flow
* asks for is already in hand. A host that drives its own flow, as desktop's
* loopback OAuth listener does, does that work inside these collectors instead.
*/

import type { CollectedMaterial, CredentialCollector, WalletProof } from '@cipherbox/login';

/** What each of web's collectors is handed. */
export interface WebCollected extends CollectedMaterial {
/** The ID token Google Identity Services delivered. */
google: string;
email: { email: string; code: string };
wallet: WalletProof;
}

/**
* A build carrying no Google client ID renders no Google button, so the method
* is absent here to match — that alone disables it, and no other method is
* affected (`engine/config`).
*/
export function webCollector(
googleClientId: string | undefined
): CredentialCollector<WebCollected> {
return {
google: googleClientId ? (idToken) => Promise.resolve(idToken) : undefined,
email: (answer) => Promise.resolve(answer),
wallet: (proof) => Promise.resolve(proof),
};
}
Loading