Skip to content

feat(be,fe): session delegations for ceremony-free account reads#4014

Open
MRmarioruci wants to merge 24 commits into
mainfrom
feat/session-delegations-poc
Open

feat(be,fe): session delegations for ceremony-free account reads#4014
MRmarioruci wants to merge 24 commits into
mainfrom
feat/session-delegations-poc

Conversation

@MRmarioruci

@MRmarioruci MRmarioruci commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

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

  • Backend: new prepare_session_delegation / get_session_delegation endpoints. The delegated principal is derived from H(salt, "session-delegation", anchor) and recognized by check_session_authorization, which sits alongside check_authorization in authz_utils.rs and returns a sealed CallerCapability so it's the only entry point for scoped authz. check_authorization itself is unchanged — every other endpoint stays full-auth by construction. Scope is limited to get_accounts and get_default_account (reads only). Minting requires full auth; email-recovery callers are rejected.
  • Frontend: after every full sign-in, mint a session delegation for a non-extractable browser keypair stored in IndexedDB. On return visits the account list reads on the authorize screen route through that delegation. Sign-in (prepare_account_delegation), account create/update/rename, and set_default_account stay full-auth — those handlers force authLastUsedFlow.authenticate() before any out-of-scope call. Mint is fire-and-forget and degrades to the status quo on failure.

Tests

  • Rust integration suite at `src/internet_identity/tests/integration/session_delegation.rs` covering the happy path through `get_accounts`/`get_default_account` via a session principal, default-deny for out-of-scope endpoints (`create_account`, `update_account`, `identity_info`, `set_default_account`), TTL clamp and default, upgrade survival of the derived principal, and isolation across anchors.
  • Vitest coverage for the session-delegation store (IndexedDB persistence, expiry margin, mint/purge round-trip) and the keypair/signing util.
  • Playwright spec at `src/frontend/tests/e2e-playwright/routes/authorize/session-delegation.spec.ts` exercising the ceremony-free return-visit path.

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.
Comment thread src/internet_identity/src/session_delegation.rs Outdated
Comment thread src/internet_identity/src/main.rs Outdated
SessionScoped,
}

pub fn check_authorization_with_scope(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we compile-time enforce that this function is the only way to check scoped authorizations?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/internet_identity/src/session_delegation.rs Outdated
Comment thread src/internet_identity/src/session_delegation.rs Outdated
Comment thread src/internet_identity/src/storage/storable/anchor.rs Outdated
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).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_delegation APIs and a session-scoped authorization gate used by get_accounts, get_default_account, and set_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.

Comment thread src/internet_identity/src/main.rs Outdated
Comment thread src/internet_identity/src/session_delegation.rs
Comment thread src/internet_identity/src/session_delegation.rs
Comment thread src/internet_identity/tests/integration/session_delegation.rs
@MRmarioruci MRmarioruci changed the title feat(be,fe): session delegations POC for ceremony-free account reads feat(be,fe): session delegations for ceremony-free account reads Jun 16, 2026
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.
@MRmarioruci MRmarioruci marked this pull request as ready for review June 16, 2026 12:08
@MRmarioruci MRmarioruci requested a review from a team as a code owner June 16, 2026 12:08
@MRmarioruci MRmarioruci requested a review from aterga June 16, 2026 12:08
@MRmarioruci MRmarioruci requested a review from sea-snake June 16, 2026 12:08
@zeropath-ai

zeropath-ai Bot commented Jun 16, 2026

Copy link
Copy Markdown

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.)

Comment thread src/internet_identity/src/session_delegation.rs
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.
@MRmarioruci MRmarioruci force-pushed the feat/session-delegations-poc branch from 15a9902 to 2c6ea10 Compare June 17, 2026 09:46
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants