-
Notifications
You must be signed in to change notification settings - Fork 0
feat: harvest the v1 login UI and rewire it to the facade #911
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from 'react'; | ||
| import { errorMessage } from '../lib/errorMessage'; | ||
| import type { CoreKitSession } from './coreKit'; | ||
|
|
||
| export interface CoreKitContextValue { | ||
| /** `null` until the session is built and its restore attempt has settled. */ | ||
| session: CoreKitSession | null; | ||
| /** True while the mount-time session restore is still in flight. */ | ||
| isRestoring: boolean; | ||
| /** Why Core Kit is unusable at all — a missing or rejected build config. */ | ||
| error: string | null; | ||
| } | ||
|
|
||
| const CoreKitContext = createContext<CoreKitContextValue | undefined>(undefined); | ||
|
|
||
| export interface CoreKitProviderProps { | ||
| /** Builds this tab's Core Kit session. Called once per tab. */ | ||
| createSession: () => CoreKitSession; | ||
| children: ReactNode; | ||
| } | ||
|
|
||
| /** | ||
| * Owns the tab's one Core Kit session and its mount-time restore. The session | ||
| * and the restore promise are both latched in refs: the SDK holds a device | ||
| * factor in origin storage, so a StrictMode remount must reuse the instance | ||
| * rather than race a second one against the same store. | ||
| */ | ||
| export function CoreKitProvider({ createSession, children }: CoreKitProviderProps) { | ||
| const [value, setValue] = useState<CoreKitContextValue>({ | ||
| session: null, | ||
| isRestoring: true, | ||
| error: null, | ||
| }); | ||
| const factory = useRef(createSession); | ||
| const session = useRef<CoreKitSession | null>(null); | ||
| const restore = useRef<Promise<void> | null>(null); | ||
|
|
||
| useEffect(() => { | ||
| let live = true; | ||
| try { | ||
| session.current ??= factory.current(); | ||
| restore.current ??= session.current.restore(); | ||
| } catch (error) { | ||
| setValue({ session: null, isRestoring: false, error: errorMessage(error) }); | ||
| return; | ||
| } | ||
|
|
||
| const settled = { session: session.current, isRestoring: false, error: null }; | ||
| // A failed restore just means there is no session to resume; the methods | ||
| // below still work, and a real breakage surfaces when one is used. | ||
| restore.current.then( | ||
| () => live && setValue(settled), | ||
| () => live && setValue(settled) | ||
| ); | ||
|
|
||
| return () => { | ||
| live = false; | ||
| }; | ||
| }, []); | ||
|
|
||
| return <CoreKitContext.Provider value={value}>{children}</CoreKitContext.Provider>; | ||
| } | ||
|
|
||
| /** This tab's Core Kit session and its restore state. */ | ||
| export function useCoreKit(): CoreKitContextValue { | ||
| const value = useContext(CoreKitContext); | ||
| if (!value) throw new Error('auth hooks must be used within <CoreKitProvider>'); | ||
| return value; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| /** | ||
| * 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. | ||
| */ | ||
|
|
||
| import { COREKIT_STATUS, WEB3AUTH_NETWORK, Web3AuthMPCCoreKit } from '@web3auth/mpc-core-kit'; | ||
| import { tssLib } from '@toruslabs/tss-dkls-lib'; | ||
| import { environment } from '../engine/config'; | ||
| import type { LoginSecretExporter } from '../engine/loginHandoff'; | ||
|
|
||
| /** How a session was established; also the `authStore` login method. */ | ||
| export type CoreKitLoginMethod = 'google' | 'email'; | ||
|
|
||
| /** | ||
| * 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; | ||
| login(method: CoreKitLoginMethod, email?: string): Promise<void>; | ||
| /** How the live session was established, as Core Kit reports it. */ | ||
| method(): CoreKitLoginMethod; | ||
| /** 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. */ | ||
| class Web3AuthSession implements CoreKitSession { | ||
| constructor( | ||
| private readonly coreKit: Web3AuthMPCCoreKit, | ||
| private readonly verifier: string, | ||
| private readonly clientId: string | ||
| ) {} | ||
|
|
||
| async restore(): Promise<void> { | ||
| await this.coreKit.init(); | ||
| } | ||
|
|
||
| isLoggedIn(): boolean { | ||
| return this.coreKit.status === COREKIT_STATUS.LOGGED_IN; | ||
| } | ||
|
|
||
| async login(method: CoreKitLoginMethod, email?: string): Promise<void> { | ||
| await this.coreKit.loginWithOAuth({ | ||
| subVerifierDetails: { | ||
| typeOfLogin: method === 'google' ? 'google' : 'email_passwordless', | ||
| verifier: this.verifier, | ||
| clientId: this.clientId, | ||
| ...(email ? { jwtParams: { login_hint: email } } : {}), | ||
| }, | ||
| }); | ||
| if (this.coreKit.status !== COREKIT_STATUS.LOGGED_IN) { | ||
| // REQUIRED_SHARE: MFA is on and this device holds no factor. Recovery and | ||
| // device approval are not built yet, so fail rather than half-log-in, and | ||
| // end the partial session rather than leave it resident on the device. | ||
| await this.coreKit.logout().catch(() => undefined); | ||
| throw new Error('this device needs approval or a recovery phrase before it can sign in'); | ||
| } | ||
| await this.coreKit.commitChanges(); | ||
| } | ||
|
|
||
| method(): CoreKitLoginMethod { | ||
| return this.coreKit.getUserInfo().typeOfLogin === 'google' ? 'google' : 'email'; | ||
| } | ||
|
|
||
| email(): string | null { | ||
| return this.coreKit.getUserInfo().email ?? null; | ||
| } | ||
|
|
||
| async logout(): Promise<void> { | ||
| if (this.isLoggedIn()) await this.coreKit.logout(); | ||
| } | ||
|
|
||
| _UNSAFE_exportTssKey(): Promise<string> { | ||
| return this.coreKit._UNSAFE_exportTssKey(); | ||
| } | ||
| } | ||
|
|
||
| /** Builds this tab's Core Kit session from the build-time environment. */ | ||
| export function createCoreKitSession(env: Partial<ImportMetaEnv>): CoreKitSession { | ||
| const clientId = env.VITE_WEB3AUTH_CLIENT_ID; | ||
| const verifier = env.VITE_WEB3AUTH_VERIFIER; | ||
| if (!clientId || !verifier) { | ||
| throw new Error('VITE_WEB3AUTH_CLIENT_ID and VITE_WEB3AUTH_VERIFIER must both be configured'); | ||
| } | ||
|
|
||
| const coreKit = new Web3AuthMPCCoreKit({ | ||
| web3AuthClientId: clientId, | ||
| web3AuthNetwork: | ||
| environment(env) === 'production' ? WEB3AUTH_NETWORK.MAINNET : WEB3AUTH_NETWORK.DEVNET, | ||
| // Core Kit persists its own device-factor share and session id here. The | ||
| // login secret is not among them — it only ever leaves this realm as the | ||
| // transferred buffer — but this store is a bearer path back to a logged-in | ||
| // Core Kit, so its scope is a decision, not a default: see #913. | ||
| storage: window.localStorage, | ||
| manualSync: true, | ||
| tssLib, | ||
| }); | ||
| return new Web3AuthSession(coreKit, verifier, clientId); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| import { afterEach, describe, expect, it, vi } from 'vitest'; | ||
| import { requestSiweNonce } from './siweNonce'; | ||
|
|
||
| // A fresh Response per call: a body can only be read once. | ||
| function respond(body: unknown, status = 200) { | ||
| return vi.spyOn(globalThis, 'fetch').mockImplementation(() => | ||
| Promise.resolve( | ||
| new Response(JSON.stringify(body), { | ||
| status, | ||
| headers: { 'content-type': 'application/json' }, | ||
| }) | ||
| ) | ||
| ); | ||
| } | ||
|
|
||
| describe('requestSiweNonce', () => { | ||
| afterEach(() => vi.restoreAllMocks()); | ||
|
|
||
| it('posts to the API challenge endpoint under a timeout and returns the nonce', async () => { | ||
| const fetchSpy = respond({ nonce: 'abc12345', expiresAt: '2026-01-01T00:00:00.000Z' }); | ||
|
|
||
| await expect(requestSiweNonce('https://api.test')).resolves.toBe('abc12345'); | ||
|
|
||
| const [url, init] = fetchSpy.mock.calls[0]; | ||
| expect(String(url)).toBe('https://api.test/auth/siwe/challenge'); | ||
| expect(init?.signal).toBeInstanceOf(AbortSignal); | ||
| }); | ||
|
|
||
| it('resolves the endpoint against a base with a path or a trailing slash', async () => { | ||
| const fetchSpy = respond({ nonce: 'abc12345' }); | ||
|
|
||
| await requestSiweNonce('https://api.test/'); | ||
| await requestSiweNonce('https://api.test/ignored'); | ||
|
|
||
| for (const [url] of fetchSpy.mock.calls) { | ||
| expect(String(url)).toBe('https://api.test/auth/siwe/challenge'); | ||
| } | ||
| }); | ||
|
|
||
| it('refuses a nonce too weak, too long, or too malformed to sign over', async () => { | ||
| respond({ nonce: 'short' }); | ||
| await expect(requestSiweNonce('https://api.test')).rejects.toThrow(/unusable nonce/); | ||
|
|
||
| respond({ nonce: 'not a nonce!' }); | ||
| await expect(requestSiweNonce('https://api.test')).rejects.toThrow(/unusable nonce/); | ||
|
|
||
| // A hostile API must not push an unbounded string into the signing prompt. | ||
| respond({ nonce: 'a'.repeat(129) }); | ||
| await expect(requestSiweNonce('https://api.test')).rejects.toThrow(/unusable nonce/); | ||
|
|
||
| respond({}); | ||
| await expect(requestSiweNonce('https://api.test')).rejects.toThrow(/unusable nonce/); | ||
| }); | ||
|
|
||
| it('surfaces a refused challenge by status, without echoing its body', async () => { | ||
| respond({ message: '<script>alert(1)</script>' }, 429); | ||
| await expect(requestSiweNonce('https://api.test')).rejects.toThrow( | ||
| /^siwe challenge refused with 429$/ | ||
| ); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| /** | ||
| * The single-use nonce an EIP-4361 message must carry. The engine owns the SIWE | ||
| * exchange itself (`facade.siweLogin`) but exposes no challenge command, so the | ||
| * page fetches the nonce from the API's public challenge endpoint — see #910 to | ||
| * move this below the facade. | ||
| */ | ||
|
|
||
| /** EIP-4361 requires 8+ alphanumerics; the API issues 32 hex characters. */ | ||
| const NONCE = /^[A-Za-z0-9]{8,128}$/; | ||
|
|
||
| const TIMEOUT_MS = 10_000; | ||
|
|
||
| export async function requestSiweNonce(apiBaseUrl: string): Promise<string> { | ||
| const response = await fetch(new URL('/auth/siwe/challenge', apiBaseUrl), { | ||
| method: 'POST', | ||
| signal: AbortSignal.timeout(TIMEOUT_MS), | ||
| }); | ||
| if (!response.ok) throw new Error(`siwe challenge refused with ${response.status}`); | ||
|
|
||
| const { nonce } = (await response.json()) as { nonce?: unknown }; | ||
| // Fail closed: an unusable nonce must not reach the wallet as a signing | ||
| // prompt, and its character class is what keeps a hostile response from | ||
| // injecting extra EIP-4361 fields into the signed text. | ||
| if (typeof nonce !== 'string' || !NONCE.test(nonce)) { | ||
| throw new Error('siwe challenge returned an unusable nonce'); | ||
| } | ||
| return nonce; | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.