feat(be,fe): session delegations for ceremony-free account reads#4014
feat(be,fe): session delegations for ceremony-free account reads#4014MRmarioruci wants to merge 24 commits into
Conversation
Introduce backend-resident, anchor-scoped session delegations so the frontend can authenticate low-stakes calls (get_accounts, get_default_account, set_default_account) without a fresh WebAuthn ceremony after the device-rooted delegation expires. The delegated principal is derived from H(salt, anchor, scope, epoch), recognized in a new check_authorization_with_scope branch that mirrors the email-recovery re-derivation path; check_authorization itself is unchanged, so every other endpoint stays full-auth by construction. Revocation is a session_delegation_epoch field on the anchor, bumped by invalidate_session_delegations and automatically on auth-method removal. prepare_session_delegation requires full auth (email-recovery callers rejected); get_session_delegation is a query; both reuse the existing signature-map machinery. POC: no archive op on invalidate, targets:None.
After every full sign-in, mint a backend session delegation for a non-extractable browser keypair and persist it in IndexedDB. On return visits, route get_accounts / get_default_account / set_default_account through that delegation so the authorize screen's account list and the multiple-accounts toggle render with no WebAuthn ceremony. Signing in (prepare_account_delegation) and account create/update/rename stay full-auth: those handlers force authLastUsedFlow.authenticate() before any out-of-scope call, since the session delegation is read-only plus set_default_account. Mint is fire-and-forget and degrades to the status quo on failure.
| SessionScoped, | ||
| } | ||
|
|
||
| pub fn check_authorization_with_scope( |
There was a problem hiding this comment.
Can we compile-time enforce that this function is the only way to check scoped authorizations?
There was a problem hiding this comment.
Done by sealing CallerCapability, both variants wrap a Sealed token whose field is module-private, so no code outside session_delegation.rs can construct one. main.rs can still match on the variants but can't forge one, which makes check_session_authorization the only mint site.
That covers the "forged capability" angle at compile time. The other angle, forgetting the gate entirely on a new scoped endpoint, would want something like #[scoped_query]. II has no proc macros today, and to stay consistent the macro would also have to absorb the existing match check_authorization(...) boilerplate on the full-auth endpoints. Should I explore that path or no? Also I exlored the inspect_message macro but that won't work on query endpoints.
Pare the POC down to what we actually need today: a single-purpose session principal derived from `H(salt, "session-delegation", anchor)`. - Drop `session_delegation_epoch` from the anchor and remove the four bump hooks plus the `invalidate_session_delegations` endpoint. Explicit revocation is deferred; sessions age out at their (<=7d) TTL. Re-adding the field at `#[n(6)]` later decodes absent->None on old anchors, so this stays migration-free. - Drop the `SessionScope` variant from the Candid API and the seed. Future scope-down lands as an additive optional field on the `prepare_session_delegation` record. - Seal `CallerCapability` with a module-private marker so `check_session_authorization` is the only way to mint one, and factor the principal derivation into `expected_session_principal`.
`check_session_authorization`, `CallerCapability` and the `Sealed` seal token belong with the other caller-against-anchor authorization primitives in `authz_utils.rs`, alongside `check_authorization`. This mirrors the existing pattern: email-recovery's principal derivation also lives in `authz_utils.rs::check_authorization`, pulling the seed function from `email_recovery::smtp`. `session_delegation.rs` keeps the session-specific primitives (`session_delegation_seed`, `expected_session_principal`) as `pub(crate)` so the authz module can call into them, plus the two endpoint helpers (`prepare_session_delegation`, `get_session_delegation`). No behavior change. The seal property is preserved -- `Sealed`'s private field still makes `check_session_authorization` the only mint site for a `CallerCapability`.
…t_account The two `Ok` arms duplicated the call to `set_default_account_for_origin` + `post_operation_bookkeeping`. Pull the gate up via `?` and use `if let` for the FullAuth-only activity bookkeeping; the shared work then runs once below. No behavior change.
…ons-poc # Conflicts: # src/internet_identity/src/main.rs
Integration tests gained `fresh_anchor` and `fresh_session` helpers so each test opens with one descriptive line instead of three lines of `env() + install_ii_canister() + register_anchor()` plumbing. Unit tests gained `setup_with_salt` to absorb the repeated `storage_replace(new_storage()) + update_salt(...)` setup. Drive-by: drop a stray double blank line in authz_utils.rs flagged by cargo fmt. No behavior change; 7 integration tests + 4 unit tests pass as before.
The descope made `actorForAccountManagement` misleading -- the actor is no longer scoped to account management. Renamed to neutral, descriptive names that stand on their own at call sites: - `mintAndStore` → `mintSession` - `purge` → `purgeSession` - `actorForAccountManagement` → `actorForIdentity` Also corrected the `actorForIdentity` docstring -- the function returns either the live authenticated actor (when currently authenticated as the identity) OR a stored session delegation, in that order. The live-actor branch is load-bearing for the immediate-post-ceremony race where `mintSession` is fire-and-forget and the IDB write may not have landed yet.
Per team discussion, the first iteration of session delegations should cover read-only endpoints. Any mutation via session would require a revocation story (per-anchor invalidation, session-management UX) that is intentionally out of scope here. - set_default_account moves back to full-auth via check_authz_and_record_activity. The previous session-eligible path with the FullAuth-vs-SessionScoped match is replaced by a single full-auth gate, which also gives us activity bookkeeping for free. - CallerCapability::FullAuth drops the now-unused Box<Anchor> and AuthorizationKey fields. The variant remains as a marker for future endpoints that may need to distinguish full-auth from session callers; the sealed token continues to prove the value came from check_session_authorization. - Integration test session_delegation_default_deny now also asserts set_default_account is rejected for session callers. The happy_path test no longer exercises set_default_account as a session caller. 573 unit tests pass (including check_candid_interface_compatibility, since the public Candid surface is unchanged).
There was a problem hiding this comment.
Pull request overview
This PR introduces a proof-of-concept “session delegation” path to let the authorize screen re-load anchor-scoped account reads (and default-account selection) without requiring a fresh WebAuthn ceremony on return visits, while keeping other flows on full authentication by default.
Changes:
- Backend adds
prepare_session_delegation/get_session_delegationAPIs and a session-scoped authorization gate used byget_accounts,get_default_account, andset_default_account. - Frontend mints and persists a non-extractable session keypair + delegation chain in IndexedDB and uses it to load account data ceremony-free when possible.
- Adds Rust integration tests plus Vitest and Playwright coverage for the new session delegation flow.
Reviewed changes
Copilot reviewed 15 out of 17 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| src/internet_identity/tests/integration/session_delegation.rs | New integration test suite for session delegation behavior and TTL clamping. |
| src/internet_identity/tests/integration/main.rs | Registers the new integration test module. |
| src/internet_identity/src/session_delegation.rs | Implements session delegation seed derivation, minting, and signed-delegation retrieval. |
| src/internet_identity/src/main.rs | Wires new canister endpoints and opts selected account APIs into session authorization. |
| src/internet_identity/src/authz_utils.rs | Introduces CallerCapability and check_session_authorization to permit session-scoped callers. |
| src/internet_identity/internet_identity.did | Extends the public Candid interface with session delegation types and endpoints. |
| src/internet_identity_interface/src/internet_identity/types.rs | Adds Rust interface types for session delegation API responses/errors. |
| src/frontend/tests/e2e-playwright/routes/authorize/session-delegation.spec.ts | New Playwright spec asserting ceremony-free return-visit account reads. |
| src/frontend/src/routes/(new-styling)/authorize/views/ContinueView.svelte | Routes account reads/default selection through session delegation when available; keeps out-of-scope ops full-auth. |
| src/frontend/src/lib/utils/authentication/sessionDelegation.ts | Mints delegations and rebuilds DelegationIdentity from persisted data. |
| src/frontend/src/lib/utils/authentication/sessionDelegation.test.ts | Unit tests for delegation minting and identity reconstruction. |
| src/frontend/src/lib/stores/session-delegation.store.ts | IndexedDB-backed store for session delegations + helper to build an authorized actor. |
| src/frontend/src/lib/stores/session-delegation.store.test.ts | Store tests (IDB persistence, expiry margin purging, authenticated-actor precedence). |
| src/frontend/src/lib/generated/internet_identity_types.d.ts | Generated TS type updates for new session delegation APIs. |
| src/frontend/src/lib/generated/internet_identity_idl.js | Generated IDL updates for new session delegation APIs. |
| src/frontend/src/lib/flows/authFlow.svelte.ts | Fire-and-forget minting of a session delegation after successful sign-in. |
| src/canister_tests/src/api/internet_identity/api_v2.rs | Test harness API additions for session delegation and default-account endpoints. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
state::salt() traps if the canister salt hasn't been initialised yet. On a fresh canister where no prior endpoint has called ensure_salt_set, a session caller hitting get_session_delegation would trap instead of getting a clean NoSuchDelegation response. Mirror the salt-existence check already used in check_session_authorization to short-circuit with NoSuchDelegation when no session could have been minted yet.
If the account list changes while the edit dialog is open (e.g., a concurrent refresh removes the account being edited), findIndex returns -1 and the subsequent accounts[index] access would throw a TypeError. Add an early return when the index is -1.
eslint enforces no-unused-vars with allow-pattern /^_/u; the test takes the identities fixture only to trigger its registration setup, so rename to _identities to keep the side effect while quieting eslint.
The default matches the hard cap now -- callers that pass None for max_ttl get the longest delegation the canister will issue. The 7-day default was conservative; given the IC's 30-day delegation ceiling and the descope of revocation, there's no reason to expire sessions sooner than the user's other delegations. The two constants stay separate so the default can be tightened later without touching the max.
Capture the non-obvious why: the margin prevents serving a delegation that's valid at the FE check but expires between dispatch and IC validation (network latency, ingress queue, browser clock skew). Cleaner to fast-fail to a fresh ceremony than to surface an InvalidDelegation error mid-call.
Two layers of coverage for what happens after a session TTL passes. Backend (PocketIC): - get_session_delegation_returns_no_such_delegation_after_expiry: mint a session, advance the canister's clock past the 30-day TTL, and assert that the next get_session_delegation lookup returns NoSuchDelegation (the signature map prunes expired entries). Frontend (Playwright): - "expired session triggers WebAuthn ceremony when toggling multiple accounts": sign in to populate a session record, tamper with IndexedDB on II's origin to set expiresAtMillis in the past (simulating clock-moved-forward at the FE layer), then trigger another authorize and assert a WebAuthn dialog fires. Exercises the EXPIRY_MARGIN_MS short-circuit + fall-through to authLastUsedFlow.authenticate in actorForIdentity. Not covered: real IC-layer delegation rejection after expiry. The icp dev replica doesn't support time advance, so verifying that an expired delegation chain is rejected by ingress validation would require either short-TTL minting + real-time sleep or a different test harness. Filed as a follow-up if needed.
Both new tests were based on wrong assumptions. Canister-side: the canister-sigs signature map does NOT auto-prune by time -- it evicts by capacity. So after env.advance_time(31d), get_session_delegation still returns the signed delegation; there is no canister-side post-expiry behavior to assert on. Expiration is purely the IC's job at ingress validation, which PocketIC's call_candid_as bypasses. Playwright-side: the WebAuthn virtual authenticator suppresses native dialogs, so waitForEvent(\"dialog\") doesn't actually distinguish \"session worked silently\" from \"ceremony succeeded silently\". Detection mechanism is unreliable. The FE-side expiry-margin behavior is still covered by the existing session-delegation.store unit tests (returns undefined + purges when the record is within the expiry margin). This reverts commit 2f418a4.
|
✅ No security or compliance issues detected. Reviewed everything up to d7ba077. Security Overview
Detected Code Changes| Change Type | Relevant files ... (code changes summary truncated to fit VCS comment limits.) |
The original reasoning was that a brief email compromise should not extend into a 30-day session. But an email-recovery caller already passes check_authorization, which means they can add new auth methods (passkey, OIDC) to the anchor anyway -- giving themselves permanent access independent of any session. Blocking session-mint specifically for email-recovery callers doesn't actually contain the damage, it just introduces an asymmetry between auth methods. All methods that pass check_authorization (passkey, OIDC, recovery phrase) can now mint sessions uniformly.
15a9902 to
2c6ea10
Compare
The toggle was pure in-memory Svelte $state, resetting to false on every mount and identity switch. With session delegations in place, users can load the multi-accounts UI ceremony-free on return visits -- but they still had to flip the toggle ON each time. Persist per-anchor in localStorage (key `ii:multi-accounts:<anchor>`): - Hydrate at mount + on identity change. - Auto-load accounts when hydrated to ON, so the UI matches the user's persisted preference without a manual re-toggle. Uses the same session-first / ceremony-fallback path as the manual click handler. - Persist on every change via a $effect; remove the key when toggled off. Per-anchor (not per-dapp) because the toggle is a mental-mode switch: a user who self-identifies as a multi-accounts user wants the affordance everywhere, not separately per dapp. Matches Thomas's "keep it browser-local for now" stance from the Slack discussion; backend toggle persistence remains out of scope. No backend change.
…user clicks The identity-change effect read `isMultipleAccountsEnabled` inside an `if` (to decide whether to auto-load accounts), which made it a reactive dependency. When the user clicked the toggle, `bind:checked` updated the state, the effect re-fired, re-read localStorage (which the persist effect hadn't written to yet), and overwrote the new value back to false. Toggle visually flipped on then immediately off; accounts never loaded. Read the persisted value into a local instead so the effect's only reactive dependency is `selectedIdentityNumber` -- user clicks no longer re-trigger hydration.
…s toggle The test was clicking plain "Continue" on the third authorize, which worked when the toggle reset to off on every visit. Now that the toggle persists per-anchor in localStorage, the third visit shows the multi-accounts list (no plain "Continue" button) and the click timed out at 60s. Click "Continue with Test account" explicitly instead -- same outcome as the default-trust path under toggle-off, since "Test account" is the default we just set.
Previously mintSession was wired into the 4 authenticationStore.set call sites in authFlow.svelte.ts. authLastUsedFlow.svelte.ts (the common return-visit re-auth path) has its own set sites and doesn't import mintSession -- so a user whose 30-day session expired would ceremony via authLastUsedFlow forever without ever refreshing the session. Same gap in migrationFlow and registerAccessMethodFlow. Move the mint to a subscriber on authenticationStore in session-delegation.store.ts. Now any code path that completes a ceremony and lands in `authenticationStore.set(...)` refreshes the session automatically -- no flow can forget. Removes the 4 inline mintSession calls from authFlow as redundant. Caught by Thomas Gladdines in code review: > Or as a side effect of the authentication store > Basically any place, that guarantees we're minting sessions on auth > and re-auth (specifically authorize screens, but if other screens are > included that's a non issue)
The previous commit added a module-level \`authenticationStore.subscribe(...)\` so any successful auth refreshes the session. But \`authenticationStore\`'s derived throws "Not initialized" when subscribed pre-\`init()\`, and \`init()\` is wired only in \`hooks.client.ts\` -- which never runs during SSR/build/static analyse. CI's frontend build failed in the SvelteKit prerender analyse phase. Wrap the subscribe in \`if (browser)\` so SSR/build skips it and the subscriber only attaches in the browser, after hooks.client.ts has called init().
…thLastUsedFlow The previous subscriber-based approach broke the chrome-extension Playwright test -- the popup timed out waiting for the "close" event after sign-up. The fire-and-forget mintSession from the authenticationStore subscriber appears to keep an in-flight canister request alive that prevents the chrome-extension popup from closing cleanly within the 15s test budget. Revert to explicit mintSession calls at each authenticationStore.set site, but also add them to authLastUsedFlow.svelte.ts (the gap Thomas caught in code review) so re-auths via the "last used identity" path properly refresh the session. Less DRY than the subscriber, but matches the proven pattern that was passing chrome-extension before.
After signing in to id.ai, the authorize screen's account list still re-prompts for a WebAuthn ceremony every time the device-rooted delegation expires (~30 min). This POC introduces anchor-scoped backend session delegations so the account list and default-account reads on return visits render without prompting the user to re-tap a passkey.
Changes
prepare_session_delegation/get_session_delegationendpoints. The delegated principal is derived fromH(salt, "session-delegation", anchor)and recognized bycheck_session_authorization, which sits alongsidecheck_authorizationinauthz_utils.rsand returns a sealedCallerCapabilityso it's the only entry point for scoped authz.check_authorizationitself is unchanged — every other endpoint stays full-auth by construction. Scope is limited toget_accountsandget_default_account(reads only). Minting requires full auth; email-recovery callers are rejected.prepare_account_delegation), account create/update/rename, andset_default_accountstay full-auth — those handlers forceauthLastUsedFlow.authenticate()before any out-of-scope call. Mint is fire-and-forget and degrades to the status quo on failure.Tests