Skip to content

feat(fe): hand apps a session to re-issue their own delegations from - #4273

Merged
sea-snake merged 129 commits into
fe/app-session-storefrom
fe/ii-session-delegation-handler
Sep 10, 2026
Merged

feat(fe): hand apps a session to re-issue their own delegations from#4273
sea-snake merged 129 commits into
fe/app-session-storefrom
fe/ii-session-delegation-handler

Conversation

@sea-snake

@sea-snake sea-snake commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Design: #4224. Overview: #4230. Depends on #4272 for the record store, #4271 for the browser key, and #4270 for the browser description — this is what gives all three their production caller.

The canister side is complete after #4265, but nothing hands an app a session yet. This is the JSON-RPC method that does.

ii_session_delegation is what an app calls instead of icrc34_delegation when it wants a session rather than a long-lived delegation. It answers with the session chain, extended to the app's own key, and the app mints five-minute delegations from it with no further browser involvement.

The ii_ prefix is deliberate: an RPC method travels over a transport shared with other providers and has to say which one it belongs to, which is why every II-specific RPC method carries it.

targets on the chain is restricted to the II canister. That is a developer guardrail, not a defence against a thief — a thief holding the chain can refresh with it either way. Revocability is the protection that matters, and saying otherwise in a security table would be dishonest.

The consent duration is honoured, and an SSO organization's cap still binds it: valid_for carries the lifetime the user chose, and an SSO identity sends a duration even when the user picked none, matching the ICRC-34 path.

The request carries only a session public key and an optional derivation origin. An app cannot ask for an access level or a lifetime, because both are the user's to decide at consent.

icrc25_permissions now reports ii_session_delegation as a granted scope so an app can discover the method. icrc34_delegation itself is unchanged.

Tests: sessionDelegation.test.ts (5) covers a request for another method being ignored and params carrying no session key being rejected. The success path has no unit test here, which is worth knowing rather than glossing: the harness does not stub the authentication store, so createSession never completes in it, and neither this path's SSO clamp nor the ICRC-34 one is covered. #4248 stubs enough to drive the handler to an answer and exercises the targets restriction and the held-session path. Review createSession by reading it.

@zeropath-ai

zeropath-ai Bot commented Aug 22, 2026

Copy link
Copy Markdown

No security or compliance issues detected. Reviewed everything up to e0044a5.

Security Overview
Detected Code Changes
Change Type Relevant files
Enhancement ► src/frontend/src/lib/stores/channelHandlers/icrc25.ts
     Extend supported standards with ii_session_delegation
Enhancement ► src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts
     Add II session delegation handling and session minting flow
 ► src/frontend/src/lib/stores/channelStore.ts
    Wire in session delegation handler to channel events
Enhancement ► src/frontend/src/lib/stores/channelStore.ts
     Register session delegation request handler on channel
Enhancement ► src/frontend/src/lib/utils/iiConnection.ts
     Expose remapToLegacyDomain utility and re-export to maintain compatibility
Enhancement ► src/frontend/src/lib/utils/urlUtils.ts
     Add remapToLegacyDomain function for domain mapping

@sea-snake
sea-snake force-pushed the fe/ii-session-delegation-handler branch 2 times, most recently from ad26518 to 712f197 Compare August 22, 2026 18:45
@sea-snake
sea-snake force-pushed the fe/ii-session-delegation-handler branch 2 times, most recently from d36160e to c3cb305 Compare August 22, 2026 19:25
`ii_session_delegation` is what an app calls instead of `icrc34_delegation` when
it wants a session rather than a long-lived delegation. It answers with the
session chain, extended to the app's own key, and the app mints five-minute
delegations from it with no further browser involvement.

The chain the app receives has `targets` restricted to the II canister. That is a
developer guardrail, not a defence against a thief, who can refresh with it
either way: revocability is the protection that matters.

The consent duration is honoured — `valid_for` carries the lifetime the user
chose, clamped by the canister — and an SSO organization's own cap still binds
it, as it already does on the ICRC-34 path.

