diff --git a/src/canister_tests/src/api/internet_identity/api_v2.rs b/src/canister_tests/src/api/internet_identity/api_v2.rs index 070577865b..5e6ef4af49 100644 --- a/src/canister_tests/src/api/internet_identity/api_v2.rs +++ b/src/canister_tests/src/api/internet_identity/api_v2.rs @@ -773,6 +773,15 @@ pub fn get_account_session( query_candid_as(env, canister_id, sender, "get_account_session", (request,)).map(|(x,)| x) } +/// The II frontend's liveness check, called as the query it is declared as. +pub fn check_session( + env: &PocketIc, + canister_id: CanisterId, + sender: Principal, +) -> Result { + query_candid_as(env, canister_id, sender, "check_session", ()).map(|(x,)| x) +} + pub fn app_prepare_delegation( env: &PocketIc, canister_id: CanisterId, diff --git a/src/frontend/src/lib/generated/internet_identity_idl.js b/src/frontend/src/lib/generated/internet_identity_idl.js index 328564f1fb..8ee7ecd5d8 100644 --- a/src/frontend/src/lib/generated/internet_identity_idl.js +++ b/src/frontend/src/lib/generated/internet_identity_idl.js @@ -1067,6 +1067,7 @@ export const idlFactory = ({ IDL }) => { ], [], ), + 'check_session' : IDL.Func([], [IDL.Bool], ['query']), 'config' : IDL.Func([], [InternetIdentityInit], ['query']), 'create_account' : IDL.Func( [UserNumber, FrontendHostname, IDL.Text], diff --git a/src/frontend/src/lib/generated/internet_identity_types.d.ts b/src/frontend/src/lib/generated/internet_identity_types.d.ts index 77db8280c8..dfd5731c00 100644 --- a/src/frontend/src/lib/generated/internet_identity_types.d.ts +++ b/src/frontend/src/lib/generated/internet_identity_types.d.ts @@ -2143,6 +2143,13 @@ export interface _SERVICE { { 'Ok' : IdRegNextStepResult } | { 'Err' : CheckCaptchaError } >, + /** + * Whether the calling session is still usable. For the II frontend's silent + * re-auth path, which must decide whether it can answer without rendering + * anything. Advisory: a query reply is not certified, and every mint enforces + * the same conditions regardless of the answer here. + */ + 'check_session' : ActorMethod<[], boolean>, 'config' : ActorMethod<[], InternetIdentityInit>, 'create_account' : ActorMethod< [UserNumber, FrontendHostname, string], diff --git a/src/frontend/src/lib/stores/app-session.store.test.ts b/src/frontend/src/lib/stores/app-session.store.test.ts index 3e36fe82cd..4adb81c534 100644 --- a/src/frontend/src/lib/stores/app-session.store.test.ts +++ b/src/frontend/src/lib/stores/app-session.store.test.ts @@ -4,7 +4,6 @@ import { createStore, set as idbSet } from "idb-keyval"; import { appAccountsForOrigin, appSessionsForOrigin, - discardAppSession, purgeAppSessions, rememberAppAccount, storeAppSession, @@ -65,16 +64,6 @@ describe("app session store", () => { ]); }); - it("keeps the account mapping when the session is discarded", async () => { - const key = { identityNumber: BigInt(10_000), origin: ORIGIN }; - await rememberAppAccount(key, { accountPrincipal: "2vxsx-fae" }); - await storeAppSession(key, record(anHourFromNow())); - - await discardAppSession(key); - - await expect(appAccountsForOrigin(ORIGIN)).resolves.toHaveLength(1); - }); - it("keeps accounts of one identity apart", async () => { const identityNumber = BigInt(10_000); await storeAppSession( @@ -99,15 +88,6 @@ describe("app session store", () => { await expect(appSessionsForOrigin(ORIGIN)).resolves.toEqual([]); }); - it("discards a session", async () => { - const key = { identityNumber: BigInt(10_000), origin: ORIGIN }; - await storeAppSession(key, record(anHourFromNow())); - - await discardAppSession(key); - - await expect(appSessionsForOrigin(ORIGIN)).resolves.toEqual([]); - }); - it("lists every identity holding a session at one origin", async () => { await storeAppSession( { identityNumber: BigInt(10_000), origin: ORIGIN }, diff --git a/src/frontend/src/lib/stores/app-session.store.ts b/src/frontend/src/lib/stores/app-session.store.ts index abfe065aa0..2b5c1ed3f6 100644 --- a/src/frontend/src/lib/stores/app-session.store.ts +++ b/src/frontend/src/lib/stores/app-session.store.ts @@ -115,14 +115,6 @@ export const rememberAppAccount = async ( } }; -export const discardAppSession = async (key: SessionKey): Promise => { - try { - await idbDel(sessionKey(key), APP_SESSION_STORE); - } catch { - // A session that cannot be discarded locally is still revocable canister-side. - } -}; - /** Every session this identity holds, for the sibling lookup and for sign-out. * * Each carries the principal its account is known by, which lives in the other store diff --git a/src/frontend/src/lib/stores/authorization.store.ts b/src/frontend/src/lib/stores/authorization.store.ts index 0d3b385b84..b4ceb8d573 100644 --- a/src/frontend/src/lib/stores/authorization.store.ts +++ b/src/frontend/src/lib/stores/authorization.store.ts @@ -33,6 +33,18 @@ export type Authorized = { const contextInternal = writable(); const authorizedInternal = writable(); +export type AuthorizationPromptContext = { + prompt?: "none" | "login"; + hint?: string; + resumable?: boolean; +}; + +/** Kept out of `AuthorizationContext`, whose presence is what makes the sign-in UI + * render: URL state must not paint anything before there is a request to answer. */ +export const authorizationPromptStore = writable( + {}, +); + export const authorizationStore = { /** Called by the channel handler once the delegation request is parsed. * Sets the effective origin and the app's requested session duration in a diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts index 929510d0f9..7a9118475b 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts @@ -1,11 +1,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import "fake-indexeddb/auto"; +import { DelegationChain, ECDSAKeyIdentity } from "@icp-sdk/core/identity"; +import { Principal } from "@icp-sdk/core/principal"; +import type { Writable } from "svelte/store"; +const CANISTER_ID_TEXT = "rwlgt-iiaaa-aaaaa-aaaaa-cai"; const ORIGIN = "https://app.example.com"; vi.mock("$lib/globals", async () => { const { Principal } = await import("@icp-sdk/core/principal"); return { + agentOptions: {}, canisterId: Principal.fromText("rwlgt-iiaaa-aaaaa-aaaaa-cai"), backendCanisterConfig: { openid_configs: [] }, frontendCanisterConfig: { related_origins: [], dev_csp: [] }, @@ -15,48 +20,56 @@ 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 () => {}; +const checkSession = vi.fn(() => Promise.resolve(true)); +vi.mock("@icp-sdk/core/agent", async () => { + const actual = await vi.importActual( + "@icp-sdk/core/agent", + ); + return { + ...actual, + HttpAgent: { ...actual.HttpAgent, createSync: () => ({}) }, + Actor: { + ...actual.Actor, + createActor: () => ({ check_session: checkSession }), }, - }, + }; +}); +vi.mock("$lib/stores/authentication.store", async () => { + const { writable } = await import("svelte/store"); + return { authenticationStore: writable(undefined) }; +}); +vi.mock("$lib/stores/browser-key.store", async (importOriginal) => ({ + ...(await importOriginal()), + withBrowserProof: ( + _identityNumber: bigint, + _sessionKey: Uint8Array, + _description: unknown, + signIn: (proof: unknown) => Promise, + ) => + signIn({ + publicKey: new Uint8Array(), + nextPublicKey: new Uint8Array(), + signature: new Uint8Array(), + nextSignature: new Uint8Array(), + accept: () => Promise.resolve(), + }), })); -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 () => {}; - }, - }, +vi.mock("$lib/stores/channelHandlers/describeBrowser", () => ({ + describeBrowser: () => Promise.resolve("a browser"), })); +const setRequestContext = vi.fn(); +vi.mock("$lib/stores/authorization.store", async () => { + const { writable } = await import("svelte/store"); + return { + authorizationStore: { + setRequestContext: (...args: unknown[]) => setRequestContext(...args), + }, + authorizedStore: writable(undefined), + authorizationPromptStore: writable<{ prompt?: string; hint?: string }>({}), + }; +}); + import { asBrowserKeyError, handleSessionDelegationRequest, @@ -64,12 +77,88 @@ import { import { StaleBrowserKeyError } from "$lib/stores/browser-key.store"; import { CanisterError } from "$lib/utils/utils"; import { + appAccountsForOrigin, appSessionsForOrigin, + rememberAppAccount, purgeAppSessions, + storeAppSession, } 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"; +import { INTERACTION_REQUIRED_ERROR_CODE } from "$lib/utils/transport/utils"; + +/** Drives one ceremony to the point where the session it created is either kept or + * discarded, which is the whole of what `resumable` decides. */ +const runCeremony = async ( + resumable?: boolean, + extraParams: Record = {}, +) => { + const { authorizationPromptStore, authorizedStore } = + await import("$lib/stores/authorization.store"); + authorizationPromptStore.set(resumable === undefined ? {} : { resumable }); + + const identityNumber = BigInt(10_000); + const sessionKey = await ECDSAKeyIdentity.generate({ extractable: true }); + const expiration = BigInt(Date.now() + 60 * 60 * 1000) * BigInt(1_000_000); + const chain = await DelegationChain.create( + sessionKey, + sessionKey.getPublicKey(), + new Date(Number(expiration / BigInt(1_000_000))), + ); + const signed = chain.delegations[0]; + + const prepared: Record[] = []; + const actor = { + prepare_account_session: (request: Record) => ( + prepared.push(request), + Promise.resolve({ + Ok: { + user_key: new Uint8Array(chain.publicKey), + expiration, + session_id: BigInt(1_000), + account_principal: Principal.fromText("2vxsx-fae"), + browser_id: BigInt(1), + }, + }) + ), + get_account_session: () => + Promise.resolve({ + Ok: { + signed_delegation: { + delegation: { + pubkey: new Uint8Array(signed.delegation.pubkey), + expiration: signed.delegation.expiration, + targets: [], + }, + signature: new Uint8Array(signed.signature), + }, + }, + }), + }; + const { authenticationStore } = + await import("$lib/stores/authentication.store"); + // Both stores are mocked as plain writables above; only their real types are in + // scope here, and neither is writable or shaped like what the handler reads. + (authenticationStore as unknown as Writable).set({ + identityNumber, + actor, + authMethod: { passkey: { credentialId: new Uint8Array() } }, + }); + (authorizedStore as unknown as Writable).set({ + accountNumberPromise: Promise.resolve(undefined), + accessLevel: "full-access", + }); + + const { channel, sent } = channelWith(); + await handleSessionDelegationRequest( + channel, + vi.fn(), + )({ + jsonrpc: "2.0", + id: 1, + method: "ii_session_delegation", + params: { sessionPublicKey: await appKey(), ...extraParams }, + }); + return { sent, prepared }; +}; const channelWith = () => { const sent: unknown[] = []; @@ -89,6 +178,36 @@ const channelWith = () => { }; }; +const storedSession = async (identityNumber: bigint) => { + const key = await ECDSAKeyIdentity.generate({ extractable: false }); + const root = await ECDSAKeyIdentity.generate({ extractable: true }); + const chain = await DelegationChain.create( + root, + key.getPublicKey(), + new Date(Date.now() + 60 * 60 * 1000), + ); + await rememberAppAccount( + { identityNumber, origin: ORIGIN }, + { accountPrincipal: "2vxsx-fae" }, + ); + await storeAppSession( + { identityNumber, origin: ORIGIN }, + { + keyPair: key.getKeyPair(), + chainJson: JSON.stringify(chain.toJSON()), + expiresAtMillis: Date.now() + 60 * 60 * 1000, + sessionId: BigInt(1_000), + accessLevel: "full-access" as const, + }, + ); +}; + +const appKey = async () => { + const identity = await ECDSAKeyIdentity.generate({ extractable: true }); + const der = new Uint8Array(identity.getPublicKey().toDer()); + return btoa(String.fromCharCode(...der)); +}; + describe("ii_session_delegation", () => { beforeEach(async () => { setRequestContext.mockClear(); @@ -162,88 +281,442 @@ describe("ii_session_delegation", () => { }, ); - /// 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 () => { + it("answers a malformed silent request without rendering anything", async () => { + const { authorizationPromptStore } = + await import("$lib/stores/authorization.store"); + authorizationPromptStore.set({ prompt: "none" }); 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), - }, - }, - }), - ); + const onError = vi.fn(); await handleSessionDelegationRequest( channel, - vi.fn(), + onError, )({ jsonrpc: "2.0", id: 1, method: "ii_session_delegation", - params: { sessionPublicKey: Base64ToBytesCodec.encode(appPublicKey) }, + params: {}, }); - // Asked for what the request and the consent said, at this origin. - expect(prepareAccountSession).toHaveBeenCalledWith( - expect.objectContaining({ - identity_number: IDENTITY, - origin: ORIGIN, - account_number: [], - }), - ); + expect(sent).toHaveLength(1); + expect(sent[0]).toMatchObject({ id: 1, error: { code: -32602 } }); + expect(onError).not.toHaveBeenCalled(); + }); - // 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); + it("re-issues from a held session when silence is asked for", async () => { + await storedSession(BigInt(10_000)); + const { authorizationPromptStore } = + await import("$lib/stores/authorization.store"); + authorizationPromptStore.set({ prompt: "none" }); + const { channel, sent } = channelWith(); + const onError = vi.fn(); + + await handleSessionDelegationRequest( + channel, + onError, + )({ + jsonrpc: "2.0", + id: 1, + method: "ii_session_delegation", + params: { sessionPublicKey: await appKey() }, + }); - // 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(onError).not.toHaveBeenCalled(); + expect(setRequestContext).not.toHaveBeenCalled(); expect(sent).toHaveLength(1); - expect(sent[0]).toMatchObject({ id: 1 }); + const result = (sent[0] as { result: Record }).result; + // The chain is the whole answer: nothing else travels with it, and nothing has to be + // attached to the calls the app makes with it. + expect(Object.keys(result).sort()).toEqual([ + "publicKey", + "signerDelegation", + ]); + }); + + it("restricts the session chain to the II canister", async () => { + await storedSession(BigInt(10_000)); + const { authorizationPromptStore } = + await import("$lib/stores/authorization.store"); + authorizationPromptStore.set({ prompt: "none" }); + const { channel, sent } = channelWith(); + + await handleSessionDelegationRequest( + channel, + vi.fn(), + )({ + jsonrpc: "2.0", + id: 1, + method: "ii_session_delegation", + params: { sessionPublicKey: await appKey() }, + }); + const result = ( sent[0] as { - result: { - publicKey: string; - signerDelegation: { delegation: { targets?: string[] } }[]; - }; + result: { signerDelegation: { delegation: { targets?: string[] } }[] }; } ).result; - expect(result.publicKey).toEqual(expect.any(String)); + const targets = result.signerDelegation + .map((signed) => signed.delegation.targets) + .filter((value): value is string[] => value !== undefined); + expect(targets).toEqual([[CANISTER_ID_TEXT]]); + }); + + it("answers a silent request it cannot satisfy without rendering", async () => { + const { authorizationPromptStore } = + await import("$lib/stores/authorization.store"); + authorizationPromptStore.set({ prompt: "none" }); + const { channel, sent } = channelWith(); + const onError = vi.fn(); + + await handleSessionDelegationRequest( + channel, + onError, + )({ + jsonrpc: "2.0", + id: 1, + method: "ii_session_delegation", + params: { sessionPublicKey: await appKey() }, + }); + + expect(setRequestContext).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + expect(sent[0]).toMatchObject({ + error: { code: 3002, data: { reason: "login_required" } }, + }); + authorizationPromptStore.set({}); + }); +}); + +describe("a session the canister no longer holds", () => { + const promptStore = async () => + (await import("$lib/stores/authorization.store")).authorizationPromptStore; + + beforeEach(async () => { + checkSession.mockClear(); + checkSession.mockResolvedValue(true); + await purgeAppSessions(BigInt(10_000)); + await purgeAppSessions(BigInt(10_001)); + (await promptStore()).set({}); + }); + + it("denies a silent request when the canister no longer holds the session", async () => { + checkSession.mockResolvedValueOnce(false); + await storedSession(BigInt(10_000)); + const { channel, sent } = channelWith(); + (await promptStore()).set({ prompt: "none" }); + + await handleSessionDelegationRequest( + channel, + vi.fn(), + )({ + jsonrpc: "2.0", + id: 1, + method: "ii_session_delegation", + params: { sessionPublicKey: await appKey() }, + }); + + expect(sent).toHaveLength(1); + expect(sent[0]).toMatchObject({ + error: { code: INTERACTION_REQUIRED_ERROR_CODE }, + }); + }); + + /// `check_session` is a query, so its reply is uncertified: anyone able to answer it + /// can say no. Deleting on that would let a wrong no destroy a working session's key, + /// which is an attacker-triggerable loss rather than a flake. The record stays, and a + /// later attempt asks again. + it("keeps the record, because a query reply is not proof", async () => { + checkSession.mockResolvedValueOnce(false); + await storedSession(BigInt(10_000)); + const { channel } = channelWith(); + (await promptStore()).set({ prompt: "none" }); + + await handleSessionDelegationRequest( + channel, + vi.fn(), + )({ + jsonrpc: "2.0", + id: 1, + method: "ii_session_delegation", + params: { sessionPublicKey: await appKey() }, + }); + + expect(await appSessionsForOrigin(ORIGIN)).toHaveLength(1); + }); + + /// Two records, one of them ended from another browser. Counting the records rather + /// than the live sessions made that an ambiguity and refused a request there was only + /// one answer to. + it("serves the one live session beside a record that was revoked elsewhere", async () => { + await storedSession(BigInt(10_000)); + await storedSession(BigInt(10_001)); + // `appSessionsForOrigin` lists them in key order, so the first is 10_000's. + checkSession.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + (await promptStore()).set({ prompt: "none" }); + + const { channel, sent } = channelWith(); + await handleSessionDelegationRequest( + channel, + vi.fn(), + )({ + jsonrpc: "2.0", + id: 1, + method: "ii_session_delegation", + params: { sessionPublicKey: await appKey() }, + }); + + expect(sent[0]).toMatchObject({ result: {} }); + }); + + /// The denial is a skip, not a verdict: the very next request finds the record still + /// there and can succeed on it. + it("serves the same record once the canister answers again", async () => { + checkSession.mockResolvedValueOnce(false); + await storedSession(BigInt(10_000)); + (await promptStore()).set({ prompt: "none" }); + const request = { + jsonrpc: "2.0" as const, + id: 1, + method: "ii_session_delegation", + params: { sessionPublicKey: await appKey() }, + }; + + const denied = channelWith(); + await handleSessionDelegationRequest(denied.channel, vi.fn())(request); + + checkSession.mockResolvedValueOnce(true); + const served = channelWith(); + await handleSessionDelegationRequest(served.channel, vi.fn())(request); + + expect(denied.sent[0]).toMatchObject({ + error: { code: INTERACTION_REQUIRED_ERROR_CODE }, + }); + expect(served.sent[0]).toMatchObject({ result: {} }); + }); +}); + +describe("silent requests never paint", () => { + const promptStore = async () => + (await import("$lib/stores/authorization.store")).authorizationPromptStore; + + it("answers rather than surfacing an unverified origin", async () => { + const { validateDerivationOrigin } = + await import("$lib/utils/validateDerivationOrigin"); + vi.mocked(validateDerivationOrigin).mockResolvedValueOnce({ + result: "invalid", + message: "nope", + }); + (await promptStore()).set({ prompt: "none" }); + const { channel, sent } = channelWith(); + const onError = vi.fn(); + + await handleSessionDelegationRequest( + channel, + onError, + )({ + jsonrpc: "2.0", + id: 1, + method: "ii_session_delegation", + params: { sessionPublicKey: await appKey() }, + }); + + expect(onError).not.toHaveBeenCalled(); + expect(sent[0]).toMatchObject({ error: { code: 3002 } }); + (await promptStore()).set({}); + }); + + it("runs a ceremony for prompt=login even when a session is held", async () => { + await storedSession(BigInt(10_000)); + (await promptStore()).set({ prompt: "login" }); + const { channel } = channelWith(); + + const handled = handleSessionDelegationRequest( + channel, + vi.fn(), + )({ + jsonrpc: "2.0", + id: 1, + method: "ii_session_delegation", + params: { sessionPublicKey: await appKey() }, + }); + await Promise.race([ + handled, + new Promise((resolve) => setTimeout(resolve, 50)), + ]); + + expect(setRequestContext).toHaveBeenCalled(); + (await promptStore()).set({}); + }); +}); + +describe("recovering from a revoked session", () => { + it("does not answer from a held record once the ceremony path is taken", async () => { + await storedSession(BigInt(10_000)); + await storedSession(BigInt(10_001)); + const { channel } = channelWith(); + + const handled = handleSessionDelegationRequest( + channel, + vi.fn(), + )({ + jsonrpc: "2.0", + id: 1, + method: "ii_session_delegation", + params: { sessionPublicKey: await appKey() }, + }); + await Promise.race([ + handled, + new Promise((resolve) => setTimeout(resolve, 50)), + ]); + + expect(setRequestContext).toHaveBeenCalled(); + }); +}); + +// Ordered last: each leaves a ceremony pending, which holds the shared authorization queue. +describe("requests that fall through to a ceremony", () => { + it("asks for a ceremony when more than one identity holds a session", async () => { + await storedSession(BigInt(10_000)); + await storedSession(BigInt(10_001)); + const { channel } = channelWith(); + + const handled = handleSessionDelegationRequest( + channel, + vi.fn(), + )({ + jsonrpc: "2.0", + id: 1, + method: "ii_session_delegation", + params: { sessionPublicKey: await appKey() }, + }); + await Promise.race([ + handled, + new Promise((resolve) => setTimeout(resolve, 50)), + ]); + + expect(setRequestContext).toHaveBeenCalledWith(ORIGIN, undefined); + }); + + it("runs the ceremony when silence was not asked for", async () => { + await storedSession(BigInt(10_000)); + const { authorizationPromptStore } = + await import("$lib/stores/authorization.store"); + authorizationPromptStore.set({}); + const { channel, sent } = channelWith(); + + const handled = handleSessionDelegationRequest( + channel, + vi.fn(), + )({ + jsonrpc: "2.0", + id: 1, + method: "ii_session_delegation", + params: { sessionPublicKey: await appKey() }, + }); + await Promise.race([ + handled, + new Promise((resolve) => setTimeout(resolve, 50)), + ]); + + // A held session is not handed back: the ceremony starts and nothing is answered from + // local state, because silence is something an app has to ask for. + expect(setRequestContext).toHaveBeenCalled(); + expect(sent).toEqual([]); + }); +}); + +describe("keeping a session for later", () => { + beforeEach(async () => { + await purgeAppSessions(BigInt(10_000)); + await purgeAppSessions(BigInt(10_001)); + }); + + // Earlier tests in this file start a handler and only race it against a + // timeout, so one can still be waiting on the auth stores when a later + // fixture sets them — and then it prepares a session of its own. The TTL this + // ceremony asks for is what picks its request out of the ones captured. + const ourRequest = ( + prepared: Record[], + validForNs: bigint, + ): Record => { + const found = prepared.find( + (request) => + Array.isArray(request.valid_for) && request.valid_for[0] === validForNs, + ); + expect(found, "this ceremony's prepare_account_session call").toBeDefined(); + return found as Record; + }; + + it("carries the app's idle bound to the canister", async () => { + const { sent, prepared } = await runCeremony(true, { + maxTimeToLive: "3600000000000", + maxTimeToIdle: "600000000000", + }); + + expect(sent).toHaveLength(1); + expect(ourRequest(prepared, BigInt(3_600_000_000_000)).max_idle).toEqual([ + BigInt(600_000_000_000), + ]); + }); + + it("leaves the bound to the canister when the app names none", async () => { + // Absent rather than a number this frontend picked: the default belongs to + // the canister, and sending one here would override it. + const { prepared } = await runCeremony(true, { + maxTimeToLive: "7200000000000", + }); + + expect(ourRequest(prepared, BigInt(7_200_000_000_000)).max_idle).toEqual( + [], + ); + }); + + /// What the ceremony sends and what it keeps, neither of which the tests around this + /// one reach: they locate the request by its TTL and then read only `max_idle`, and + /// they check that a record exists without looking at what is in it. + /// + /// `session_id` earns its own assertion because nothing downstream would catch a wrong + /// one here: `get_account_session` names the session by that id, so a record holding + /// the wrong one is a session that cannot be resumed, and every other test in this + /// file would still pass. + it("asks for this identity at this origin, and keeps the session it is given", async () => { + const { sent, prepared } = await runCeremony(true, { + maxTimeToLive: "5400000000000", + }); + + expect(sent).toHaveLength(1); + expect(ourRequest(prepared, BigInt(5_400_000_000_000))).toMatchObject({ + identity_number: BigInt(10_000), + origin: ORIGIN, + // The default account, which the consent resolved to no account number. + account_number: [], + }); + await expect(appSessionsForOrigin(ORIGIN)).resolves.toMatchObject([ + { identityNumber: BigInt(10_000), record: { sessionId: BigInt(1_000) } }, + ]); + }); + + it("keeps a session the app asked to be resumable", async () => { + const { sent } = await runCeremony(true); + + expect(sent).toHaveLength(1); + await expect(appSessionsForOrigin(ORIGIN)).resolves.toHaveLength(1); + }); + + it("answers without keeping a session the app did not ask to be resumable", async () => { + const { sent } = await runCeremony(); + + // The app is served either way: what `resumable` decides is only whether this + // browser can answer the next request without another ceremony. + expect(sent).toHaveLength(1); + await expect(appSessionsForOrigin(ORIGIN)).resolves.toEqual([]); + }); + + it("remembers the account even when the session is not kept", async () => { + await runCeremony(); - // 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", + await expect(appAccountsForOrigin(ORIGIN)).resolves.toMatchObject([ + { record: { accountPrincipal: "2vxsx-fae" } }, ]); }); }); diff --git a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts index 67e93756c2..6350eb39d6 100644 --- a/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts +++ b/src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts @@ -2,17 +2,20 @@ import type { Channel, JsonRequest } from "$lib/utils/transport/utils"; import { Base64ToBytesCodec, Base64ToPublicKeyCodec, + INTERACTION_REQUIRED_ERROR_CODE, INVALID_PARAMS_ERROR_CODE, OriginSchema, Nat64StringCodec, StringToBigIntCodec, } from "$lib/utils/transport/utils"; import { + authorizationPromptStore, authorizationStore, authorizedStore, } from "$lib/stores/authorization.store"; import { authenticationStore } from "$lib/stores/authentication.store"; import { + appSessionsForOrigin, rememberAppAccount, storeAppSession, type AppSessionRecord, @@ -26,14 +29,23 @@ import { throwCanisterError, waitForStore, } from "$lib/utils/utils"; -import { canisterId } from "$lib/globals"; +import { agentOptions, canisterId } from "$lib/globals"; +import { Actor, HttpAgent } from "@icp-sdk/core/agent"; +import { idlFactory as internet_identity_idl } from "$lib/generated/internet_identity_idl"; +import type { _SERVICE } from "$lib/generated/internet_identity_types"; import { Principal } from "@icp-sdk/core/principal"; import { Delegation, DelegationChain, + DelegationIdentity, ECDSAKeyIdentity, } from "@icp-sdk/core/identity"; import type { PublicKey, Signature } from "@icp-sdk/core/agent"; +import { get } from "svelte/store"; +import { + chooseSilentSession, + type SilentDenial, +} from "$lib/stores/channelHandlers/silentReauth"; import type { AccountSessionError } from "$lib/generated/internet_identity_types"; import { serializeAuthorizationRequest } from "$lib/stores/channelHandlers/serialize"; import { @@ -131,6 +143,36 @@ const extendToApp = async ( }, ); +/** + * Whether the canister still holds the session this record names. + * + * A record can outlive its session: revoking from settings or from another app leaves + * this browser's copy in place. Answering from the record alone would hand the app a + * chain that cannot mint, and the failure would surface later as something the client + * cannot tell apart from a real error. + * + * A hint and not an authority. The reply is a query reply, so it is uncertified and + * anyone able to answer it can say no — which is why a no only skips the silent path. + * Nothing here is deleted on it: a wrong no would otherwise destroy a working session's + * key, turning a flake into something an attacker can trigger. A no that was a lie costs + * one silent attempt, and the next one asks again. + */ +const sessionIsLive = async (record: AppSessionRecord): Promise => { + try { + const identity = DelegationIdentity.fromDelegation( + await ECDSAKeyIdentity.fromKeyPair(record.keyPair), + DelegationChain.fromJSON(JSON.parse(record.chainJson)), + ); + const actor = Actor.createActor<_SERVICE>(internet_identity_idl, { + agent: HttpAgent.createSync({ ...agentOptions, identity }), + canisterId, + }); + return await actor.check_session(); + } catch { + return false; + } +}; + /** * Obtains the session an app re-issues its own delegations from. * @@ -150,6 +192,19 @@ export const handleSessionDelegationRequest = } const requestId = request.id; + const isSilent = get(authorizationPromptStore).prompt === "none"; + const deny = async (reason: SilentDenial) => { + await channel.send({ + jsonrpc: "2.0", + id: requestId, + error: { + code: INTERACTION_REQUIRED_ERROR_CODE, + message: "Interaction required", + data: { reason }, + }, + }); + }; + const parsed = SessionParamsCodec.safeParse(request.params); if (!parsed.success) { await channel.send({ @@ -160,7 +215,12 @@ export const handleSessionDelegationRequest = message: z.prettifyError(parsed.error), }, }); - onError("invalid-request"); + // A malformed request is still a protocol error rather than a denial, so the code + // stays INVALID_PARAMS. What the silent path must not do is render: it was asked to + // answer without showing the user anything, and that holds however it fails. + if (!isSilent) { + onError("invalid-request"); + } return; } @@ -172,6 +232,10 @@ export const handleSessionDelegationRequest = derivationOrigin: params.icrc95DerivationOrigin, }); if (validation.result === "invalid") { + if (isSilent) { + await deny("login_required"); + return; + } onError("unverified-origin"); return; } @@ -180,10 +244,52 @@ export const handleSessionDelegationRequest = params.icrc95DerivationOrigin ?? channel.origin, ); + const { prompt, hint, resumable } = get(authorizationPromptStore); + // Silence is something an app asks for. Anything else, an absent `prompt` included, + // runs the ceremony, so a held session is never handed over without the user + // seeing a screen they did not request. + const stored = + prompt === "none" ? await appSessionsForOrigin(effectiveOrigin) : []; + // Liveness before the choice, not after it: choosing among the stored records + // counts a session the user ended elsewhere, so one live record beside one + // revoked record read as two candidates and were refused as an ambiguity. + // + // The records stay either way. A session that is really gone leaves a record + // that is filtered out on read once it expires, and removing it would need a + // certified answer — an update call, made by the app, not by this. + const alive = await Promise.all( + stored.map((entry) => sessionIsLive(entry.record)), + ); + const held = stored.filter((_, index) => alive[index]); + const chosen = chooseSilentSession({ held, hint }); + + const usable = "session" in chosen ? chosen.session : undefined; + + if (usable) { + const chain = await extendToApp( + usable.record, + params.sessionPublicKey, + ); + await channel.send({ + jsonrpc: "2.0", + id: requestId, + result: SessionResultSchema.encode({ + chain, + }), + }); + return; + } + + if (isSilent) { + await deny("denial" in chosen ? chosen.denial : "login_required"); + return; + } + const created = await createSession( effectiveOrigin, params.maxTimeToLive, params.maxTimeToIdle, + resumable === true, ); const chain = await extendToApp( created.record, @@ -198,6 +304,10 @@ export const handleSessionDelegationRequest = }); } catch (error) { console.error(error); + if (isSilent) { + await deny("login_required"); + return; + } onError("delegation-failed"); } }); @@ -218,6 +328,7 @@ const createSession = async ( effectiveOrigin: string, requestedMaxTimeToLive: bigint | undefined, requestedMaxTimeToIdle: bigint | undefined, + resumable: boolean, ): Promise<{ record: AppSessionRecord }> => { authorizationStore.setRequestContext(effectiveOrigin, requestedMaxTimeToLive); const authorized = await waitForStore(authorizedStore); @@ -333,9 +444,13 @@ const createSession = async ( sessionId: prepared.session_id, accessLevel: authorized.accessLevel, }; + // The mapping is not a credential and is kept either way, so a later hint still names + // an account this browser has seen. The session is what an app has to ask to have kept. await rememberAppAccount(recordKey, { accountPrincipal: prepared.account_principal.toText(), }); - await storeAppSession(recordKey, record); + if (resumable) { + await storeAppSession(recordKey, record); + } return { record }; }; diff --git a/src/frontend/src/lib/stores/channelHandlers/silentReauth.test.ts b/src/frontend/src/lib/stores/channelHandlers/silentReauth.test.ts new file mode 100644 index 0000000000..2937df6c44 --- /dev/null +++ b/src/frontend/src/lib/stores/channelHandlers/silentReauth.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { chooseSilentSession } from "./silentReauth"; + +const PRINCIPAL_A = "2vxsx-fae"; +const PRINCIPAL_B = "aaaaa-aa"; + +const held = (accountPrincipal: string) => ({ accountPrincipal }); + +describe("chooseSilentSession", () => { + it("denies when this browser holds nothing for the origin", () => { + expect(chooseSilentSession({ held: [] })).toEqual({ + denial: "login_required", + }); + }); + + it("uses the only session held when no hint is given", () => { + expect(chooseSilentSession({ held: [held(PRINCIPAL_A)] })).toEqual({ + session: held(PRINCIPAL_A), + }); + }); + + it("asks rather than guessing between personas", () => { + expect( + chooseSilentSession({ + held: [held(PRINCIPAL_A), held(PRINCIPAL_B)], + }), + ).toEqual({ denial: "account_selection_required" }); + }); + + it("selects the hinted session", () => { + expect( + chooseSilentSession({ + held: [held(PRINCIPAL_A), held(PRINCIPAL_B)], + hint: PRINCIPAL_B, + }), + ).toEqual({ session: held(PRINCIPAL_B) }); + }); + + it("denies a hint this browser holds no session for", () => { + expect( + chooseSilentSession({ + held: [held(PRINCIPAL_A)], + hint: PRINCIPAL_B, + }), + ).toEqual({ denial: "login_required" }); + }); + + it("denies rather than guessing when a hint is ambiguous", () => { + expect( + chooseSilentSession({ + held: [held(PRINCIPAL_A), held(PRINCIPAL_A)], + hint: PRINCIPAL_A, + }), + ).toEqual({ denial: "account_selection_required" }); + }); +}); diff --git a/src/frontend/src/lib/stores/channelHandlers/silentReauth.ts b/src/frontend/src/lib/stores/channelHandlers/silentReauth.ts new file mode 100644 index 0000000000..9f04e08e30 --- /dev/null +++ b/src/frontend/src/lib/stores/channelHandlers/silentReauth.ts @@ -0,0 +1,39 @@ +export type SilentDenial = "login_required" | "account_selection_required"; + +export type SilentOutcome = { session: T } | { denial: SilentDenial }; + +/** + * Picks which of the origin's held sessions a silent request re-issues from. + * + * A hint is a preference, not a credential: it can only select from what this browser + * already holds for the origin being authorized, and holding the session is what confers + * anything. Picking the wrong persona for the user is worse than asking, so several + * candidates with nothing to choose between them is a denial rather than a guess. + */ +export const chooseSilentSession = ({ + held, + hint, +}: { + held: T[]; + hint?: string; +}): SilentOutcome => { + if (held.length === 0) { + return { denial: "login_required" }; + } + + if (hint !== undefined) { + const matched = held.filter((entry) => entry.accountPrincipal === hint); + if (matched.length === 1) { + return { session: matched[0] }; + } + return { + denial: + matched.length === 0 ? "login_required" : "account_selection_required", + }; + } + + if (held.length === 1) { + return { session: held[0] }; + } + return { denial: "account_selection_required" }; +}; diff --git a/src/frontend/src/lib/utils/transport/utils.ts b/src/frontend/src/lib/utils/transport/utils.ts index 2fa61c02c5..98466a0bb0 100644 --- a/src/frontend/src/lib/utils/transport/utils.ts +++ b/src/frontend/src/lib/utils/transport/utils.ts @@ -6,6 +6,10 @@ import { Principal } from "@icp-sdk/core/principal"; // See: https://www.jsonrpc.org/specification#error_object export const INVALID_PARAMS_ERROR_CODE = -32602; // See: https://github.com/dfinity/wg-identity-authentication/blob/main/topics/icrc_25_signer_interaction_standard.md#errors-3 +/// Placed in ICRC-25's 3xxx "user action" range, so a client can tell a silent request +/// that needs a ceremony apart from a real failure. +export const INTERACTION_REQUIRED_ERROR_CODE = 3002; + export const GENERIC_ERROR_CODE = 1000; export interface ChannelOptions { diff --git a/src/frontend/src/routes/(new-styling)/authorize/+layout.svelte b/src/frontend/src/routes/(new-styling)/authorize/+layout.svelte index e5a82ab905..cff2d68231 100644 --- a/src/frontend/src/routes/(new-styling)/authorize/+layout.svelte +++ b/src/frontend/src/routes/(new-styling)/authorize/+layout.svelte @@ -10,6 +10,8 @@ authorizationStore, authorizedStore, } from "$lib/stores/authorization.store"; + import { authorizationPromptStore } from "$lib/stores/authorization.store"; + import { resolvePromptParams, stripPromptParams } from "./promptParams"; import { lastUsedIdentitiesStore } from "$lib/stores/last-used-identities.store"; import { forgetIdentity } from "$lib/stores/session-delegation.store"; import { authenticationStore } from "$lib/stores/authentication.store"; @@ -18,7 +20,6 @@ import { handleError } from "$lib/components/utils/error"; import { sessionStore } from "$lib/stores/session.store"; import { t } from "$lib/stores/locale.store"; - import { onMount } from "svelte"; import { analytics } from "$lib/utils/analytics/analytics"; import { throwCanisterError } from "$lib/utils/utils"; import { AuthLastUsedFlow } from "$lib/flows/authLastUsedFlow.svelte"; @@ -62,6 +63,16 @@ return "normal" as const; })(); + // Set before the channel is established, so the delegation handler has the prompt + // context by the time a request arrives. + authorizationPromptStore.set( + resolvePromptParams( + new URL(window.location.href), + flow === "openid-resume", + ), + ); + stripPromptParams(); + // --- Channel establishment --- $effect.pre(() => { if (flow === "error") { @@ -243,16 +254,27 @@ } }; - // Pre-fetch passkey credential ids - $effect(() => + // Both of these belong to a request that puts something on screen, and neither can be + // decided at mount: `prompt: "none"` is known only once the request is parsed. That is + // what `isReady` reports — a silently answered request never sets the authorization + // context, so it never turns true. Gating on it skips the credential pre-fetch's one + // canister query per remembered identity, none of which a silent request uses, and + // stops counting a page view for a page nobody was shown. + // + // The interactive path loses only the gap between mount and request parse, which is + // over well before the user could act on either. + let viewCounted = false; + $effect(() => { + if (!isReady) { + return; + } authLastUsedFlow.init( lastUsedIdentities.map(({ identityNumber }) => identityNumber), - ), - ); - - // Track page view for authorization flow - onMount(() => { - analytics.pageView(); + ); + if (!viewCounted) { + viewCounted = true; + analytics.pageView(); + } }); diff --git a/src/frontend/src/routes/(new-styling)/authorize/promptParams.test.ts b/src/frontend/src/routes/(new-styling)/authorize/promptParams.test.ts new file mode 100644 index 0000000000..1b5331d1fe --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/authorize/promptParams.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + readPromptParams, + resolvePromptParams, + stripPromptParams, +} from "./promptParams"; + +const PRINCIPAL = "2vxsx-fae"; + +describe("authorize prompt params", () => { + beforeEach(() => { + sessionStorage.clear(); + window.history.replaceState(null, "", "http://localhost:3000/authorize"); + }); + + it("reads a silent request", () => { + expect( + readPromptParams( + new URL( + `http://localhost:3000/authorize?prompt=none&hint=${PRINCIPAL}`, + ), + ), + ).toEqual({ prompt: "none", hint: PRINCIPAL, resumable: undefined }); + }); + + it("reads a request to be kept for later", () => { + expect( + readPromptParams( + new URL("http://localhost:3000/authorize?resumable=true"), + ), + ).toEqual({ prompt: undefined, hint: undefined, resumable: true }); + }); + + it("keeps nothing for an app that did not ask in so many words", () => { + for (const value of ["false", "1", "yes", ""]) { + expect( + readPromptParams( + new URL(`http://localhost:3000/authorize?resumable=${value}`), + ).resumable, + ).toBeUndefined(); + } + }); + + it("reads an interactive request", () => { + expect( + readPromptParams(new URL("http://localhost:3000/authorize?prompt=login")), + ).toEqual({ prompt: "login", hint: undefined, resumable: undefined }); + }); + + it("treats an unknown prompt as absent", () => { + expect( + readPromptParams( + new URL("http://localhost:3000/authorize?prompt=consent"), + ), + ).toEqual({ prompt: undefined, hint: undefined, resumable: undefined }); + }); + + it("treats a hint that is not a principal as absent", () => { + expect( + readPromptParams( + new URL("http://localhost:3000/authorize?hint=not-a-principal"), + ), + ).toEqual({ prompt: undefined, hint: undefined, resumable: undefined }); + }); + + it("keeps the params across a resume", () => { + resolvePromptParams( + new URL( + `http://localhost:3000/authorize?prompt=none&hint=${PRINCIPAL}&resumable=true`, + ), + false, + ); + + expect( + resolvePromptParams(new URL("http://localhost:3000/authorize"), true), + ).toEqual({ prompt: "none", hint: PRINCIPAL, resumable: true }); + }); + + it("keeps a lone request to be kept across a resume", () => { + resolvePromptParams( + new URL("http://localhost:3000/authorize?resumable=true"), + false, + ); + + expect( + resolvePromptParams(new URL("http://localhost:3000/authorize"), true) + .resumable, + ).toBe(true); + }); + + it("clears a stored context when a later request carries none", () => { + resolvePromptParams( + new URL("http://localhost:3000/authorize?prompt=none"), + false, + ); + + resolvePromptParams(new URL("http://localhost:3000/authorize"), false); + + expect( + resolvePromptParams(new URL("http://localhost:3000/authorize"), true), + ).toEqual({}); + }); + + it("strips the params it has consumed and leaves the rest", () => { + window.history.replaceState( + null, + "", + `http://localhost:3000/authorize?prompt=none&hint=${PRINCIPAL}&resumable=true&sso=example.com`, + ); + + stripPromptParams(); + + const url = new URL(window.location.href); + expect(url.searchParams.get("prompt")).toBeNull(); + expect(url.searchParams.get("hint")).toBeNull(); + expect(url.searchParams.get("resumable")).toBeNull(); + expect(url.searchParams.get("sso")).toBe("example.com"); + }); +}); diff --git a/src/frontend/src/routes/(new-styling)/authorize/promptParams.ts b/src/frontend/src/routes/(new-styling)/authorize/promptParams.ts new file mode 100644 index 0000000000..bfbc882115 --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/authorize/promptParams.ts @@ -0,0 +1,98 @@ +import { Principal } from "@icp-sdk/core/principal"; +import { z } from "zod"; + +export const PROMPT_PARAM = "prompt"; +export const HINT_PARAM = "hint"; +export const RESUMABLE_PARAM = "resumable"; + +/** Survives the round trip an interactive flow may take through an IdP. */ +const STORAGE_KEY = "ii-authorize-prompt"; + +export type AuthorizationPrompt = "none" | "login"; + +export interface PromptContext { + prompt?: AuthorizationPrompt; + hint?: string; + /** Whether this sign-in may be kept here to be resumed later. An app that does not + * ask is not kept, so a later silent request finds nothing — which is the answer + * for every app that never thought about it. */ + resumable?: boolean; +} + +const isPrincipal = (value: string): boolean => { + try { + Principal.fromText(value); + return true; + } catch { + return false; + } +}; + +// `prompt` and `hint` are preferences, never credentials, so an unreadable value +// degrades to an interactive sign-in rather than failing the request. +const PromptContextSchema = z.object({ + prompt: z.enum(["none", "login"]).optional().catch(undefined), + hint: z + .string() + .refine(isPrincipal) + .transform((value) => Principal.fromText(value).toText()) + .optional() + .catch(undefined), + // Arrives from the URL as the string it was written as, and from session storage as + // the boolean it was stored as, so the schema has to read both. + resumable: z + .union([z.literal("true"), z.literal(true)]) + .transform(() => true) + .optional() + .catch(undefined), +}); + +export const readPromptParams = (url: URL): PromptContext => { + const parsed = PromptContextSchema.safeParse({ + prompt: url.searchParams.get(PROMPT_PARAM) ?? undefined, + hint: url.searchParams.get(HINT_PARAM) ?? undefined, + resumable: url.searchParams.get(RESUMABLE_PARAM) ?? undefined, + }); + return parsed.success ? parsed.data : {}; +}; + +export const resolvePromptParams = ( + url: URL, + isResuming: boolean, +): PromptContext => { + if (isResuming) { + const stored = sessionStorage.getItem(STORAGE_KEY); + if (stored === null) { + return {}; + } + try { + const parsed = PromptContextSchema.safeParse(JSON.parse(stored)); + return parsed.success ? parsed.data : {}; + } catch { + return {}; + } + } + + const context = readPromptParams(url); + if ( + context.prompt === undefined && + context.hint === undefined && + context.resumable === undefined + ) { + sessionStorage.removeItem(STORAGE_KEY); + } else { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(context)); + } + return context; +}; + +/** Keeps the address bar free of values the flow has already consumed. */ +export const stripPromptParams = (): void => { + const url = new URL(window.location.href); + const params = [PROMPT_PARAM, HINT_PARAM, RESUMABLE_PARAM]; + if (!params.some((param) => url.searchParams.has(param))) { + return; + } + params.forEach((param) => url.searchParams.delete(param)); + window.history.replaceState(null, "", url.toString()); +}; diff --git a/src/internet_identity/internet_identity.did b/src/internet_identity/internet_identity.did index 92e1c6c74d..d96d3c0a6e 100644 --- a/src/internet_identity/internet_identity.did +++ b/src/internet_identity/internet_identity.did @@ -2040,6 +2040,11 @@ service : (opt InternetIdentityInit) -> { // client that retries, or that signs out twice, does not have to reason about whether // its session was still there. An app can revoke only its own session. app_revoke_session : () -> (variant { Ok; Err : AppSessionError }); + // Whether the calling session is still usable. For the II frontend's silent + // re-auth path, which must decide whether it can answer without rendering + // anything. Advisory: a query reply is not certified, and every mint enforces + // the same conditions regardless of the answer here. + check_session : () -> (bool) query; revoke_browser_sessions : (RevokeBrowserSessionsRequest) -> (variant { Ok; Err : SessionRevokeError }); diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index 914973f825..8503f03397 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -531,6 +531,11 @@ fn app_revoke_session() -> Result<(), AppSessionError> { sessions::app_revoke_session(ic_cdk::api::time()) } +#[query] +fn check_session() -> bool { + sessions::check_session() +} + #[query] fn app_get_delegation( request: AppGetDelegationRequest, diff --git a/src/internet_identity/src/sessions.rs b/src/internet_identity/src/sessions.rs index 2d831c83e9..061d9f7f91 100644 --- a/src/internet_identity/src/sessions.rs +++ b/src/internet_identity/src/sessions.rs @@ -482,6 +482,13 @@ fn authorize_session(now: Timestamp) -> Result bool { + authorize_session(time()).is_ok() +} + /// Signs the caller's own session out. A caller cannot produce another session's /// principal, so the seed match is the whole authorization. /// diff --git a/src/internet_identity/tests/integration/sessions.rs b/src/internet_identity/tests/integration/sessions.rs index 651e71ae2f..3ebfba14af 100644 --- a/src/internet_identity/tests/integration/sessions.rs +++ b/src/internet_identity/tests/integration/sessions.rs @@ -904,6 +904,69 @@ fn should_sign_a_whole_browser_out() -> Result<(), RejectResponse> { Ok(()) } +/// The silent re-auth path answers from a locally held record, so it needs a way to ask +/// whether that record still stands for a session the canister has since lost. +#[test] +fn should_report_a_live_session_as_live() -> Result<(), RejectResponse> { + use canister_tests::api::internet_identity::api_v2::check_session; + + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + let (_, session_principal) = create_session(&env, canister_id, identity_number); + + assert!(check_session(&env, canister_id, session_principal,)?); + + Ok(()) +} + +#[test] +fn should_report_a_revoked_session_as_gone() -> Result<(), RejectResponse> { + use canister_tests::api::internet_identity::api_v2::check_session; + + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + let (prepared, session_principal) = create_session(&env, canister_id, identity_number); + + revoke_browser_sessions( + &env, + canister_id, + principal_1(), + RevokeBrowserSessionsRequest { + identity_number, + browser_id: prepared.browser_id, + }, + )? + .unwrap(); + + assert!(!check_session(&env, canister_id, session_principal,)?); + + Ok(()) +} + +#[test] +fn should_report_an_expired_session_as_gone() -> Result<(), RejectResponse> { + use canister_tests::api::internet_identity::api_v2::check_session; + + let env = env(); + let canister_id = install_ii_with_archive(&env, None, None); + let identity_number = flows::register_anchor(&env, canister_id); + let mut request = session_request(identity_number); + request.valid_for = Some(10 * 60 * 1_000_000_000); + let prepared = prepare_account_session(&env, canister_id, principal_1(), request)?.unwrap(); + let session_principal = Principal::self_authenticating(&prepared.user_key); + + env.advance_time(Duration::from_secs(11 * 60)); + // `check_session` is a query, so it sees the latest certified state: without a round the + // canister's clock has not moved and the session is not yet expired from its view. + env.tick(); + + assert!(!check_session(&env, canister_id, session_principal)?); + + Ok(()) +} + /// Naming a default account keeps its principal, so it must keep its sessions. Before the /// session seed was built on the account seed, naming it signed the user out of every app /// using that account.