Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
129 commits
Select commit Hold shift + click to select a range
e0fdc2c
feat(fe): hand apps a session to re-issue their own delegations from
sea-snake Aug 22, 2026
b045cca
feat(fe): let an app cap how long its session lasts
sea-snake Aug 23, 2026
6e424ca
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Aug 25, 2026
f41338c
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Aug 25, 2026
299f507
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Aug 25, 2026
dd089bd
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Aug 31, 2026
b63b6b1
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Aug 31, 2026
b17d6c4
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Aug 31, 2026
3f8f2f6
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Aug 31, 2026
e6dd29d
feat(sessions): record the account mapping alongside the session
sea-snake Aug 31, 2026
257e7d1
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Aug 31, 2026
37f16ad
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Aug 31, 2026
79a0711
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 1, 2026
1f4a744
refactor(fe): send current_device_key by the name the canister now uses
sea-snake Sep 1, 2026
079fe46
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 1, 2026
7559a30
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 1, 2026
ed9228c
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 1, 2026
cd912eb
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 1, 2026
9172669
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 2, 2026
7ac1a65
feat(fe): carry the app's idle bound to the canister
sea-snake Sep 2, 2026
f018dd1
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 4, 2026
c3e04d1
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 4, 2026
05f7e40
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 4, 2026
4e17ea0
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 4, 2026
0920e97
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 4, 2026
aa3ddfb
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 4, 2026
889fc2b
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 4, 2026
7a7e622
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 5, 2026
2b7a48d
fix(fe): name the session get_account_session is fetching
sea-snake Sep 5, 2026
24ffb57
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 5, 2026
6074e87
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 5, 2026
3e98ee8
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 5, 2026
8aaaeeb
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 5, 2026
26f787c
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 5, 2026
f10937a
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 5, 2026
ce2afee
fix(fe): name the session get_account_session is fetching by its id
sea-snake Sep 5, 2026
571c246
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 5, 2026
0c5ff96
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 5, 2026
0bc3e59
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 5, 2026
7b70f47
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 5, 2026
3998ed1
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 5, 2026
8cbf7d0
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 5, 2026
53c2485
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 5, 2026
659cc9b
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 5, 2026
69579e4
feat(fe): retry a sign-in the canister refused as a stale key
sea-snake Sep 5, 2026
6a81cd8
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 5, 2026
a643211
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 5, 2026
bab2812
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 5, 2026
daea5c2
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 5, 2026
5046aaf
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 5, 2026
20fa2bb
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 5, 2026
90670b7
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 5, 2026
5614385
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 5, 2026
a0fa407
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 5, 2026
4070355
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 5, 2026
084d56a
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 5, 2026
c729076
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 5, 2026
b10bf1e
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 5, 2026
dd02507
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 5, 2026
9dabb91
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 5, 2026
ec57fa2
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 5, 2026
858cf96
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 5, 2026
87ed6c3
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 5, 2026
b94e9c5
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 5, 2026
32a2837
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 6, 2026
997991d
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 6, 2026
6d1da21
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 6, 2026
9e86af6
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 6, 2026
a119727
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 6, 2026
cc602f9
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 6, 2026
c378843
refactor(be): a browser, not a session device
sea-snake Sep 6, 2026
6de75c1
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 6, 2026
c446a88
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 6, 2026
92174df
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 6, 2026
21f33a4
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 6, 2026
0274624
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 6, 2026
e3f9175
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 6, 2026
9f3be29
refactor(fe): the sign-in handler names a browser
sea-snake Sep 6, 2026
144e2a8
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 6, 2026
146d849
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 6, 2026
52cd973
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 6, 2026
2073d02
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 6, 2026
6f57b43
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 6, 2026
5493377
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 7, 2026
a527777
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 7, 2026
79080db
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 7, 2026
ad7621f
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 7, 2026
4fa6831
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 7, 2026
efb3f3a
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 7, 2026
5eec2ff
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 7, 2026
fde140e
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 7, 2026
11af3a7
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 7, 2026
64f3dde
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 7, 2026
5255223
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 7, 2026
7f81737
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 7, 2026
e50a887
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 8, 2026
bc83d9a
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 8, 2026
2e60a01
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 8, 2026
13256b2
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 8, 2026
19eafb3
chore: merge fe/app-session-store
sea-snake Sep 8, 2026
7775758
chore: merge fe/app-session-store
sea-snake Sep 8, 2026
cf62055
chore: merge fe/app-session-store
sea-snake Sep 8, 2026
e244425
chore: merge fe/app-session-store
sea-snake Sep 8, 2026
c910b4b
chore: merge fe/app-session-store
sea-snake Sep 8, 2026
4c788d4
chore: merge fe/app-session-store
sea-snake Sep 8, 2026
bbbf460
chore: merge fe/app-session-store
sea-snake Sep 8, 2026
d15a2ab
feat(frontend): send what the browser is, not a label for it
sea-snake Sep 8, 2026
20aec53
chore: merge fe/app-session-store
sea-snake Sep 8, 2026
2a2e5d3
chore: merge fe/app-session-store
sea-snake Sep 9, 2026
6955c79
chore: merge fe/app-session-store
sea-snake Sep 9, 2026
667fa9d
chore: merge fe/app-session-store
sea-snake Sep 9, 2026
89319bb
chore: merge fe/app-session-store
sea-snake Sep 9, 2026
e4d2605
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 9, 2026
b402663
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 9, 2026
9a398c9
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 9, 2026
3d5847c
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 9, 2026
beb78b9
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 9, 2026
617a50a
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 9, 2026
f58f71c
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 9, 2026
b2cec72
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 9, 2026
a6003c5
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 10, 2026
a60b970
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 10, 2026
bb25bf4
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 10, 2026
1d1860d
fix(session-delegation): a duration it cannot read is an error, not s…
sea-snake Sep 10, 2026
17a9ae4
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 10, 2026
7dfa92c
fix(session-delegation): keep the targets the canister signed
sea-snake Sep 10, 2026
0803107
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 10, 2026
e371f37
fix(transport): accept only decimal digits as a nat64
sea-snake Sep 10, 2026
e0044a5
Merge branch 'fe/app-session-store' into fe/ii-session-delegation-han…
sea-snake Sep 10, 2026
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
5 changes: 4 additions & 1 deletion src/frontend/src/lib/stores/channelHandlers/icrc25.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ const supportedStandards = [
},
];

const scopes = [{ method: "icrc34_delegation" }];
const scopes = [
{ method: "icrc34_delegation" },
{ method: "ii_session_delegation" },
];

/** ICRC-25: respond with the list of supported standards. */
export const handleSupportedStandards =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import "fake-indexeddb/auto";

const ORIGIN = "https://app.example.com";

vi.mock("$lib/globals", async () => {
const { Principal } = await import("@icp-sdk/core/principal");
return {
canisterId: Principal.fromText("rwlgt-iiaaa-aaaaa-aaaaa-cai"),
backendCanisterConfig: { openid_configs: [] },
frontendCanisterConfig: { related_origins: [], dev_csp: [] },
};
});
vi.mock("$lib/utils/validateDerivationOrigin", () => ({
validateDerivationOrigin: vi.fn(() => Promise.resolve({ result: "valid" })),
}));

const setRequestContext = vi.fn();

const IDENTITY = BigInt(10_000);
const prepareAccountSession = vi.fn();
const getAccountSession = vi.fn();

vi.mock("$lib/stores/authorization.store", () => ({
authorizationStore: {
setRequestContext: (...args: unknown[]) => setRequestContext(...args),
},
// A store that already holds its value, which is what `waitForStore` waits for.
// Inlined rather than shared, because `vi.mock` is hoisted above anything declared
// here.
authorizedStore: {
subscribe: (run: (value: unknown) => void) => {
run({
accessLevel: "full-access",
maxTimeToLive: undefined,
accountNumberPromise: Promise.resolve(undefined),
});
return () => {};
},
},
}));
vi.mock("$lib/stores/authentication.store", () => ({
authenticationStore: {
subscribe: (run: (value: unknown) => void) => {
run({
identityNumber: BigInt(10_000),
authMethod: { passkey: {} },
actor: {
prepare_account_session: (...args: unknown[]) =>
prepareAccountSession(...args),
get_account_session: (...args: unknown[]) =>
getAccountSession(...args),
},
});
return () => {};
},
},
}));

import {
asBrowserKeyError,
handleSessionDelegationRequest,
} from "./sessionDelegation";
import { StaleBrowserKeyError } from "$lib/stores/browser-key.store";
import { CanisterError } from "$lib/utils/utils";
import {
appSessionsForOrigin,
purgeAppSessions,
} from "$lib/stores/app-session.store";
import { ECDSAKeyIdentity } from "@icp-sdk/core/identity";
import { Principal } from "@icp-sdk/core/principal";
import { Base64ToBytesCodec } from "$lib/utils/transport/utils";

const channelWith = () => {
const sent: unknown[] = [];
return {
channel: {
origin: ORIGIN,
closed: false,
resumeToken: "token",
addEventListener: () => () => {},
send: (response: unknown) => {
sent.push(response);
return Promise.resolve();
},
close: async () => {},
},
sent,
};
};

describe("ii_session_delegation", () => {
beforeEach(async () => {
setRequestContext.mockClear();
await purgeAppSessions(BigInt(10_000));
await purgeAppSessions(BigInt(10_001));
});

it("ignores a request for another method", async () => {
const { channel, sent } = channelWith();
const onError = vi.fn();

await handleSessionDelegationRequest(
channel,
onError,
)({
jsonrpc: "2.0",
id: 1,
method: "icrc34_delegation",
});

expect(sent).toEqual([]);
expect(onError).not.toHaveBeenCalled();
});

it("rejects params that carry no session key", async () => {
const { channel, sent } = channelWith();
const onError = vi.fn();

await handleSessionDelegationRequest(
channel,
onError,
)({
jsonrpc: "2.0",
id: 1,
method: "ii_session_delegation",
params: {},
});

expect(sent).toHaveLength(1);
expect(sent[0]).toMatchObject({ id: 1, error: { code: -32602 } });
expect(onError).toHaveBeenCalledWith("invalid-request");
});

/// A duration `BigInt` cannot read throws out of `safeParse`, which sits above the
/// handler's `try`, so the app would be told nothing at all. The rest `BigInt` reads
/// happily as something else: `""` and `" "` are `0n`, `"+1"` is `1n`, `"0x10"` is
/// `16n`, and the nat64 bounds reject none of them — so the canister would clamp a
/// number the app never meant to send.
it.each(["not a number", "", " ", "+1", "0x10", "-1"])(
"rejects %o as a duration",
async (maxTimeToLive) => {
const { channel, sent } = channelWith();
const onError = vi.fn();

await handleSessionDelegationRequest(
channel,
onError,
)({
jsonrpc: "2.0",
id: 1,
method: "ii_session_delegation",
params: {
sessionPublicKey: btoa("an app key"),
maxTimeToLive,
},
});

expect(sent).toHaveLength(1);
expect(sent[0]).toMatchObject({ id: 1, error: { code: -32602 } });
expect(onError).toHaveBeenCalledWith("invalid-request");
},
);

/// The whole ceremony, which nothing else here reaches: what the canister is asked
/// for, what is kept, and what the app is handed back.
it("mints a session and answers with a chain the app can use", async () => {
const { channel, sent } = channelWith();
const appKey = await ECDSAKeyIdentity.generate({ extractable: false });
const appPublicKey = new Uint8Array(appKey.getPublicKey().toDer());
const expiration = BigInt(Date.now() + 60 * 60 * 1000) * BigInt(1_000_000);

prepareAccountSession.mockImplementation(({ session_key }) =>
Promise.resolve({
Ok: {
user_key: session_key,
expiration,
session_id: BigInt(77),
browser_id: 3,
account_principal: Principal.anonymous(),
},
}),
);
getAccountSession.mockImplementation(({ session_key }) =>
Promise.resolve({
Ok: {
signed_delegation: {
// As the canister answers since the session credential was scoped: the
// targets are part of what it signed, so a chain rebuilt without them is
// refused by the replica.
delegation: {
pubkey: session_key,
expiration,
targets: [[Principal.fromText("rwlgt-iiaaa-aaaaa-aaaaa-cai")]],
},
// At least 32 bytes: the chain's own parser refuses anything shorter.
signature: new Uint8Array(64).fill(7),
},
},
}),
);

await handleSessionDelegationRequest(
channel,
vi.fn(),
)({
jsonrpc: "2.0",
id: 1,
method: "ii_session_delegation",
params: { sessionPublicKey: Base64ToBytesCodec.encode(appPublicKey) },
});

// Asked for what the request and the consent said, at this origin.
expect(prepareAccountSession).toHaveBeenCalledWith(
expect.objectContaining({
identity_number: IDENTITY,
origin: ORIGIN,
account_number: [],
}),
);

// Kept, so a later silent re-auth resumes rather than signing in again — and kept
// against II's own key, never the app's.
const [stored] = await appSessionsForOrigin(ORIGIN);
expect(stored.record.sessionId).toBe(BigInt(77));
expect(stored.identityNumber).toBe(IDENTITY);

// Answered, and the chain ends at the app's key rather than at what the canister
// signed: the hop only II can make is what makes the on-chain half unusable alone.
expect(sent).toHaveLength(1);
expect(sent[0]).toMatchObject({ id: 1 });
const result = (
sent[0] as {
result: {
publicKey: string;
signerDelegation: { delegation: { targets?: string[] } }[];
};
}
).result;
expect(result.publicKey).toEqual(expect.any(String));

// The hop the canister signed keeps its targets. Dropping them leaves a delegation
// that hashes to nothing in the signature tree, and every call the app makes with
// this chain comes back "Invalid canister signature".
expect(result.signerDelegation[0].delegation.targets).toEqual([
"rwlgt-iiaaa-aaaaa-aaaaa-cai",
]);
});
});

describe("asBrowserKeyError", () => {
it("names a retired browser key so the key store can promote its successor", () => {
const stale = asBrowserKeyError(
new CanisterError({ StaleBrowserKey: null }),
);

expect(stale).toBeInstanceOf(StaleBrowserKeyError);
});

it("leaves every other canister error alone", () => {
const other = new CanisterError({ NoSuchAccount: null });

expect(asBrowserKeyError(other)).toBe(other);
});

it("leaves a transport failure alone", () => {
const network = new Error("network");

expect(asBrowserKeyError(network)).toBe(network);
});
});
Comment thread
sea-snake marked this conversation as resolved.
Loading
Loading