diff --git a/demos/test-app/package.json b/demos/test-app/package.json index 3cfb51739a..93ddaa2071 100644 --- a/demos/test-app/package.json +++ b/demos/test-app/package.json @@ -7,6 +7,8 @@ "@dfinity/candid": "*", "@dfinity/identity": "*", "@dfinity/principal": "*", + "@slide-computer/signer": "^3.16.0", + "@slide-computer/signer-web": "^3.16.0", "buffer": "^6.0.3", "jose": "^5.1.2", "react": "^18.2.0", diff --git a/demos/test-app/src/auth.ts b/demos/test-app/src/auth.ts index a62dc62761..f68c52bb08 100644 --- a/demos/test-app/src/auth.ts +++ b/demos/test-app/src/auth.ts @@ -6,6 +6,8 @@ import { SignedDelegation, } from "@dfinity/identity"; import { Principal } from "@dfinity/principal"; +import { Signer } from "@slide-computer/signer"; +import { PostMessageTransport } from "./signer-web"; // The type of response from II as per the spec interface AuthResponseSuccess { @@ -30,6 +32,7 @@ export const authWithII = async ({ derivationOrigin, sessionIdentity, autoSelectionPrincipal, + useIcrc25, }: { url: string; maxTimeToLive?: bigint; @@ -37,7 +40,21 @@ export const authWithII = async ({ derivationOrigin?: string; autoSelectionPrincipal?: string; sessionIdentity: SignIdentity; + useIcrc25?: boolean; }): Promise<{ identity: DelegationIdentity; authnMethod: string }> => { + if (useIcrc25) { + const transport = new PostMessageTransport({ url: url_ }); + const signer = new Signer({ transport }); + const delegation = await signer.delegation({ + maxTimeToLive, + publicKey: sessionIdentity.getPublicKey().toDer(), + }); + return { + identity: DelegationIdentity.fromDelegation(sessionIdentity, delegation), + authnMethod: "passkey", + }; + } + // Figure out the II URL to use const iiUrl = new URL(url_); iiUrl.hash = "#authorize"; diff --git a/demos/test-app/src/index.html b/demos/test-app/src/index.html index 0962c3d661..c17914b18e 100644 --- a/demos/test-app/src/index.html +++ b/demos/test-app/src/index.html @@ -10,12 +10,15 @@ margin: 1rem 0; overflow-wrap: break-word; } + .postMessage-title { font-weight: bold; } + .received { background-color: lightblue; } + .sent { background-color: lightgreen; } @@ -69,6 +72,12 @@

Sign In

> +
+ + +

/.well-known/ii-alternative-origins