The request carries only a session public key and an optional derivation origin.
An app cannot ask for an access level or a lifetime, because both are the user's
to decide at consent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sea-snake
sea-snake force-pushed the fe/ii-session-delegation-handler branch from c3cb305 to e0fdc2c Compare August 22, 2026 19:49
sea-snake and others added 11 commits August 23, 2026 20:43
ii_session_delegation took no lifetime, so an application had no way to ask for a
session shorter than whatever the consent picker offered, and the request context
was set with undefined where the ICRC-34 handler passes what the app asked for.

The method now accepts maxTimeToLive on the same terms as ICRC-34: a ceiling
rather than a request. What the user picks at consent wins, an SSO organization's
cap narrows it further, and the canister clamps the result to between ten minutes
and thirty days.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two are written together here; they are separable so that a later layer can
decline the session and keep the mapping.
@sea-snake
sea-snake removed this pull request from stack #4277 September 9, 2026 20:32
@sea-snake
sea-snake added this pull request to stack #4324 September 9, 2026 20:34
@sea-snake
sea-snake removed this pull request from stack #4324 September 9, 2026 20:35
@sea-snake
sea-snake added this pull request to stack #4323 September 9, 2026 20:38
@sea-snake
sea-snake removed this pull request from stack #4323 September 9, 2026 20:42
@sea-snake
sea-snake added this pull request to stack #4326 September 9, 2026 20:43
Comment thread src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts
sea-snake and others added 3 commits September 10, 2026 14:02
…ilence

`StringToBigIntCodec` calls `BigInt` straight, and a throw inside a codec
escapes `safeParse` rather than becoming an issue. The parse runs above the
handler's `try`, so a duration like `"soon"` never reached the invalid-params
branch and the app was told nothing at all. `Nat64StringCodec` reports it
instead — and bounds it, since `BigInt` alone accepts `"-1"` and `""`. No
regex: a second grammar beside `BigInt`'s own would be free to drift from it.

`remapToLegacyDomain` moves to `urlUtils`, beside the two constants it already
used, and is re-exported from `iiConnection` for its callers there. It is seven
lines of string work with nothing to do with a connection, and importing it
from there forced this handler's test to mock the whole legacy module — as the
identity function, which quietly disabled the remap it was standing in for.

The key soup gets names that say whose key each is: `recordKey` is where the
session is stored, `iiSessionIdentity` and `iiSessionPublicKey` are II's own,
and the request's `sessionPublicKey` stays the app's. With a comment on why
that identity exists at all, which is a security property: over a redirect no
verified origin identifies the requester, so the canister must not certify
toward a key the request supplied.

Tests reach the ceremony for the first time — the canister arguments, the
stored record and the returned chain — plus the malformed duration above.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ
@sea-snake
sea-snake removed this pull request from stack #4326 September 10, 2026 12:55
@sea-snake
sea-snake added this pull request to stack #4328 September 10, 2026 12:55
The session credential is scoped to the II canister, and `targets` is part of
the message the canister signs. Rebuilding the delegation from the reply left
them out, so the chain handed to the app carried a first hop that hashes to
nothing in the signature tree — every call made with it came back "Invalid
canister signature: the signature tree doesn't contain sig/…/… path".

The wire format was never at fault: the result codec carries `targets` both
ways. They were gone before anything was encoded, and went into the stored
`chainJson` with the rest.

The test that should have caught this mocked `targets: []` — Candid's `None`,
the shape from before the credential was scoped — so a rebuild that dropped
them matched. It now answers as the canister does and asserts the hop keeps
them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ
Copilot AI balanced review requested due to automatic review settings September 10, 2026 14:00

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.

🟡 Changes recommended

The request shape conflicts with the documented API, and nat64 parsing accepts non-decimal inputs.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds the frontend JSON-RPC flow that issues revocable app-session chains.

Changes:

  • Implements and registers ii_session_delegation.
  • Persists sessions and restricts chains to the II canister.
  • Advertises the new permission scope and adds handler tests.
File summaries
File Description
urlUtils.ts Hosts gateway-domain remapping.
transport/utils.ts Adds nat64 string decoding.
iiConnection.ts Re-exports the moved remapping helper.
channelStore.ts Registers the session handler.
sessionDelegation.ts Creates, stores, and returns app sessions.
sessionDelegation.test.ts Tests requests, issuance, and error mapping.
icrc25.ts Advertises the new scope.
Review details
  • Files reviewed: 7/7 changed files
  • Comments generated: 2
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts
Comment thread src/frontend/src/lib/utils/transport/utils.ts
sea-snake and others added 2 commits September 10, 2026 17:29
BigInt reads more than the decimal strings JSON-RPC carries: "" and
" " are 0n, "+1" is 1n, "0x10" is 16n, and the nat64 bounds reject
none of them. A malformed duration therefore reached the canister as
a number it silently clamped, instead of the invalid-params error the
app could act on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ
@sea-snake sea-snake closed this Sep 10, 2026
@sea-snake sea-snake reopened this Sep 10, 2026
@sea-snake
sea-snake merged commit c451c33 into main Sep 10, 2026
85 checks passed
@sea-snake
sea-snake deleted the fe/ii-session-delegation-handler branch September 10, 2026 21:20
sea-snake added a commit that referenced this pull request Sep 10, 2026
Design: #4224. Overview: #4230. Groundwork for #4273, which is the
second caller.

The delegation handler serialises its own requests so that two consent
screens cannot race. The session handler coming in #4273 also
authorizes, and it has to share that queue rather than keep one of its
own — two queues would let one request paint over the other's screen.

Lifts the queue out of the delegation handler into `serialize.ts` as
`serializeAuthorizationRequest`. No change to what the delegation
handler does with it.

No tests: `handleDelegationRequest` has no unit coverage today, and the
extracted function is a move of the existing queue. Its first test
arrives with the second caller in #4273.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
sea-snake added a commit that referenced this pull request Sep 10, 2026
Design: #4224. Overview: #4230. Nothing imports this until the sign-in
in #4273.

Settings shows which browsers hold a session so the user can end one
they do not recognise, and each entry needs to be something they *will*
recognise. The client is the only party that can see what it is running
on, so it is the one that has to say.

