feat(be,fe): answer a silent re-auth without rendering anything - #4248
Merged
Conversation
|
✅ No security or compliance issues detected. Reviewed everything up to d31f27f. Security Overview
Detected Code Changes
|
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/authorize-prompt-none
branch
from
August 19, 2026 02:57
620d5be to
606418f
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/authorize-prompt-none
branch
from
August 19, 2026 03:47
606418f to
58592ed
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/authorize-prompt-none
branch
from
August 19, 2026 06:28
58592ed to
486bb58
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/authorize-prompt-none
branch
from
August 19, 2026 07:07
486bb58 to
8c687b8
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/authorize-prompt-none
branch
from
August 19, 2026 08:17
8c687b8 to
5e98a05
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/authorize-prompt-none
branch
from
August 19, 2026 08:37
5e98a05 to
18ee8c3
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/authorize-prompt-none
branch
from
August 19, 2026 10:21
18ee8c3 to
3b06408
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/authorize-prompt-none
branch
from
August 20, 2026 10:57
3b06408 to
68ae7c4
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/authorize-prompt-none
branch
from
August 20, 2026 13:08
68ae7c4 to
42cb712
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/authorize-prompt-none
branch
from
August 20, 2026 13:52
42cb712 to
477ccf4
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/authorize-prompt-none
branch
from
August 20, 2026 15:24
477ccf4 to
1603372
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/authorize-prompt-none
branch
from
August 20, 2026 16:10
1603372 to
1c236ef
Compare
sea-snake
force-pushed
the
feat/ii-session-delegation-rpc
branch
from
August 20, 2026 17:24
d8a2574 to
7a11ac2
Compare
MRmarioruci
reviewed
Sep 10, 2026
MRmarioruci
reviewed
Sep 10, 2026
MRmarioruci
approved these changes
Sep 10, 2026
A device stating Watch, XR, Automotive or EInk is none of desktop, mobile or tablet. Falling through to `mobile` called a watch a phone and an e-reader a desktop, so those four resolve to Unknown instead, after the Tablet check so a device stating both keeps the variant that exists. The singular `formFactor` fallback goes with it: only `formFactors` is ever requested, so the singular could never arrive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ
`announced` is an adjective with no noun, leaving the doc comment to supply the word. With the noun in the name the comment says why the key is retained instead of what it is, and the rotation function's summary states what it returns. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ
`parseKey` is documented to return undefined for a key it cannot read and honoured that for the shape checks, but let `BigInt` throw on a non-numeric segment. Every purge and the expiry sweep run it over every key in the store, so one unreadable key stopped them all. Removing an identity from the list, and an Unauthorized from a stored II session, both drop the records they can rather than calling forgetIdentity: revoking a browser's sessions needs full authorization, which a stored session delegation does not carry, so the call was a round trip that could only fail. The toast no longer claims apps were signed out, and Undo restores what removal took. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ
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
The row switched layouts on the page's width while living in a settings column that is narrow at any viewport, so the wide layout stayed in a space too small for it: the name truncated and the meta printed over the badge. It switches on the card's own width now. Narrow, the action moves up beside the name instead of taking a full-width row below the meta, which is what made a row five lines tall. The inset rule between rows is its own element: indenting the row to inset the rule moved the row with it, so every browser after the first sat 16px right of the first. The browser being read from leads its group, and the sign-out confirmation names it in the title while the button carries the scope, so the two no longer say the same words and Cancel exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ
…pt-none # Conflicts: # src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts
Counting the stored records rather than the live sessions made one live record beside one revoked elsewhere look like two candidates, so a request with exactly one answer was refused as an ambiguity. Liveness is decided before the choice now, which also leaves the chosen session already checked. The credential pre-fetch and the page view both wait for `isReady`. Neither can be decided at mount, and a silently answered request sets no authorization context, so it was paying a canister query per remembered identity and counting a view of a page nobody saw. chooseSilentSession moves beside the handler that uses it: its only non-test caller is in $lib and reached into a route directory for it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ
One function served both flows and what it did depended on whether authenticationStore happened to hold the identity, which no caller passed in. So removing an identity from a list fired revoke_browser_sessions through a stored session delegation, which full authorization refuses: a round trip that always answered Unauthorized, swallowed, while four toasts told the user their apps had been signed out. forgetIdentity now drops the local records and nothing else. revokeIdentity ends this browser's sessions and then forgets, and inspects the result variant, so a refusal no longer resolves like a success. Its one caller is the sign-out-and-remove on the manage page, where the user is authenticated as the identity. Removing the access method in use is refused rather than handled. Both item components disable Remove while it is the current method unless it is also the last, and removing the last requires isSignedInWithRecovery, which reads the same authMethod and is true only for recovery — so it cannot hold at once with isCurrentAccessMethod. Removing the selected identity moves the selection on first: the landing page and the other list pages pass no `selected`, so every identity there is removable, and the selection would otherwise name an entry that is gone and render as signed out. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ
The dialog asks "Remember this browser?" with Remember and Forget. The fixture still waited for the old heading and clicked the old buttons, so every test that signed out timed out. Forget is matched exactly, so it does not also match the "Forgetting..." label the button takes while the sessions are being revoked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ
The sign-out dialog names no identity, so asserting the active one appears in it cannot hold. Which identity was signed out is still covered, by the stored last-used entries the test already checks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ
…-none # Conflicts: # src/frontend/src/lib/stores/app-session.store.test.ts # src/frontend/src/lib/stores/app-session.store.ts # src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts # src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts
The tests around this one locate the prepare call by its TTL and then read only max_idle, and they check a record exists without looking inside it. So the identity, origin and account the session is asked for went unasserted, and so did the session id. The id earns its own assertion: get_account_session names the session by it, so a record holding the wrong one cannot be resumed, and every other test here would still pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVi99RYo2jyi2kCurgovNJ
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: #4228, on the sessions of #4224. Overview: #4230. Sits on the frontend stack at #4249.
Sibling subdomains of one domain can share a sign-in: sign in on
chat.example.comandhr.example.comis signed in too, sign out of one and the others follow. The client half is already specified in@icp-sdk/auth— a sharedderivationOrigin, a domain-scoped cookie holding only a principal and an expiry, and a/reauthpage runningprompt: 'none'with ahint. This is only what II has to supply for that to work.Re-issuing is a canister-checked mint like any other rather than an offline extension of a stored chain, so what the app receives stays revocable. The mint is unchanged; this adds one query,
check_session, so the frontend can tell a record it still holds from a session the canister has already dropped.Three authorize-URL parameters.
prompt(none|login) andhint(a principal in text form) are preferences: both are read with.catch(undefined), so a value that cannot be read degrades to an interactive sign-in rather than failing the request, and neither is ever a credential. They survive the round trip an interactive flow may take through an IdP, and are stripped from the address bar once consumed.resumableis not a preference, and it changes the interactive path. It flipsstoreAppSessionfrom unconditional to conditional — one line, and the one that makes the silent path fail closed for every app that never opted in, since a session that was never stored cannot be silently re-issued. It is therefore load-bearing for the security argument below in a waypromptis not:promptdecides whether a held session is consulted,resumabledecides whether there is one to consult. It is also the only change here that alters what happens on an ordinary, non-silent sign-in.prompt=nonerenders nothing, ever. Either the redirect carries a session chain or it carriesinteraction_required, which the client can tell apart from a real error and fall back from. Three things make that hold: the context is read before the channel is established, so the handler has it when it answers, ahead of anything that paints; the denial goes on the channel rather than through the error store, which drives a full-page view; and every failure path answers, invalid params and the catch-all around the whole handler included. Otherwise a storage or WebCrypto hiccup would paint II's error page inside what the user experiences as their own app's tab and send no response at all, hanging the client's/reauthinstead of letting it take its fallback branch.prompt=nonenever creates a session, because a session comes only fromprepare_account_session, which requires an anchor access method that a silent request has not proved. So it can exercise authority and never obtain it — the same rule that stops a stolen chain spawning siblings.hintselects among the sessions this browser holds for the origin being authorized and can never name another origin's. That is what makes it safe for the hint to come from a cookie the app can read and write: it selects from what II already holds for that origin, and holding the session is what confers anything. The derivation origin is validated before the session lookup, and the store filters on exact origin match. Several candidates with nothing to choose between them isinteraction_required, not a guess.Silence is something an app asks for, twice. A session is stored only where the sign-in that created it was marked
resumable, and a stored session is consulted only underprompt=none— anything else, an absentpromptincluded, runs the ceremony. So a session this browser holds is never handed over without the user seeing a screen they did not ask for, and an app that never asked for resumability has nothing held for it at all.Sibling sharing and sign-out propagation need no mechanism of their own: a shared
derivationOriginmeans every sibling resolves to one application and sessions live at(anchor, application), so there is one record the siblings take turns re-issuing from — andapp_revoke_sessionremoves the record they all share, not merely the cookie.promptParams.tsandsilentReauth.tssit inside the authorize route that uses them.Tests:
promptParams.test.ts(10) andsilentReauth.test.ts(6) cover reading and degrading both parameters, the context surviving a resume, and selection — nothing held, one session, two personas asking rather than guessing, and hinted, unheld and ambiguous hints.sessionDelegation.test.tsgains a silent request answering rather than surfacing an unverified origin, and bothprompt=loginand an absent prompt running the ceremony with a session held.