diff --git a/demos/test-app/src/index.tsx b/demos/test-app/src/index.tsx index cbf3c8dd46..534e02f4aa 100644 --- a/demos/test-app/src/index.tsx +++ b/demos/test-app/src/index.tsx @@ -74,6 +74,7 @@ const autoSelectionPrincipalEl = document.getElementById( const allowPinAuthenticationEl = document.getElementById( "allowPinAuthentication", ) as HTMLInputElement; +const useIcrc25El = document.getElementById("useIcrc25") as HTMLInputElement; let iiProtocolTestWindow: Window | undefined; @@ -82,6 +83,7 @@ let delegationIdentity: DelegationIdentity | undefined = undefined; // The local, ephemeral key-pair let localIdentity_: SignIdentity | undefined = undefined; + function getLocalIdentity(): SignIdentity { if (localIdentity_ === undefined) { localIdentity_ = Ed25519KeyIdentity.generate(); @@ -241,6 +243,7 @@ const init = async () => { allowPinAuthentication, sessionIdentity: getLocalIdentity(), autoSelectionPrincipal, + useIcrc25: useIcrc25El.checked, }); delegationIdentity = result.identity; updateDelegationView({ diff --git a/demos/test-app/src/signer-web/icrc29/heartbeat/client.ts b/demos/test-app/src/signer-web/icrc29/heartbeat/client.ts new file mode 100644 index 0000000000..d88763cffb --- /dev/null +++ b/demos/test-app/src/signer-web/icrc29/heartbeat/client.ts @@ -0,0 +1,179 @@ +import { isJsonRpcResponse, type JsonResponse } from "@slide-computer/signer"; + +export interface HeartbeatClientOptions { + /** + * Signer window to send and receive heartbeat messages from + */ + signerWindow: Window; + /** + * Callback when first heartbeat has been received + */ + onEstablish: (origin: string) => void; + /** + * Reasonable time in milliseconds in which the communication channel needs to be established + * @default 10000 + */ + establishTimeout?: number; + /** + * Callback when no heartbeats have been received for {@link establishTimeout} milliseconds + */ + onEstablishTimeout: () => void; + /** + * Time in milliseconds of not receiving heartbeat responses after which the communication channel is disconnected + * @default 2000 + */ + disconnectTimeout?: number; + /** + * Callback when no heartbeats have been received for {@link disconnectTimeout} milliseconds + */ + onDisconnect: () => void; + /** + * Status polling rate in ms + * @default 300 + */ + statusPollingRate?: number; + /** + * Relying party window, used to listen for incoming message events + * @default globalThis.window + */ + window?: Window; + /** + * Get random uuid implementation for status messages + * @default globalThis.crypto + */ + crypto?: Pick; +} + +export class HeartbeatClient { + readonly #options: Required; + + constructor(options: HeartbeatClientOptions) { + this.#options = { + establishTimeout: 10000, + disconnectTimeout: 2000, + statusPollingRate: 300, + window: globalThis.window, + crypto: globalThis.crypto, + ...options, + }; + + this.#establish(); + } + + #establish(): void { + let pending: Array = []; + + // Create new pending entry that's waiting for a response + const create = (): string => { + const id = this.#options.crypto.randomUUID(); + pending.push(id); + return id; + }; + + // Establish communication channel if a response is received for any pending id + const listener = this.#receiveReadyResponse((response) => { + if (pending.includes(response.data.id)) { + pending = []; + listener(); + clearInterval(interval); + clearTimeout(timeout); + + this.#options.onEstablish(response.origin); + this.#maintain(response.origin); + } + }); + + // Init timeout + const timeout = setTimeout(() => { + listener(); + clearInterval(interval); + + this.#options.onEstablishTimeout(); + }, this.#options.establishTimeout); + + // Start sending requests + const interval = setInterval( + () => this.#sendStatusRequest(create()), + this.#options.statusPollingRate, + ); + } + + #maintain(origin: string): void { + let interval: ReturnType; + let timeout: ReturnType; + let pending: Array<{ id: string | number; time: number }> = []; + + // Consume a pending entry if it exists + const consume = (id: string | number): boolean => { + const index = pending.findIndex((entry) => entry.id === id); + if (index > -1) { + pending.splice(index, 1); + } + return index > -1; + }; + + // Create new pending entry that's waiting for a response + const create = (): string => { + const id = this.#options.crypto.randomUUID(); + const time = new Date().getTime(); + + // Cleanup ids outside disconnect window + pending = pending.filter( + (entry) => time - this.#options.disconnectTimeout > entry.time, + ); + + // Insert and return new id + pending.push({ id, time }); + return id; + }; + + // Clear existing timeout (if any) and create a new one + const resetTimeout = () => { + clearTimeout(timeout); + timeout = setTimeout(() => { + listener(); + clearInterval(interval); + + this.#options.onDisconnect(); + }, this.#options.disconnectTimeout); + }; + + // Reset disconnect timeout if a response is received to an id within disconnect window + const listener = this.#receiveReadyResponse((response) => { + if (response.origin === origin && consume(response.data.id)) { + resetTimeout(); + } + }); + + // Init timeout and start sending requests + resetTimeout(); + interval = setInterval( + () => this.#sendStatusRequest(create()), + this.#options.statusPollingRate, + ); + } + + #receiveReadyResponse( + handler: (event: MessageEvent>) => void, + ): () => void { + const listener = (event: MessageEvent) => { + if ( + event.source === this.#options.signerWindow && + isJsonRpcResponse(event.data) && + "result" in event.data && + event.data.result === "ready" + ) { + handler(event); + } + }; + this.#options.window.addEventListener("message", listener); + return () => this.#options.window.removeEventListener("message", listener); + } + + #sendStatusRequest(id: string): void { + this.#options.signerWindow.postMessage( + { jsonrpc: "2.0", id, method: "icrc29_status" }, + "*", + ); + } +} diff --git a/demos/test-app/src/signer-web/icrc29/heartbeat/index.ts b/demos/test-app/src/signer-web/icrc29/heartbeat/index.ts new file mode 100644 index 0000000000..cc576d71b9 --- /dev/null +++ b/demos/test-app/src/signer-web/icrc29/heartbeat/index.ts @@ -0,0 +1,2 @@ +export * from "./client"; +export * from "./server"; diff --git a/demos/test-app/src/signer-web/icrc29/heartbeat/server.ts b/demos/test-app/src/signer-web/icrc29/heartbeat/server.ts new file mode 100644 index 0000000000..f6e4ca32e1 --- /dev/null +++ b/demos/test-app/src/signer-web/icrc29/heartbeat/server.ts @@ -0,0 +1,115 @@ +import { isJsonRpcRequest, type JsonRequest } from "@slide-computer/signer"; + +export interface HeartbeatServerOptions { + /** + * Callback when first heartbeat has been received + */ + onEstablish: (origin: string, source: MessageEventSource) => void; + /** + * Reasonable time in milliseconds in which the communication channel needs to be established + * @default 2000 + */ + establishTimeout?: number; + /** + * Callback when no heartbeats have been received for {@link establishTimeout} milliseconds + */ + onEstablishTimeout: () => void; + /** + * Time in milliseconds of not receiving heartbeat requests after which the communication channel is disconnected + * @default 2000 + */ + disconnectTimeout?: number; + /** + * Callback when no heartbeats have been received for {@link disconnectTimeout} milliseconds + */ + onDisconnect: () => void; + /** + * Signer window, used to listen for incoming message events + * @default globalThis.window + */ + window?: Window; +} + +export class HeartbeatServer { + readonly #options: Required; + + constructor(options: HeartbeatServerOptions) { + this.#options = { + establishTimeout: 10000, + disconnectTimeout: 2000, + window: globalThis.window, + ...options, + }; + + this.#establish(); + } + + #establish(): void { + // Establish communication channel if a request is received + const listener = this.#receiveStatusRequest((request) => { + if (!request.source) { + return; + } + listener(); + clearTimeout(timeout); + + this.#options.onEstablish(request.origin, request.source); + this.#maintain(request.origin, request.source); + }); + + // Init timeout + const timeout = setTimeout(() => { + listener(); + + this.#options.onEstablishTimeout(); + }, this.#options.establishTimeout); + } + + #maintain(origin: string, source: MessageEventSource): void { + let timeout: ReturnType; + + // Clear existing timeout (if any) and create a new one + const resetTimeout = () => { + clearTimeout(timeout); + timeout = setTimeout(() => { + listener(); + + this.#options.onDisconnect(); + }, this.#options.disconnectTimeout); + }; + + // Init timeout and start sending messages + resetTimeout(); + + // Reset disconnect timeout and send response if a request is received + const listener = this.#receiveStatusRequest((request) => { + if ( + request.origin === origin && + request.source === source && + request.data.id !== undefined + ) { + resetTimeout(); + this.#sendReadyResponse(request.data.id, request.source); + } + }); + } + + #receiveStatusRequest( + handler: (event: MessageEvent>) => void, + ): () => void { + const listener = (event: MessageEvent) => { + if ( + isJsonRpcRequest(event.data) && + event.data.method === "icrc29_status" + ) { + handler(event); + } + }; + this.#options.window.addEventListener("message", listener); + return () => this.#options.window.removeEventListener("message", listener); + } + + #sendReadyResponse(id: string | number, source: MessageEventSource): void { + source.postMessage({ jsonrpc: "2.0", id, result: "ready" }); + } +} diff --git a/demos/test-app/src/signer-web/icrc29/index.ts b/demos/test-app/src/signer-web/icrc29/index.ts new file mode 100644 index 0000000000..0797789a70 --- /dev/null +++ b/demos/test-app/src/signer-web/icrc29/index.ts @@ -0,0 +1,2 @@ +export * from "./postMessageTransport"; +export * from "./postMessageChannel"; diff --git a/demos/test-app/src/signer-web/icrc29/postMessageChannel.ts b/demos/test-app/src/signer-web/icrc29/postMessageChannel.ts new file mode 100644 index 0000000000..046d7599f8 --- /dev/null +++ b/demos/test-app/src/signer-web/icrc29/postMessageChannel.ts @@ -0,0 +1,102 @@ +import { + type Channel, + isJsonRpcResponse, + type JsonRequest, + type JsonResponse, +} from "@slide-computer/signer"; +import { PostMessageTransportError } from "./postMessageTransport"; + +export interface PostMessageChannelOptions { + /** + * Signer window with which a communication channel has been established + */ + signerWindow: Window; + /** + * Signer origin obtained when communication channel was established + */ + signerOrigin: string; + /** + * Relying party window, used to listen for incoming message events + * @default globalThis.window + */ + window?: Window; + /** + * Manage focus between relying party and signer window + * @default true + */ + manageFocus?: boolean; +} + +export class PostMessageChannel implements Channel { + readonly #closeListeners = new Set<() => void>(); + readonly #options: Required; + #closed = false; + + constructor(options: PostMessageChannelOptions) { + this.#options = { + window: globalThis.window, + manageFocus: true, + ...options, + }; + } + + get closed() { + return this.#closed; + } + + addEventListener( + ...[event, listener]: + | [event: "close", listener: () => void] + | [event: "response", listener: (response: JsonResponse) => void] + ): () => void { + switch (event) { + case "close": + this.#closeListeners.add(listener); + return () => { + this.#closeListeners.delete(listener); + }; + case "response": + const messageListener = async (event: MessageEvent) => { + if ( + event.source !== this.#options.signerWindow || + event.origin !== this.#options.signerOrigin || + !isJsonRpcResponse(event.data) + ) { + return; + } + listener(event.data); + }; + this.#options.window.addEventListener("message", messageListener); + return () => { + this.#options.window.removeEventListener("message", messageListener); + }; + } + } + + async send(request: JsonRequest): Promise { + if (this.#closed) { + throw new PostMessageTransportError("Communication channel is closed"); + } + + this.#options.signerWindow.postMessage(request, this.#options.signerOrigin); + + if (this.#options.manageFocus) { + this.#options.signerWindow.focus(); + } + } + + async close(): Promise { + if (this.#closed) { + return; + } + + this.#closed = true; + + this.#options.signerWindow.close(); + if (this.#options.manageFocus) { + this.#options.window.focus(); + } + + this.#closeListeners.forEach((listener) => listener()); + } +} diff --git a/demos/test-app/src/signer-web/icrc29/postMessageTransport.ts b/demos/test-app/src/signer-web/icrc29/postMessageTransport.ts new file mode 100644 index 0000000000..19819879b7 --- /dev/null +++ b/demos/test-app/src/signer-web/icrc29/postMessageTransport.ts @@ -0,0 +1,140 @@ +import { type Transport } from "@slide-computer/signer"; +import { PostMessageChannel } from "./postMessageChannel"; +import { urlIsSecureContext } from "../utils"; +import { HeartbeatClient } from "./heartbeat"; + +const NON_CLICK_ESTABLISHMENT_LINK = + "https://github.com/slide-computer/signer-js/blob/main/packages/signer-web/README.md#channels-must-be-established-in-a-click-handler"; + +export class PostMessageTransportError extends Error { + constructor(message: string) { + super(message); + Object.setPrototypeOf(this, PostMessageTransportError.prototype); + } +} + +export interface PostMessageTransportOptions { + /** + * Signer RPC url to send and receive messages from + */ + url: string; + /** + * Signer window feature config string + * @example "toolbar=0,location=0,menubar=0,width=500,height=500,left=100,top=100" + */ + windowOpenerFeatures?: string; + /** + * Relying party window, used to listen for incoming message events + * @default globalThis.window + */ + window?: Window; + /** + * Reasonable time in milliseconds in which the communication channel needs to be established + * TODO: Lower this value once "not available, try again later" error is standardized and implemented + * @default 120000 + */ + establishTimeout?: number; + /** + * Time in milliseconds of not receiving heartbeat responses after which the communication channel is disconnected + * @default 2000 + */ + disconnectTimeout?: number; + /** + * Status polling rate in ms + * @default 300 + */ + statusPollingRate?: number; + /** + * Get random uuid implementation for status messages + * @default globalThis.crypto + */ + crypto?: Pick; + /** + * Manage focus between relying party and signer window + * @default true + */ + manageFocus?: boolean; + /** + * Close signer window on communication channel establish timeout + * @default true + */ + closeOnEstablishTimeout?: boolean; + /** + * Detect attempts to establish channel outside of click handler + * @default true + */ + detectNonClickEstablishment?: boolean; +} + +// Boolean that tracks click events to check if the popup is opened within a click context +let withinClick = false; +if (globalThis.window) { + globalThis.window.addEventListener("click", () => (withinClick = true), true); + globalThis.window.addEventListener("click", () => (withinClick = false)); +} + +export class PostMessageTransport implements Transport { + readonly #options: Required; + + constructor(options: PostMessageTransportOptions) { + if (!urlIsSecureContext(options.url)) { + throw new PostMessageTransportError("Invalid signer RPC url"); + } + + this.#options = { + windowOpenerFeatures: "", + window: globalThis.window, + establishTimeout: 120000, + disconnectTimeout: 2000, + statusPollingRate: 300, + crypto: globalThis.crypto, + manageFocus: true, + closeOnEstablishTimeout: true, + detectNonClickEstablishment: true, + ...options, + }; + } + + async establishChannel(): Promise { + if (this.#options.detectNonClickEstablishment && !withinClick) { + throw new PostMessageTransportError( + `Signer window should not be opened outside of click handler, see: ${NON_CLICK_ESTABLISHMENT_LINK}`, + ); + } + const signerWindow = this.#options.window.open( + this.#options.url, + "signerWindow", + this.#options.windowOpenerFeatures, + ); + if (!signerWindow) { + throw new PostMessageTransportError("Signer window could not be opened"); + } + + return new Promise((resolve, reject) => { + let channel: PostMessageChannel; + new HeartbeatClient({ + ...this.#options, + signerWindow, + onEstablish: (origin) => { + channel = new PostMessageChannel({ + ...this.#options, + signerOrigin: origin, + signerWindow: signerWindow, + }); + resolve(channel); + }, + onEstablishTimeout: () => { + if (this.#options.closeOnEstablishTimeout) { + signerWindow.close(); + } + reject( + new PostMessageTransportError( + "Communication channel could not be established within a reasonable time", + ), + ); + }, + onDisconnect: () => channel.close(), + }); + }); + } +} diff --git a/demos/test-app/src/signer-web/index.ts b/demos/test-app/src/signer-web/index.ts new file mode 100644 index 0000000000..96b669dba8 --- /dev/null +++ b/demos/test-app/src/signer-web/index.ts @@ -0,0 +1,2 @@ +export * from "./icrc29"; +export * from "./utils"; diff --git a/demos/test-app/src/signer-web/utils.ts b/demos/test-app/src/signer-web/utils.ts new file mode 100644 index 0000000000..be32b23856 --- /dev/null +++ b/demos/test-app/src/signer-web/utils.ts @@ -0,0 +1,12 @@ +export const urlIsSecureContext = (value: string): boolean => { + try { + const url = new URL(value); + return ( + url.protocol === "https:" || + url.hostname === "127.0.0.1" || + url.hostname.split(".").slice(-1)[0] === "localhost" + ); + } catch { + return false; + } +}; diff --git a/package-lock.json b/package-lock.json index 02cf4af998..b878937916 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@dfinity/utils": "^2.5.1", "@lucide/svelte": "^0.508.0", "@noble/hashes": "^1.3.1", + "@slide-computer/signer-web": "3.16.0", "bip39": "^3.0.4", "borc": "^2.1.1", "buffer": "^6.0.3", @@ -94,6 +95,8 @@ "@dfinity/candid": "*", "@dfinity/identity": "*", "@dfinity/principal": "*", + "@slide-computer/signer": "^3.16.0", + "@slide-computer/signer-web": "^3.16.0", "buffer": "^6.0.3", "jose": "^5.1.2", "react": "^18.2.0", @@ -3095,6 +3098,27 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@slide-computer/signer": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@slide-computer/signer/-/signer-3.16.0.tgz", + "integrity": "sha512-K2hHPd/ebqne6gJ332bGVtE99V9e+CliVCW31RXRa2CCbXzodqYFuRWPJx1V8d8TGVruEXxtXCVmqsPnQ0mBmA==", + "license": "MIT", + "peerDependencies": { + "@dfinity/agent": "^2.3.0", + "@dfinity/candid": "^2.3.0", + "@dfinity/identity": "^2.3.0", + "@dfinity/principal": "^2.3.0" + } + }, + "node_modules/@slide-computer/signer-web": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@slide-computer/signer-web/-/signer-web-3.16.0.tgz", + "integrity": "sha512-pT2j0mSRN/ocQEHfKy2ikrbK+KuFhho7+yNcCIS5G8BkXv+NSfJ9riZJeQGOjlpz8gp87Q86Gng0CJ18vUgOsQ==", + "license": "MIT", + "peerDependencies": { + "@slide-computer/signer": "^3.16.0" + } + }, "node_modules/@sveltejs/acorn-typescript": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz", diff --git a/package.json b/package.json index 418df5d5e4..9711ff1bf6 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "@dfinity/utils": "^2.5.1", "@lucide/svelte": "^0.508.0", "@noble/hashes": "^1.3.1", + "@slide-computer/signer-web": "3.16.0", "bip39": "^3.0.4", "borc": "^2.1.1", "buffer": "^6.0.3", diff --git a/src/frontend/src/hooks.client.ts b/src/frontend/src/hooks.client.ts index 0616afe667..b0ddcfa4b6 100644 --- a/src/frontend/src/hooks.client.ts +++ b/src/frontend/src/hooks.client.ts @@ -10,6 +10,7 @@ import { } from "$lib/globals"; import { isNullish } from "@dfinity/utils"; import { isSameOrigin } from "$lib/utils/urlUtils"; +import { persistentSessionStore } from "$lib/stores/persistent-session.store"; const FEATURE_FLAG_PREFIX = "feature_flag_"; @@ -69,5 +70,6 @@ export const init: ClientInit = async () => { overrideFeatureFlags(); maybeSetDiscoverablePasskeyFlowFlag(); await sessionStore.init({ canisterId, agentOptions }); + await persistentSessionStore.init({ canisterId, agentOptions }); authenticationStore.init({ canisterId, agentOptions }); }; diff --git a/src/frontend/src/lib/components/wizards/auth/AuthWizard.svelte b/src/frontend/src/lib/components/wizards/auth/AuthWizard.svelte index 700782ac49..d422ef62c8 100644 --- a/src/frontend/src/lib/components/wizards/auth/AuthWizard.svelte +++ b/src/frontend/src/lib/components/wizards/auth/AuthWizard.svelte @@ -1,7 +1,7 @@ {#snippet dialogContent()} @@ -191,12 +215,14 @@ {:else} {#if authFlow.view === "chooseMethod" || !withinDialog} {@render children?.()} - (isMigrating = true)} - /> + {#if isNullish(continueWithJWT)} + (isMigrating = true)} + /> + {/if} {/if} {#if authFlow.view !== "chooseMethod"} {#if !withinDialog} diff --git a/src/frontend/src/lib/flows/authFlow.svelte.ts b/src/frontend/src/lib/flows/authFlow.svelte.ts index 83d19b6f69..99988e6587 100644 --- a/src/frontend/src/lib/flows/authFlow.svelte.ts +++ b/src/frontend/src/lib/flows/authFlow.svelte.ts @@ -38,6 +38,7 @@ import { GOOGLE_ISSUER, extractIssuerTemplateClaims, } from "$lib/utils/openID"; +import { persistentSessionStore } from "$lib/stores/persistent-session.store"; export class AuthFlow { #view = $state< @@ -235,6 +236,7 @@ export class AuthFlow { continueWithOpenId = async ( config: OpenIdConfig, + existingJWT?: string, ): Promise< | { identityNumber: bigint; @@ -245,7 +247,7 @@ export class AuthFlow { type: "signUp"; } > => { - let jwt: string | undefined = undefined; + let jwt: string | undefined = existingJWT; // Convert OpenIdConfig to RequestConfig const requestConfig: RequestConfig = { clientId: config.client_id, @@ -256,26 +258,33 @@ export class AuthFlow { authenticationV2Funnel.addProperties({ provider: config.name, }); - // Create two try-catch blocks to avoid double-triggering the analytics. - try { - this.#systemOverlay = true; - jwt = await requestJWT(requestConfig, { - nonce: get(sessionStore).nonce, - mediation: "required", - }); - } catch (error) { - this.#view = "chooseMethod"; - throw error; - } finally { - this.#systemOverlay = false; - // Moved after `requestJWT` to avoid Safari from blocking the popup. - authenticationV2Funnel.trigger(AuthenticationV2Events.ContinueWithOpenID); + if (isNullish(jwt)) { + // Create two try-catch blocks to avoid double-triggering the analytics. + try { + this.#systemOverlay = true; + jwt = await requestJWT(requestConfig, { + nonce: get(sessionStore).nonce, + mediation: "required", + }); + } catch (error) { + this.#view = "chooseMethod"; + throw error; + } finally { + this.#systemOverlay = false; + // Moved after `requestJWT` to avoid Safari from blocking the popup. + authenticationV2Funnel.trigger( + AuthenticationV2Events.ContinueWithOpenID, + ); + } } try { + console.log("jwt", jwt); const { iss, sub, loginHint } = decodeJWT(jwt); const { identity, identityNumber } = await authenticateWithJWT({ canisterId, - session: get(sessionStore), + session: get( + nonNullish(existingJWT) ? persistentSessionStore : sessionStore, + ), jwt, }); // If the previous call succeeds, it means the OpenID user already exists in II. diff --git a/src/frontend/src/lib/stores/authorization.store.ts b/src/frontend/src/lib/stores/authorization.store.ts index 122296ed5b..3d0f3c3f79 100644 --- a/src/frontend/src/lib/stores/authorization.store.ts +++ b/src/frontend/src/lib/stores/authorization.store.ts @@ -16,6 +16,7 @@ import { lastUsedIdentitiesStore } from "$lib/stores/last-used-identities.store" import { features } from "$lib/legacy/features"; import { canisterConfig } from "$lib/globals"; import { validateDerivationOrigin } from "$lib/utils/validateDerivationOrigin"; +import { rpcAuthenticationProtocol } from "$lib/utils/postMessageInterface"; export type AuthorizationContext = { authRequest: AuthRequest; // Additional details e.g. derivation origin @@ -50,7 +51,7 @@ type AuthorizationStore = Readable<{ context?: AuthorizationContext; status: AuthorizationStatus; }> & { - init: () => Promise; + init: (rpc?: boolean) => Promise; authorize: ( accountNumber: bigint | undefined, artificialDelay?: number, @@ -68,8 +69,10 @@ let authorize: ( ) => Promise; export const authorizationStore: AuthorizationStore = { - init: async () => { - const status = await authenticationProtocol({ + init: async (rpc = false) => { + const status = await ( + rpc ? rpcAuthenticationProtocol : authenticationProtocol + )({ authenticate: async (context) => { const effectiveOrigin = remapToLegacyDomain( context.authRequest.derivationOrigin ?? context.requestOrigin, diff --git a/src/frontend/src/lib/stores/persistent-session.store.ts b/src/frontend/src/lib/stores/persistent-session.store.ts new file mode 100644 index 0000000000..f3c60b72fb --- /dev/null +++ b/src/frontend/src/lib/stores/persistent-session.store.ts @@ -0,0 +1,94 @@ +import { derived, writable } from "svelte/store"; +import { ECDSAKeyIdentity } from "@dfinity/identity"; +import { isNullish } from "@dfinity/utils"; +import { Actor } from "@dfinity/agent"; +import { createAnonymousNonce } from "$lib/utils/openID"; +import type { _SERVICE } from "$lib/generated/internet_identity_types"; +import { idlFactory as internet_identity_idl } from "$lib/generated/internet_identity_idl"; +import { LazyHttpAgent } from "$lib/utils/lazyHttpAgent"; +import { Session, SessionStore } from "$lib/stores/session.store"; +import { toBase64, fromBase64 } from "$lib/utils/utils"; + +const STORAGE_KEY = "ii-session"; + +const internalStore = writable(); + +const create = async () => { + const identity = await ECDSAKeyIdentity.generate({ + extractable: true, + }); + const keyPair = identity.getKeyPair(); + const privateJwk = await crypto.subtle.exportKey("jwk", keyPair.privateKey); + const publicJwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey); + const { nonce, salt } = await createAnonymousNonce(identity.getPrincipal()); + sessionStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + privateJwk, + publicJwk, + nonce, + salt: toBase64(salt), + }), + ); + return { + identity, + nonce, + salt, + }; +}; + +const read = async () => { + const item = sessionStorage.getItem(STORAGE_KEY); + if (isNullish(item)) { + return undefined; + } + const { privateJwk, publicJwk, nonce, salt } = JSON.parse(item); + const privateKey = await crypto.subtle.importKey( + "jwk", + privateJwk, + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign"], + ); + const publicKey = await crypto.subtle.importKey( + "jwk", + publicJwk, + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["verify"], + ); + return { + identity: await ECDSAKeyIdentity.fromKeyPair({ publicKey, privateKey }), + nonce, + salt: new Uint8Array(fromBase64(salt)), + }; +}; + +export const persistentSessionStore: SessionStore = { + init: async ({ canisterId, agentOptions }) => { + const { identity, nonce, salt } = (await read()) ?? (await create()); + const agent = LazyHttpAgent.createLazy({ ...agentOptions, identity }); + const actor = Actor.createActor<_SERVICE>(internet_identity_idl, { + agent, + canisterId, + }); + internalStore.set({ identity, agent, actor, nonce, salt }); + }, + subscribe: derived(internalStore, (session) => { + if (isNullish(session)) { + throw new Error("Not initialized"); + } + return session; + }).subscribe, + reset: async () => { + const { identity, nonce, salt } = await create(); + internalStore.update((session) => { + if (isNullish(session)) { + throw new Error("Not initialized"); + } + const { agent, actor } = session; + agent.replaceIdentity(identity); + return { identity, agent, actor, nonce, salt }; + }); + }, +}; diff --git a/src/frontend/src/lib/stores/session.store.ts b/src/frontend/src/lib/stores/session.store.ts index c9d07f6222..48dea433d8 100644 --- a/src/frontend/src/lib/stores/session.store.ts +++ b/src/frontend/src/lib/stores/session.store.ts @@ -22,7 +22,7 @@ export interface Session { salt: Uint8Array; } -type SessionStore = Readable & { +export type SessionStore = Readable & { init: (params: { canisterId: Principal; agentOptions: HttpAgentOptions; diff --git a/src/frontend/src/lib/utils/openID.ts b/src/frontend/src/lib/utils/openID.ts index c311d58c7e..037bcff39e 100644 --- a/src/frontend/src/lib/utils/openID.ts +++ b/src/frontend/src/lib/utils/openID.ts @@ -97,14 +97,14 @@ export const isOpenIdCancelError = (error: unknown) => { }; /** - * Request JWT through redirect flow in a popup + * Create JWT request redirect flow URL * @param config of the OpenID provider * @param options for the JWT request */ -const requestWithRedirect = async ( +export const createRedirectURL = ( config: Omit, options: RequestOptions, -): Promise => { +): URL => { const state = toBase64URL( window.crypto.getRandomValues(new Uint8Array(12)).buffer, ); @@ -129,11 +129,24 @@ const requestWithRedirect = async ( authURL.searchParams.set("login_hint", options.loginHint); } - const callback = await redirectInPopup(authURL.href); + return authURL; +}; + +/** + * Request JWT through redirect flow in a popup + * @param config of the OpenID provider + * @param options for the JWT request + */ +const requestWithRedirect = async ( + config: Omit, + options: RequestOptions, +): Promise => { + const redirectURL = createRedirectURL(config, options); + const callback = await redirectInPopup(redirectURL.href); const callbackURL = new URL(callback); const searchParams = new URLSearchParams(callbackURL.hash.slice(1)); const id_token = searchParams.get("id_token"); - if (searchParams.get("state") !== state) { + if (searchParams.get("state") !== redirectURL.searchParams.get("state")) { throw new Error("Invalid state"); } if (isNullish(id_token)) { diff --git a/src/frontend/src/lib/utils/postMessageInterface.ts b/src/frontend/src/lib/utils/postMessageInterface.ts new file mode 100644 index 0000000000..578958efb5 --- /dev/null +++ b/src/frontend/src/lib/utils/postMessageInterface.ts @@ -0,0 +1,351 @@ +import { AuthRequest } from "$lib/legacy/flows/authorize/postMessageInterface"; +import { type SignedDelegation as FrontendSignedDelegation } from "@dfinity/identity"; +import { + isJsonRpcRequest, + type SupportedStandard, + type PermissionScope, + type JsonRequest, + type DelegationRequest, +} from "@slide-computer/signer"; +import { + AuthorizeClientEvents, + authorizeClientFunnel, +} from "./analytics/authorizeClientFunnel"; +import { isNullish, nonNullish } from "@dfinity/utils"; +import { fromBase64, toBase64 } from "$lib/utils/utils"; +import { + AuthenticationV2Events, + authenticationV2Funnel, +} from "$lib/utils/analytics/authenticationV2Funnel"; + +interface HeartbeatServerOptions { + /** + * Callback when first heartbeat has been received + */ + onEstablish: (origin: string, source: MessageEventSource) => void; + /** + * Reasonable time in milliseconds in which the communication channel needs to be established + * @default 2000 + */ + establishTimeout?: number; + /** + * Callback when no heartbeats have been received for {@link establishTimeout} milliseconds + */ + onEstablishTimeout: () => void; + /** + * Time in milliseconds of not receiving heartbeat requests after which the communication channel is disconnected + * @default 2000 + */ + disconnectTimeout?: number; + /** + * Callback when no heartbeats have been received for {@link disconnectTimeout} milliseconds + */ + onDisconnect: () => void; + /** + * Signer window, used to listen for incoming message events + * @default globalThis.window + */ + window?: Window; +} + +// Copied from signer-js +class HeartbeatServer { + readonly #options: Required; + + constructor(options: HeartbeatServerOptions) { + this.#options = { + establishTimeout: 10000, + disconnectTimeout: 2000, + window: globalThis.window, + ...options, + }; + + this.#establish(); + } + + #establish(): void { + // Establish communication channel if a request is received + const listener = this.#receiveStatusRequest((request) => { + if (!request.source) { + return; + } + listener(); + clearTimeout(timeout); + + this.#options.onEstablish(request.origin, request.source); + this.#maintain(request.origin, request.source); + }); + + // Init timeout + const timeout = setTimeout(() => { + listener(); + + this.#options.onEstablishTimeout(); + }, this.#options.establishTimeout); + } + + #maintain(origin: string, source: MessageEventSource): void { + let timeout: ReturnType; + + // Clear existing timeout (if any) and create a new one + const resetTimeout = () => { + clearTimeout(timeout); + timeout = setTimeout(() => { + listener(); + + this.#options.onDisconnect(); + }, this.#options.disconnectTimeout); + }; + + // Init timeout and start sending messages + resetTimeout(); + + // Reset disconnect timeout and send response if a request is received + const listener = this.#receiveStatusRequest((request) => { + if ( + request.origin === origin && + request.source === source && + request.data.id !== undefined + ) { + resetTimeout(); + this.#sendReadyResponse(request.data.id, request.source); + } + }); + } + + #receiveStatusRequest( + handler: (event: MessageEvent>) => void, + ): () => void { + const listener = (event: MessageEvent) => { + if ( + isJsonRpcRequest(event.data) && + event.data.method === "icrc29_status" + ) { + handler(event); + } + }; + this.#options.window.addEventListener("message", listener); + return () => this.#options.window.removeEventListener("message", listener); + } + + #sendReadyResponse(id: string | number, source: MessageEventSource): void { + source.postMessage( + { jsonrpc: "2.0", id, result: "ready" }, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + "*", + ); + } +} + +const supportedStandards: SupportedStandard[] = [ + { + name: "ICRC-25", + url: "https://github.com/dfinity/wg-identity-authentication/blob/main/topics/icrc_25_signer_interaction_standard.md", + }, + { + name: "ICRC-29", + url: "https://github.com/dfinity/wg-identity-authentication/blob/main/topics/icrc_29_window_post_message_transport.md", + }, + { + name: "ICRC-34", + url: "https://github.com/dfinity/wg-identity-authentication/blob/main/topics/icrc_34_delegation.md", + }, + { + name: "ICRC-95", + url: "https://github.com/dfinity/wg-identity-authentication/blob/main/topics/icrc_95_derivationorigin.md", + }, +]; +const scopes: PermissionScope[] = [ + { + method: "icrc34_delegation", + }, +]; + +/** + * The postMessage-based authentication protocol. + */ +export function rpcAuthenticationProtocol({ + authenticate, + onProgress, +}: { + /** The callback used to get auth data (i.e. select or create anchor) */ + authenticate: (authContext: { + authRequest: AuthRequest; + requestOrigin: string; + }) => Promise< + | { + kind: "success"; + delegations: FrontendSignedDelegation[]; + userPublicKey: Uint8Array; + authnMethod: "pin" | "passkey" | "recovery"; + } + | { kind: "failure"; text: string } + | { kind: "unverified-origin"; text: string } + >; + /* Progress update messages to let the user know what's happening. */ + onProgress: (state: "waiting" | "validating") => void; +}): Promise< + "orphan" | "closed" | "invalid" | "success" | "failure" | "unverified-origin" +> { + return new Promise((resolve) => { + authorizeClientFunnel.init(); + onProgress("waiting"); + new HeartbeatServer({ + onDisconnect(): void { + resolve("closed"); + }, + onEstablish(origin: string, source: MessageEventSource): void { + window.addEventListener("message", async (event) => { + console.log("event", event); + // Ignore invalid requests + if ( + event.origin !== origin || + event.source !== source || + !isJsonRpcRequest(event.data) + ) { + return; + } + // ICRC-25 + if (event.data.method === "icrc25_supported_standards") { + source.postMessage({ + id: event.data.id, + jsonrpc: "2.0", + result: { + supportedStandards, + }, + }); + return; + } + if ( + event.data.method === "icrc25_permissions" || + event.data.method === "icrc25_request_permissions" + ) { + source.postMessage({ + id: event.data.id, + jsonrpc: "2.0", + result: { + scopes: scopes.map((scope) => ({ + scope, + state: "granted", + })), + }, + }); + return; + } + // ICRC-34 + if (event.data.method === "icrc34_delegation") { + const delegationRequest = event.data as DelegationRequest; + // Ignore if params are missing + if (isNullish(delegationRequest.params)) { + return; + } + // Create context + const derivationOrigin = + "icrc95DerivationOrigin" in delegationRequest.params && + nonNullish(delegationRequest.params.icrc95DerivationOrigin) && + typeof delegationRequest.params.icrc95DerivationOrigin === + "string" + ? delegationRequest.params.icrc95DerivationOrigin + : undefined; + const requestOrigin = derivationOrigin ?? origin; + + onProgress("validating"); + + let authenticateResult; + // This should not fail, but there is a big drop-off in the funnel here. + // It most probably means users closing the window, but we should investigate. + try { + authenticateResult = await authenticate({ + authRequest: { + kind: "authorize-client", + sessionPublicKey: new Uint8Array( + fromBase64(delegationRequest.params.publicKey), + ), + maxTimeToLive: nonNullish( + delegationRequest.params.maxTimeToLive, + ) + ? BigInt(delegationRequest.params.maxTimeToLive) + : undefined, + derivationOrigin, + }, + requestOrigin, + }); + authorizeClientFunnel.trigger(AuthorizeClientEvents.Authenticate); + authorizeClientFunnel.close(); + authenticationV2Funnel.trigger( + AuthenticationV2Events.AuthSuccess, + ); + authenticationV2Funnel.close(); + } catch (error: unknown) { + console.error("Unexpected error during authentication", error); + authenticateResult = { + kind: "failure" as const, + text: "There was an unexpected error, please try again.", + }; + } + + if ( + authenticateResult.kind === "failure" || + authenticateResult.kind === "unverified-origin" + ) { + authorizeClientFunnel.trigger( + AuthorizeClientEvents.AuthenticateError, + { + origin: requestOrigin, + failureReason: authenticateResult.text, + }, + ); + source.postMessage( + { + id: event.data.id, + jsonrpc: "2.0", + error: { + code: 1000, + message: "Generic error", + description: authenticateResult.text, + }, + }, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + "*", + ); + resolve(authenticateResult.kind); + return; + } + void (authenticateResult.kind satisfies "success"); + authorizeClientFunnel.trigger( + AuthorizeClientEvents.AuthenticateSuccess, + ); + source.postMessage( + { + id: event.data.id, + jsonrpc: "2.0", + result: { + publicKey: toBase64(authenticateResult.userPublicKey.buffer), + signerDelegation: authenticateResult.delegations.map( + (delegation) => ({ + delegation: { + pubkey: toBase64(delegation.delegation.pubkey), + expiration: + delegation.delegation.expiration.toString(10), + }, + signature: toBase64(delegation.signature), + }), + ), + }, + }, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + "*", + ); + resolve("success"); + } + }); + }, + onEstablishTimeout(): void { + resolve("closed"); + }, + }); + }); +} diff --git a/src/frontend/src/routes/(new-styling)/auth/+page.ts b/src/frontend/src/routes/(new-styling)/auth/+page.ts new file mode 100644 index 0000000000..e2cbef8676 --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/auth/+page.ts @@ -0,0 +1,37 @@ +import type { PageLoad } from "./$types"; +import { redirect } from "@sveltejs/kit"; +import { nonNullish } from "@dfinity/utils"; +import { canisterConfig } from "$lib/globals"; +import { createRedirectURL } from "$lib/utils/openID"; +import { get } from "svelte/store"; +import { persistentSessionStore } from "$lib/stores/persistent-session.store"; + +export const load: PageLoad = async ({ url }) => { + // Skip interface when OpenID provider is provided, directly redirect first + const openIdProvider = url.searchParams.get("openid"); + const openIdConfig = nonNullish(openIdProvider) + ? canisterConfig.openid_configs?.[0]?.find( + (provider) => + provider.name.toLowerCase() === openIdProvider.toLowerCase(), + ) + : undefined; + if (nonNullish(openIdConfig)) { + await persistentSessionStore.reset(); // Always reset before first use + const redirectURL = createRedirectURL( + { + clientId: openIdConfig.client_id, + authURL: openIdConfig.auth_uri, + authScope: openIdConfig.auth_scope.join(" "), + }, + { + nonce: get(persistentSessionStore).nonce, + mediation: "required", + }, + ); + const redirectState = redirectURL.searchParams.get("state")!; + sessionStorage.setItem("ii-oauth-redirect-state", redirectState); + throw redirect(307, redirectURL); + } + + throw redirect(307, "/authorize?rpc"); +}; diff --git a/src/frontend/src/routes/(new-styling)/authorize/(panel)/+page.svelte b/src/frontend/src/routes/(new-styling)/authorize/(panel)/+page.svelte index b8d2d96856..0c0a9d3be9 100644 --- a/src/frontend/src/routes/(new-styling)/authorize/(panel)/+page.svelte +++ b/src/frontend/src/routes/(new-styling)/authorize/(panel)/+page.svelte @@ -1,7 +1,7 @@
@@ -161,6 +170,12 @@
{#if status === "authenticating"} {@render children()} + {:else if status === "waiting" && data.rpc} + +
+ +

Loading

+
{:else if status === "authorizing"}
diff --git a/src/frontend/src/routes/(new-styling)/authorize/+layout.ts b/src/frontend/src/routes/(new-styling)/authorize/+layout.ts new file mode 100644 index 0000000000..c134024322 --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/authorize/+layout.ts @@ -0,0 +1,5 @@ +export const load = ({ url }) => { + return { + rpc: url.searchParams.has("rpc"), + }; +}; diff --git a/src/frontend/src/routes/(new-styling)/authorize/openid/+page.svelte b/src/frontend/src/routes/(new-styling)/authorize/openid/+page.svelte new file mode 100644 index 0000000000..01558da9b9 --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/authorize/openid/+page.svelte @@ -0,0 +1,29 @@ + + + +
+ +

Authenticating

+
+
diff --git a/src/frontend/src/routes/callback/+page.svelte b/src/frontend/src/routes/callback/+page.svelte index b80c4e23be..74753fa12a 100644 --- a/src/frontend/src/routes/callback/+page.svelte +++ b/src/frontend/src/routes/callback/+page.svelte @@ -1,9 +1,30 @@ - + onMount(() => { + // If OpenID flow was opened within same window as II, + // redirect with JWT to authorize page directly instead. + const redirectState = sessionStorage.getItem("ii-oauth-redirect-state"); + const searchParams = new URLSearchParams(window.location.hash.slice(1)); + const state = searchParams.get("state"); + const jwt = searchParams.get("id_token"); + if ( + nonNullish(redirectState) && + redirectState === state && + nonNullish(jwt) + ) { + goto("/authorize/openid?rpc", { state: { jwt } }); + return; + } + + // User was returned here after redirect from a OpenID flow callback, + // these flows are always handled in a popup and the callback url is + // returned to the opener window through the PostMessage API. + window.opener.postMessage(window.location.href, window.location.origin); + }); +