`describeBrowser` resolves the four things the record holds (#4242) — a
brand, an operating system, a form factor, and the hardware model where
one is reported — rather than composing a label out of them.

**Resolving rather than naming is the point.** A composed label fixes
the wording at the moment of sign-in: a browser that registered before
"Chrome OS" became "ChromeOS" would read the old name for as long as its
record existed, and the agent it was parsed from is not kept, so nothing
could re-derive it. With tokens the wording lives where the list is read
(#4249), so renaming a product — or teaching this table to recognise a
browser it used to miss — reaches every stored record instead of only
the browsers that sign in afterwards.

**Two sources, because neither covers the field alone.** The user agent
carries the brand and the system on every engine, and its table stays
ordered most specific first, because every later token appears inside
the earlier ones' agents: matching `Chrome` first would claim every
Chromium-based browser, and every Chromium agent carries `Safari/` as
well. Client hints are Chromium-only — Safari and Firefox expose nothing
at all — so they supply only what no agent can: the hardware model,
which a reduced Android agent reports as the literal string `K`, and a
form factor the browser states rather than one inferred. Only the plural
`formFactors` is asked for; the singular shape Chromium shipped first is
not, because only current browsers are a concern. A stated `Watch`,
`XR`, `Automotive` or `EInk` resolves to `Unknown`: the canister names
desktops, mobiles and tablets, and calling a watch a phone is worse than
naming it as none of them.

**A browser that hides reads as the one it imitates.** Brave ships a
plain Chrome agent on purpose and strips the hints that would give it
away, so it reads as Chrome — which is what the agent says, and the
honest answer rather than a gap. Asking `navigator.brave.isBrave()`
would name it, at the cost of a check per browser this frontend has
heard of, which is the hardcoded list this table exists to avoid. A
browser that does name itself in its agent, Vivaldi or DuckDuckGo,
travels as the token it gave rather than resolving to Chrome, without
needing a row of its own.

An unrecognised browser or system is carried as the token that arrived,
capped to what the canister accepts, because that row is the one this
list exists to surface. `maxTouchPoints` is consumed here rather than
stored: an iPad in desktop mode sends a Mac agent and exposes no hints,
so it is the only thing that tells it from a Mac.

Tests: `describeBrowser.test.ts` (37) — the 20 real user-agent strings,
one per rung of both ladders, including the iPad and Mac pair separated
only by `maxTouchPoints`; the model used when present and absent when
empty or refused; a stated form factor, the four that resolve to
`Unknown`, and a tablet stated beside one of them; a browser that hides
itself reading as the one it imitates; and an unrecognised token capped
to 64 bytes.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
sea-snake added a commit that referenced this pull request Sep 10, 2026
Design: #4224. Overview: #4230. Nothing imports this until the sign-in
in #4273.

The canister identifies a browser by a key it proves possession of
(#4264), so the frontend has to hold one, and rotate it, so a key
captured from one sign-in cannot claim that browser at the next.

A non-extractable P-256 keypair in IndexedDB, per identity, plus a
successor generated alongside it. A sign-in signs with the current key
and announces the successor; the successor is promoted only once the
canister has confirmed the sign-in.

**Promoting after confirmation rather than before is what survives a
lost response.** If the reply never arrives, the browser still holds the
key the canister has, so its next attempt presents the same one rather
than a successor the canister never saw — which the canister would treat
as an unknown browser and enrol as a second entry for the same machine.

**Sign-ins are serialised with a web lock**, because two at once would
leave whichever wrote last holding a key the canister never accepted.
Where the Web Locks API is missing the calls run unserialised: not
blocking sign-in on it is the deliberate trade, and the window is one
concurrent sign-in on the same device.

**A browser that no longer matches what it registered as signs in as a
new one.** The description it reported (#4270) is stored beside the key
pair, and compared before anything is sent. A registered entry keeps the
description it was created with — the canister ignores what a sign-in
reports once an entry is being advanced — so a browser whose brand,
system, form factor or model has changed would otherwise keep rotating
an entry describing something it no longer is. Where it differs, this
presents a fresh key pair, which no entry holds and which therefore
registers under its own.

Deciding here rather than being told keeps that a purely local
operation: nothing has been sent when the comparison happens, so the
entry left behind is untouched and a retry hits the same state and takes
the same branch. The description is written with the key pair and never
on its own, so the comparison is always against what the canister was
actually sent.

The private key never leaves IndexedDB and never appears in a delegation
chain.

Tests: `browser-key.store.test.ts` (21), including the successor
promoted only after the call succeeds, a lost response leaving the
current key in place, concurrent sign-ins serialised, the store working
with the lock API absent, and a changed description signing in on a
fresh key pair while an unchanged one keeps rotating.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
sea-snake added a commit that referenced this pull request Sep 10, 2026
Design: #4224. Overview: #4230. Consumed by #4273, which stores a
record, and #4248, which reads and discards one.

A session outlives the tab that created it. Without storing the chain, a
sibling subdomain asking for the first time — or a user returning to a
closed tab — would mean another ceremony, which is the cadence this
design exists to remove.

Records keyed by identity, account and origin, in IndexedDB alongside
the identity's own session.

A record within five minutes of its expiry is not treated as usable, so
a chain is never handed over that dies mid-request. Five minutes is half
the shortest session a caller can ask for, so a session near that floor
is never answered from storage.

The purge that already runs when an identity is discarded now takes
these records with it, at the eight places that discard one. Leaving
them would keep a chain for an identity the browser has forgotten.

Three of the four exports have no caller here: `storeAppSession` gains
one in #4273, `appSessionsForOrigin` and `discardAppSession` in #4248.
`purgeAppSessions` is called by this PR.

Tests: `app-session.store.test.ts` (11): storage keyed by identity,
account and origin; a record for another origin not served; an expiring
record not served; and the purge removing them.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature:revocable-app-sessions Design: revocable app sessions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants