Skip to content

Commit 479a83e

Browse files
committed
refactor(fe): request-driven authorize flow with idle timeout
Replace the isAuthenticating flag (which blocked consent rendering) with a request-driven architecture: - New authorizeFlowComplete store: layout shows children when false, redirect screen when true. - Idle timeout: starts after each channel.send() response via a new 'response' event on the Channel interface. Resets when new requests arrive. Fires when no more requests come in within the timeout window. - AuthorizationChannel handles both icrc34_delegation and ii-icrc3-attributes requests, resolving the auth UI for either. - authorizationStore.authorize() is now pure business logic — no UI state. - AuthorizationContext: authRequest and requestId are optional (only present when delegation was requested). - Channel interface: added 'response' event, implemented in both PostMessageChannel and LegacyChannel.
1 parent ebbfe3b commit 479a83e

File tree

8 files changed

+170
-57
lines changed

8 files changed

+170
-57
lines changed

src/frontend/src/lib/components/utils/AuthorizationChannel.svelte

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
INVALID_PARAMS_ERROR_CODE,
99
} from "$lib/utils/transport/utils";
1010
import { authorizationStore } from "$lib/stores/authorization.store";
11+
import {
12+
resetIdleTimeout,
13+
startIdleTimeout,
14+
} from "$lib/stores/authorize-flow.store";
1115
import { z } from "zod";
1216
import FeaturedIcon from "$lib/components/ui/FeaturedIcon.svelte";
1317
import Button from "$lib/components/ui/Button.svelte";
@@ -45,17 +49,29 @@
4549
4650
const authorizeChannel = (channel: Channel): Promise<void> =>
4751
new Promise<void>((resolve, reject) => {
48-
// TODO: Standalone attribute requests (ii-icrc3-attributes without prior
49-
// icrc34_delegation) need a separate flow that doesn't depend on
50-
// authorizationContextStore. This will be addressed in a follow-up.
52+
let resolved = false;
53+
54+
// When any recognized request arrives, reset the idle timeout
55+
// so the flow stays active.
56+
channel.addEventListener("request", (request) => {
57+
if (request.id === undefined) {
58+
return;
59+
}
60+
if (
61+
request.method === "icrc34_delegation" ||
62+
request.method === "ii-icrc3-attributes"
63+
) {
64+
resetIdleTimeout();
65+
}
66+
});
5167
68+
// Handle delegation requests
5269
channel.addEventListener("request", async (request) => {
5370
if (
5471
request.id === undefined ||
5572
request.method !== "icrc34_delegation" ||
5673
$authorizationStore !== undefined
5774
) {
58-
// Ignore if it's a different method, or we're already processing
5975
return;
6076
}
6177
const result = DelegationParamsCodec.safeParse(request.params);
@@ -82,9 +98,12 @@
8298
request.id,
8399
result.data,
84100
);
85-
resolve();
101+
if (!resolved) {
102+
resolved = true;
103+
resolve();
104+
}
86105
} catch (error) {
87-
console.error(error); // Log error to console
106+
console.error(error);
88107
reject(
89108
new AuthorizeChannelError(
90109
$t`Unverified origin`,
@@ -93,6 +112,21 @@
93112
);
94113
}
95114
});
115+
116+
// Handle standalone attribute requests (resolve auth UI if not already)
117+
channel.addEventListener("request", (request) => {
118+
if (
119+
request.id === undefined ||
120+
request.method !== "ii-icrc3-attributes"
121+
) {
122+
return;
123+
}
124+
authorizationStore.setOrigin(channel.origin);
125+
if (!resolved) {
126+
resolved = true;
127+
resolve();
128+
}
129+
});
96130
});
97131
98132
let authorizePromise = $state(
@@ -116,6 +150,10 @@
116150
// Don't authorize if we're only doing an initial handshake
117151
return;
118152
}
153+
// Start the idle timeout after each response is sent.
154+
channel.addEventListener("response", () => {
155+
startIdleTimeout();
156+
});
119157
// Replace promise when channel closes after it was established
120158
channel.addEventListener("close", () => {
121159
authorizePromise = Promise.reject(

src/frontend/src/lib/stores/authorization.store.ts

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,10 @@ import { DelegationChain } from "@icp-sdk/core/identity";
1414
import { AuthRequest, DelegationParams } from "$lib/utils/transport/utils";
1515

1616
export type AuthorizationContext = {
17-
authRequest: AuthRequest; // Additional details e.g. derivation origin
18-
requestId: string | number; // The ID of the JSON RPC request
17+
authRequest?: AuthRequest; // Present when delegation was requested
18+
requestId?: string | number; // JSON RPC request ID for delegation
1919
requestOrigin: string; // Displayed to the user to identify the app
2020
effectiveOrigin: string; // Used for last used storage and delegations
21-
isAuthenticating: boolean; // True if user is being redirect back to app
2221
};
2322

2423
type AuthorizationStore = Readable<AuthorizationContext | undefined> & {
@@ -27,6 +26,7 @@ type AuthorizationStore = Readable<AuthorizationContext | undefined> & {
2726
requestId: string | number,
2827
params: DelegationParams,
2928
) => Promise<void>;
29+
setOrigin: (requestOrigin: string) => void;
3030
authorize: (
3131
accountNumber: Promise<bigint | undefined> | bigint | undefined,
3232
artificialDelay?: number,
@@ -60,16 +60,26 @@ export const authorizationStore: AuthorizationStore = {
6060
requestId,
6161
requestOrigin,
6262
effectiveOrigin,
63-
isAuthenticating: false,
63+
});
64+
},
65+
setOrigin: (requestOrigin) => {
66+
// Set minimal context (just origin) if no context exists yet.
67+
// Used for attributes-only flow where no delegation was requested.
68+
if (get(internalStore) !== undefined) {
69+
return;
70+
}
71+
internalStore.set({
72+
requestOrigin,
73+
effectiveOrigin: remapToLegacyDomain(requestOrigin),
6474
});
6575
},
6676
subscribe: (...args) => internalStore.subscribe(...args),
6777
authorize: async (accountNumberMaybePromise, artificialDelay) => {
6878
const context = get(authorizationContextStore);
69-
internalStore.set({
70-
...context,
71-
isAuthenticating: true,
72-
});
79+
if (context.authRequest === undefined || context.requestId === undefined) {
80+
throw new Error("Cannot authorize without a delegation request");
81+
}
82+
const { authRequest, requestId } = context;
7383
const { identityNumber, actor } = get(authenticatedStore);
7484
const artificialDelayPromise = waitFor(
7585
features.DUMMY_AUTH ||
@@ -83,9 +93,9 @@ export const authorizationStore: AuthorizationStore = {
8393
identityNumber,
8494
context.effectiveOrigin,
8595
accountNumber !== undefined ? [accountNumber] : [],
86-
context.authRequest.sessionPublicKey,
87-
context.authRequest.maxTimeToLive !== undefined
88-
? [context.authRequest.maxTimeToLive]
96+
authRequest.sessionPublicKey,
97+
authRequest.maxTimeToLive !== undefined
98+
? [authRequest.maxTimeToLive]
8999
: [],
90100
)
91101
.then(throwCanisterError);
@@ -95,7 +105,7 @@ export const authorizationStore: AuthorizationStore = {
95105
identityNumber,
96106
context.effectiveOrigin,
97107
accountNumber !== undefined ? [accountNumber] : [],
98-
context.authRequest.sessionPublicKey,
108+
authRequest.sessionPublicKey,
99109
expiration,
100110
)
101111
.then(throwCanisterError)
@@ -108,7 +118,7 @@ export const authorizationStore: AuthorizationStore = {
108118
),
109119
);
110120
await artificialDelayPromise;
111-
return { requestId: context.requestId, delegationChain };
121+
return { requestId, delegationChain };
112122
},
113123
};
114124

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { writable } from "svelte/store";
2+
3+
/**
4+
* Whether the authorize flow has completed all pending requests.
5+
*
6+
* - `false` — Requests are being handled or might still arrive. Layout renders children.
7+
* - `true` — No more requests expected. Layout shows the "you may close this page" screen.
8+
*/
9+
export const authorizeFlowComplete = writable<boolean>(false);
10+
11+
const IDLE_TIMEOUT_MS = 500;
12+
let idleTimeoutId: ReturnType<typeof setTimeout> | undefined;
13+
14+
/**
15+
* Start the idle countdown. When it fires, `authorizeFlowComplete` becomes `true`.
16+
* Call after sending a response on the channel — if no new request arrives
17+
* within the timeout window, the flow is considered complete.
18+
*/
19+
export const startIdleTimeout = () => {
20+
clearTimeout(idleTimeoutId);
21+
idleTimeoutId = setTimeout(() => {
22+
authorizeFlowComplete.set(true);
23+
}, IDLE_TIMEOUT_MS);
24+
};
25+
26+
/**
27+
* Cancel the idle countdown and mark the flow as active.
28+
* Called by the channel request listener when a new request arrives.
29+
*/
30+
export const resetIdleTimeout = () => {
31+
clearTimeout(idleTimeoutId);
32+
authorizeFlowComplete.set(false);
33+
};

src/frontend/src/lib/utils/transport/legacy.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ class LegacyChannel implements Channel {
206206
#redirectOrigin?: string;
207207
#authRequest?: AuthRequest;
208208
#closeListeners = new Set<() => void>();
209+
#responseListeners = new Set<(response: JsonResponse) => void>();
209210

210211
constructor(
211212
origin: string,
@@ -230,11 +231,19 @@ class LegacyChannel implements Channel {
230231
...[event, listener]:
231232
| [event: "close", listener: () => void]
232233
| [event: "request", listener: (request: JsonRequest) => void]
234+
| [event: "response", listener: (response: JsonResponse) => void]
233235
): () => void {
234236
if (event === "close") {
235237
this.#closeListeners.add(listener);
236238
return () => this.#closeListeners.delete(listener);
237239
}
240+
if (event === "response") {
241+
this.#responseListeners.add(listener as (response: JsonResponse) => void);
242+
return () =>
243+
this.#responseListeners.delete(
244+
listener as (response: JsonResponse) => void,
245+
);
246+
}
238247
// Replay auth request if it didn't get a response yet
239248
const authRequest = this.#authRequest;
240249
if (event === "request" && authRequest !== undefined) {
@@ -315,6 +324,7 @@ class LegacyChannel implements Channel {
315324
}
316325

317326
window.opener.postMessage(data, this.#origin);
327+
this.#responseListeners.forEach((listener) => listener(response));
318328
return Promise.resolve();
319329
}
320330

src/frontend/src/lib/utils/transport/postMessage.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class PostMessageChannel implements Channel {
2020
#closed = false;
2121
#requests: JsonRequest[] = [];
2222
#requestListeners = new Set<(request: JsonRequest) => void>();
23+
#responseListeners = new Set<(response: JsonResponse) => void>();
2324
#closeListeners = new Set<() => void>();
2425

2526
constructor(origin: string, source: WindowProxy) {
@@ -54,6 +55,7 @@ class PostMessageChannel implements Channel {
5455
...[event, listener]:
5556
| [event: "close", listener: () => void]
5657
| [event: "request", listener: (request: JsonRequest) => void]
58+
| [event: "response", listener: (response: JsonResponse) => void]
5759
): () => void {
5860
switch (event) {
5961
case "close":
@@ -69,18 +71,28 @@ class PostMessageChannel implements Channel {
6971
this.#requestListeners.delete(listener);
7072
};
7173
}
74+
case "response":
75+
this.#responseListeners.add(
76+
listener as (response: JsonResponse) => void,
77+
);
78+
return () => {
79+
this.#responseListeners.delete(
80+
listener as (response: JsonResponse) => void,
81+
);
82+
};
7283
}
7384
}
7485

7586
send(response: JsonResponse): Promise<void> {
7687
if (this.#closed) {
7788
throw new Error("Post message channel is closed");
7889
}
79-
// Send response and remove request
90+
// Send response, remove request, and notify response listeners
8091
this.#source.postMessage(response, this.#origin);
8192
this.#requests = this.#requests.filter(
8293
(request) => request.id !== response.id,
8394
);
95+
this.#responseListeners.forEach((listener) => listener(response));
8496
return Promise.resolve();
8597
}
8698

src/frontend/src/lib/utils/transport/utils.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ export interface Channel {
2121
event: "request",
2222
listener: (request: JsonRequest) => void,
2323
): () => void;
24+
addEventListener(
25+
event: "response",
26+
listener: (response: JsonResponse) => void,
27+
): () => void;
2428
send(response: JsonResponse): Promise<void>;
2529
close(): Promise<void>;
2630
}

0 commit comments

Comments
 (0)