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
>
+
+ Use ICRC-25 protocol:
+
+
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}
+
+
{: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 @@
+
+
+
+
+
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);
+ });
+