feat(fe): hand apps a session to re-issue their own delegations from - #4247
Closed
sea-snake wants to merge 6 commits into
Closed
feat(fe): hand apps a session to re-issue their own delegations from#4247sea-snake wants to merge 6 commits into
sea-snake wants to merge 6 commits into
Conversation
|
✅ No security or compliance issues detected. Reviewed everything up to 12b3718. Security Overview
Detected Code Changes
|
sea-snake
force-pushed
the
feat/revoke-sessions-from-settings
branch
from
August 19, 2026 02:57
34717f6 to
b8a64f9
Compare
sea-snake
force-pushed
the
feat/ii-session-delegation-rpc
branch
from
August 19, 2026 02:57
c9292ba to
33efcb4
Compare
sea-snake
force-pushed
the
feat/revoke-sessions-from-settings
branch
from
August 19, 2026 03:47
b8a64f9 to
90f092f
Compare
sea-snake
force-pushed
the
feat/ii-session-delegation-rpc
branch
from
August 19, 2026 03:47
33efcb4 to
1ab90d6
Compare
sea-snake
force-pushed
the
feat/revoke-sessions-from-settings
branch
from
August 19, 2026 06:28
90f092f to
2fba0ef
Compare
sea-snake
force-pushed
the
feat/ii-session-delegation-rpc
branch
from
August 19, 2026 06:28
1ab90d6 to
cf69804
Compare
sea-snake
force-pushed
the
feat/revoke-sessions-from-settings
branch
from
August 19, 2026 07:07
2fba0ef to
dcb3703
Compare
sea-snake
force-pushed
the
feat/ii-session-delegation-rpc
branch
from
August 19, 2026 07:07
cf69804 to
4b28f35
Compare
sea-snake
force-pushed
the
feat/revoke-sessions-from-settings
branch
from
August 19, 2026 08:17
dcb3703 to
d6d8557
Compare
sea-snake
force-pushed
the
feat/ii-session-delegation-rpc
branch
from
August 19, 2026 08:17
4b28f35 to
c6019d7
Compare
sea-snake
force-pushed
the
feat/revoke-sessions-from-settings
branch
from
August 19, 2026 08:37
d6d8557 to
c78ef1a
Compare
sea-snake
force-pushed
the
feat/ii-session-delegation-rpc
branch
from
August 19, 2026 08:37
c6019d7 to
cb54c0b
Compare
sea-snake
force-pushed
the
feat/revoke-sessions-from-settings
branch
from
August 19, 2026 10:21
c78ef1a to
616f549
Compare
sea-snake
force-pushed
the
feat/ii-session-delegation-rpc
branch
from
August 19, 2026 10:21
cb54c0b to
12bbcbd
Compare
sea-snake
force-pushed
the
feat/revoke-sessions-from-settings
branch
from
August 20, 2026 10:57
616f549 to
1e76b12
Compare
sea-snake
force-pushed
the
feat/ii-session-delegation-rpc
branch
from
August 20, 2026 10:57
12bbcbd to
7c25b2d
Compare
sea-snake
force-pushed
the
feat/revoke-sessions-from-settings
branch
from
August 20, 2026 13:08
1e76b12 to
27cf6f4
Compare
sea-snake
force-pushed
the
feat/ii-session-delegation-rpc
branch
from
August 20, 2026 13:08
7c25b2d to
e24e8c7
Compare
sea-snake
force-pushed
the
feat/revoke-sessions-from-settings
branch
from
August 20, 2026 13:52
27cf6f4 to
1205a12
Compare
sea-snake
force-pushed
the
feat/ii-session-delegation-rpc
branch
from
August 20, 2026 13:52
e24e8c7 to
0deb842
Compare
sea-snake
force-pushed
the
feat/revoke-sessions-from-settings
branch
from
August 20, 2026 15:24
1205a12 to
1389f4b
Compare
sea-snake
force-pushed
the
feat/ii-session-delegation-rpc
branch
from
August 20, 2026 15:24
0deb842 to
dac7405
Compare
sea-snake
force-pushed
the
feat/revoke-sessions-from-settings
branch
from
August 20, 2026 16:10
1389f4b to
a55a18f
Compare
sea-snake
force-pushed
the
feat/ii-session-delegation-rpc
branch
from
August 20, 2026 16:10
dac7405 to
d8a2574
Compare
sea-snake
force-pushed
the
feat/revoke-sessions-from-settings
branch
from
August 20, 2026 17:24
a55a18f to
451b36d
Compare
sea-snake
force-pushed
the
feat/ii-session-delegation-rpc
branch
from
August 20, 2026 17:24
d8a2574 to
7a11ac2
Compare
sea-snake
force-pushed
the
feat/ii-session-delegation-rpc
branch
2 times, most recently
from
August 21, 2026 10:38
cff9676 to
e98525c
Compare
sea-snake
force-pushed
the
feat/revoke-sessions-from-settings
branch
from
August 21, 2026 10:38
e7f2393 to
0cce254
Compare
sea-snake
force-pushed
the
feat/ii-session-delegation-rpc
branch
2 times, most recently
from
August 21, 2026 17:06
8b93de9 to
ed92a4a
Compare
sea-snake
force-pushed
the
feat/revoke-sessions-from-settings
branch
2 times, most recently
from
August 22, 2026 10:49
154bb92 to
613299e
Compare
sea-snake
force-pushed
the
feat/ii-session-delegation-rpc
branch
2 times, most recently
from
August 22, 2026 11:57
2609130 to
1350827
Compare
sea-snake
force-pushed
the
feat/revoke-sessions-from-settings
branch
2 times, most recently
from
August 22, 2026 12:52
5516927 to
d3100d3
Compare
sea-snake
force-pushed
the
feat/ii-session-delegation-rpc
branch
from
August 22, 2026 12:52
1350827 to
c8c11a3
Compare
sea-snake
force-pushed
the
feat/revoke-sessions-from-settings
branch
from
August 22, 2026 17:24
d3100d3 to
c2bf0ea
Compare
sea-snake
force-pushed
the
feat/ii-session-delegation-rpc
branch
from
August 22, 2026 17:24
c8c11a3 to
eadbdc2
Compare
A session is a record on the account reference plus a canister-signed identity derived from it. The II frontend gets one from `prepare_account_session` and witnesses its delegation with `get_account_session`. The identity is `H(salt, "session", account_seed, created_at, device_id)`, every field length-prefixed. Building on the account's seed rather than on the numbers behind it is what makes a session survive anything that leaves the account's principal unchanged — naming a default account is exactly that. A ceremony from a browser that already holds a session at this account replaces it. A copy of the old session's chain therefore stops working at the user's next sign-in rather than at its expiry. The cap is 500 stored records per identity, expired ones included, because nothing observes a session dying. Reaching it reclaims to a watermark of 450 rather than refusing a sign-in: dead sessions go first, then the live ones whose use earned them the least standing. Reclaiming runs before the new record is admitted, and admission is granted against what that pass counted rather than against the anchor's counter, so the stored set never sits above the cap. Read hardest: the seed derivation, and the reclaim ordering. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An app holds a session chain and asks for a delegation with it. What it gets lasts five minutes and is not requestable any longer, so revoking the session ends access within one delegation lifetime. The call names nothing and attaches nothing. A new index maps the principal a session's chain is rooted at to the account and browser behind it, so `caller()` alone identifies the session: a hit is itself the proof, since only the holder of that session's key can arrive as that principal. The account is named by principal rather than by locator because materialising a default account changes the locator and leaves the principal alone. The index is matched on browser *and* creation time. A browser keeps its id across sign-ins, so on the browser alone an entry that outlived its session would authenticate its holder as whatever that browser created next. Every path that destroys a session drops its entry in the same write. The `get` re-derives the five-minute ceiling rather than trusting the expiration it is handed, because longer-lived delegations exist over the same account seed. An account's own principal is absent from the index, so an app delegation cannot mint its own replacement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"This browser used this app 3 minutes ago" against "5 weeks ago" is what makes a session list worth reading, and it is what lets someone spot a session they do not recognise still being used rather than merely still existing. It is also what the session cap evicts on, since ordering by creation would drop a months-old session in daily use in favour of one created an hour ago and never touched. Every refresh stamps it. An earlier revision of this design coalesced the write to one an hour on the grounds that finer resolution had no reader; it now has three. Two cap evictions order on these stamps, and an hour of slack there is enough to drop the wrong session or the wrong browser. The third is the reading above: a list that can be an hour stale does not answer the question it exists to answer. The write is small next to what the call already does, which inserts a canister signature and rehashes the certified tree. The same write carries the reference's `last_used`, which keeps account eviction accurate for accounts only ever reached through a session, and the device registry's `last_used`, so a browser that has an app open counts as in use rather than as idle since its last sign-in. That is an anchor write on a call that otherwise never touches the anchor; §9.3 traded it away to keep refresh cheap, and the accounting above is why it is worth paying now. Implements docs/ongoing/revocable-app-sessions.md §7.3 (S13). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signing out of an app cleared browser state and invalidated nothing. Now it removes the session record, so the app's access ends within one delegation lifetime instead of running to the delegation's original expiry. The method needs no authorization check beyond the match refresh already performs: a caller cannot produce another session's principal, so it can only ever remove its own. It returns nothing and always succeeds, so a client that retries, or that signs out twice, does not have to reason about whether its session was already gone. Revoking anything else is the II frontend's operation, not something a dapp can trigger. Implements docs/ongoing/revocable-app-sessions.md §8.1 (S14, S15). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The app can sign out only its own session, so "sign this browser out" has to come from somewhere the anchor is authenticated. These two are authenticated by an anchor access method rather than by a session chain, and name sessions by locator rather than by principal, so the principal index stays on the app-facing path. Signing a browser out is an eager sweep of that anchor's references in one message, so refresh never has to read the anchor to find out whether its device was revoked: refresh happens every few minutes per active session, this happens rarely. The device record survives the sweep, so a browser that has been signed out is still one the user recognises and signing back in from it reuses the same id. Implements docs/ongoing/revocable-app-sessions.md §8.2, §9.3 (S16, S20). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ii_session_delegation` obtains the session an app refreshes against. It is namespaced rather than folded into `icrc34_delegation` because an app that does not implement refresh must not silently start receiving 5-minute delegations: with a session-only response, an app that cannot refresh simply never calls this, and `icrc34_delegation` keeps behaving exactly as it does today. The canister signs the session to a non-extractable key this origin holds, and the app receives that chain extended to a key it supplies, so no private key is shared and neither side loses non-extractability. The app's hop carries `targets` restricted to the II canister, which makes an app that reaches for the session chain where it meant its app delegation fail immediately rather than appear to work. Sign-in proves which browser it comes from with a key kept in IndexedDB, one per identity so nothing stored links two identities to the same browser, and rotates that key every time: each sign-in announces a freshly generated successor and advances to it once the canister has accepted. A key therefore proves one sign-in, so a copy of this browser's profile stops working as soon as the real browser signs in again. Advancing only on success is what makes a lost response harmless, and the first sign-in stores its key before the call for the same reason: the canister may have registered it, so coming back with a different key would enrol this browser twice. Sign-ins for one identity are serialised with a web lock, since two at once would leave us holding a key the canister never accepted. The key is non-extractable and appears in no chain, no bundle and nothing an app receives, which is what lets it identify the browser without letting two apps recognise it. A browser that cannot keep its key registers a fresh one, which the identity sees as a new browser rather than as a failed sign-in. Returning to an app, or arriving at a sibling of one, re-issues from the held session without another ceremony. Extending the chain is offline and is not what makes anything revocable: the app delegation itself can only come from the canister, which checks the session record on every mint. The browser's name comes from the user agent, where several distinct browsers report the same string, so it is a label rather than an identifier: the settings list tells two entries apart by the id this browser stores alongside its key. Implements the frontend half of docs/ongoing/revocable-app-sessions-spec.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sea-snake
force-pushed
the
feat/revoke-sessions-from-settings
branch
from
August 22, 2026 17:48
c2bf0ea to
9a4941c
Compare
sea-snake
force-pushed
the
feat/ii-session-delegation-rpc
branch
from
August 22, 2026 17:48
eadbdc2 to
12b3718
Compare
sea-snake
force-pushed
the
feat/revoke-sessions-from-settings
branch
from
August 22, 2026 18:08
9a4941c to
d2b3363
Compare
Contributor
Author
|
Superseded by a five-way split of this PR, each layer under 500 lines of implementation:
The combined content is unchanged; the split was verified lossless against this branch's tree. |
This was referenced Aug 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Design: #4224. Overview: #4230.
Requires the canister API from #4243, which the stack order enforces.
Motivation
The canister half of sessions is in place; this is how an app gets one.
Changes
A new JSON-RPC method,
ii_session_delegation. Namespaced rather than folded intoicrc34_delegation, because an app that asks for a session but has not implemented refresh must not silently start receiving 5-minute delegations. With a session-only response, an app that cannot refresh never calls this, andicrc34_delegationkeeps behaving exactly as today.The chain, and nothing else. The canister signs the session identity to a non-extractable key this origin keeps in IndexedDB, keyed by
(identity, account, origin), and the frontend extends that chain to a key the app supplies. The app's hop carriestargetsrestricted to the II canister: a developer guardrail rather than a defence, since a thief holding the chain can refresh, but it makes an app reaching for the session chain where it meant its app delegation fail immediately.The result carries the chain alone. Nothing is attached to the calls an app then makes — the canister resolves the session from the signature on the call — so an app needs no agent extension of any kind.
The browser key, and its rotation. Sign-in presents a long-lived P-256 key kept in IndexedDB, one per identity so nothing stored links two of the user's identities to the same browser. Each sign-in generates a successor, signs with both keys, and advances to the successor once the canister accepts. So a key proves one sign-in and is then retired, and a copied profile cannot keep signing in unnoticed.
Three rules make that safe on the sign-in path:
A response that never arrives leaves both sides on the key the entry still holds.
The canister may have registered it, so returning with a different key would enrol this browser twice.
Two sign-ins at once would leave whichever wrote last holding a key the canister never accepted.
The consent duration is honoured, and an SSO cap still binds it. An SSO organization caps how long its sign-ins stay valid, so a session sends a duration even when the user picked none, matching what the ICRC-34 path already does. Neither clamp has a unit test — the store this path needs is not stubbed in that file.
The consent duration is honoured.
valid_forcarries the lifetime the user picked at the consent screen, clamped by the canister, so a user choosing one hour gets an hour rather than the default thirty days.The name. Derived from the user agent in its own module, ordered most-specific-token-first because every later token appears inside the earlier ones' agents. It names the device where a device word exists —
Safari on iPhone,Chrome on Mac,Chrome on Chromebook— and uses the platform's device model where there is one, so an Android entry readsChrome on Pixel 5. The label is coarse by construction, which is why the settings list identifies an entry by its id.Tests
browser-key.store.test.ts(14): both signatures verify under the domains the canister checks and neither verifies in the other's role; the announced successor is not the key in use; accepting a sign-in rotates to it and not accepting keeps the current key; each identity holds its own; clearing storage yields a key unrelated to either old one; concurrent sign-ins serialise; a browser without the lock API still signs in; the encoding is the 91-byte SPKI and 64-byte signatures the canister parses; and the device id is remembered on acceptance.describeBrowser.test.ts(27): one per row of both ladders over real user-agent strings, the iPad and Mac pair separated only bymaxTouchPoints, and the model path — used when present, ignored when empty or when the platform refuses, absent without the API, and dropped when it would exceed the canister's 128-byte limit.app-session.store.test.ts(7) andsessionDelegation.test.ts(6) cover storage keyed by identity, account and origin, expiring sessions not served, a held session re-issued without a ceremony, and the chain carryingtargetsrestricted to the II canister.