Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions demos/test-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions demos/test-app/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -30,14 +32,29 @@ export const authWithII = async ({
derivationOrigin,
sessionIdentity,
autoSelectionPrincipal,
useIcrc25,
}: {
url: string;
maxTimeToLive?: bigint;
allowPinAuthentication?: boolean;
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";
Expand Down
9 changes: 9 additions & 0 deletions demos/test-app/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@
margin: 1rem 0;
overflow-wrap: break-word;
}

.postMessage-title {
font-weight: bold;
}

.received {
background-color: lightblue;
}

.sent {
background-color: lightgreen;
}
Expand Down Expand Up @@ -69,6 +72,12 @@ <h2>Sign In</h2>
>
<input type="checkbox" checked id="allowPinAuthentication" />
</div>
<div>
<label for="useIcrc25" style="display: inline-block; width: 200px"
>Use ICRC-25 protocol:</label
>
<input type="checkbox" id="useIcrc25" />
</div>
<button data-action="authenticate" id="signinBtn">Sign In</button>
<h3>/.well-known/ii-alternative-origins</h3>
<div id="alternativeOrigins"></div>
Expand Down
3 changes: 3 additions & 0 deletions demos/test-app/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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();
Expand Down Expand Up @@ -241,6 +243,7 @@ const init = async () => {
allowPinAuthentication,
sessionIdentity: getLocalIdentity(),
autoSelectionPrincipal,
useIcrc25: useIcrc25El.checked,
});
delegationIdentity = result.identity;
updateDelegationView({
Expand Down
179 changes: 179 additions & 0 deletions demos/test-app/src/signer-web/icrc29/heartbeat/client.ts
Original file line number Diff line number Diff line change
@@ -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<Crypto, "randomUUID">;
}

export class HeartbeatClient {
readonly #options: Required<HeartbeatClientOptions>;

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<string | number> = [];

// 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<typeof setInterval>;
let timeout: ReturnType<typeof setTimeout>;
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<JsonResponse<"ready">>) => 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" },
"*",
);
}
}
2 changes: 2 additions & 0 deletions demos/test-app/src/signer-web/icrc29/heartbeat/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./client";
export * from "./server";
115 changes: 115 additions & 0 deletions demos/test-app/src/signer-web/icrc29/heartbeat/server.ts
Original file line number Diff line number Diff line change
@@ -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<HeartbeatServerOptions>;

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<typeof setTimeout>;

// 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<JsonRequest<"icrc29_status">>) => 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" });
}
}
Loading
Loading