Skip to content

feat(be,fe): answer a silent re-auth without rendering anything - #4248

Merged
sea-snake merged 1941 commits into
mainfrom
feat/authorize-prompt-none
Sep 10, 2026
Merged

feat(be,fe): answer a silent re-auth without rendering anything#4248
sea-snake merged 1941 commits into
mainfrom
feat/authorize-prompt-none

Conversation

@sea-snake

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

Copy link
Copy Markdown
Contributor

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.com and hr.example.com is signed in too, sign out of one and the others follow. The client half is already specified in @icp-sdk/auth — a shared derivationOrigin, a domain-scoped cookie holding only a principal and an expiry, and a /reauth page running prompt: 'none' with a hint. 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) and hint (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.

resumable is not a preference, and it changes the interactive path. It flips storeAppSession from 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 way prompt is not: prompt decides whether a held session is consulted, resumable decides 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=none renders nothing, ever. Either the redirect carries a session chain or it carries interaction_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 /reauth instead of letting it take its fallback branch.

prompt=none never creates a session, because a session comes only from prepare_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.

hint selects 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 is interaction_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 under prompt=none — anything else, an absent prompt included, 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 derivationOrigin means every sibling resolves to one application and sessions live at (anchor, application), so there is one record the siblings take turns re-issuing from — and app_revoke_session removes the record they all share, not merely the cookie.

promptParams.ts and silentReauth.ts sit inside the authorize route that uses them.

Tests: promptParams.test.ts (10) and silentReauth.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.ts gains a silent request answering rather than surfacing an unverified origin, and both prompt=login and an absent prompt running the ceremony with a session held.

@sea-snake
sea-snake requested a review from a team as a code owner August 19, 2026 01:48
@zeropath-ai

zeropath-ai Bot commented Aug 19, 2026

Copy link
Copy Markdown

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

Security Overview
Detected Code Changes
Change Type Relevant files
Enhancement ► src/canister_tests/src/api/internet_identity/api_v2.rs
      Add check_session frontend II frontend liveness check
Enhancement ► src/frontend/src/lib/generated/internet_identity_idl.js
      Expose check_session as IDL function
Enhancement ► src/frontend/src/lib/generated/internet_identity_types.d.ts
      Add check_session method type to _SERVICE
Refactor ► src/frontend/src/lib/stores/app-session.store.ts
      Remove discardAppSession functionality
Enhancement ► src/frontend/src/lib/stores/authorization.store.ts
      Introduce authorizationPromptStore for silent/prompt handling
Enhancement ► src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts
      Integrate authorizationPromptStore and silent reauth flow; add session live checks and silent denial handling
Enhancement ► src/frontend/src/lib/stores/channelHandlers/silentReauth.ts (new file)
      Implement chooseSilentSession logic for silent reauth
Enhancement ► src/frontend/src/lib/stores/channelHandlers/silentReauth.test.ts (new file)
      Add tests for chooseSilentSession behavior
Enhancement ► src/frontend/src/lib/stores/channelHandlers/silentReauth.ts (new file)
      Export chooseSilentSession implementation
Enhancement ► src/frontend/src/lib/utils/transport/utils.ts
      Add INTERACTION_REQUIRED_ERROR_CODE for silent requests
Enhancement ► src/frontend/src/routes/(new-styling)/authorize/+layout.svelte
      Set authorizationPromptStore with prompt params; strip prompt params; adjust imports
Enhancement ► src/frontend/src/routes/(new-styling)/authorize/promptParams.test.ts (new file)
      Add tests for prompt parameter parsing
Enhancement ► src/frontend/src/routes/(new-styling)/authorize/promptParams.ts (implied by tests referencing)
Bug Fix / Refactor ► (modifications across tests and mocks in tests) src/frontend/src/lib/stores/channelHandlers/sessionDelegation.test.ts
      Adjust mocks and test expectations to accommodate new silent reauth flow

@sea-snake
sea-snake force-pushed the feat/ii-session-delegation-rpc branch from c9292ba to 33efcb4 Compare August 19, 2026 02:57
Copilot AI lite review requested due to automatic review settings August 19, 2026 02:57
@sea-snake
sea-snake force-pushed the feat/authorize-prompt-none branch from 620d5be to 606418f Compare August 19, 2026 02:57

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@sea-snake
sea-snake force-pushed the feat/ii-session-delegation-rpc branch from 33efcb4 to 1ab90d6 Compare August 19, 2026 03:47
@sea-snake
sea-snake force-pushed the feat/authorize-prompt-none branch from 606418f to 58592ed Compare August 19, 2026 03:47
@sea-snake
sea-snake force-pushed the feat/ii-session-delegation-rpc branch from 1ab90d6 to cf69804 Compare August 19, 2026 06:28
@sea-snake
sea-snake force-pushed the feat/authorize-prompt-none branch from 58592ed to 486bb58 Compare August 19, 2026 06:28
@sea-snake
sea-snake force-pushed the feat/ii-session-delegation-rpc branch from cf69804 to 4b28f35 Compare August 19, 2026 07:07
@sea-snake
sea-snake force-pushed the feat/authorize-prompt-none branch from 486bb58 to 8c687b8 Compare August 19, 2026 07:07
@sea-snake
sea-snake force-pushed the feat/ii-session-delegation-rpc branch from 4b28f35 to c6019d7 Compare August 19, 2026 08:17
@sea-snake
sea-snake force-pushed the feat/authorize-prompt-none branch from 8c687b8 to 5e98a05 Compare August 19, 2026 08:17
@sea-snake
sea-snake force-pushed the feat/ii-session-delegation-rpc branch from c6019d7 to cb54c0b Compare August 19, 2026 08:37
@sea-snake
sea-snake force-pushed the feat/authorize-prompt-none branch from 5e98a05 to 18ee8c3 Compare August 19, 2026 08:37
@sea-snake
sea-snake force-pushed the feat/ii-session-delegation-rpc branch from cb54c0b to 12bbcbd Compare August 19, 2026 10:21
@sea-snake
sea-snake force-pushed the feat/authorize-prompt-none branch from 18ee8c3 to 3b06408 Compare August 19, 2026 10:21
@sea-snake
sea-snake force-pushed the feat/ii-session-delegation-rpc branch from 12bbcbd to 7c25b2d Compare August 20, 2026 10:57
@sea-snake
sea-snake force-pushed the feat/authorize-prompt-none branch from 3b06408 to 68ae7c4 Compare August 20, 2026 10:57
@sea-snake
sea-snake force-pushed the feat/ii-session-delegation-rpc branch from 7c25b2d to e24e8c7 Compare August 20, 2026 13:08
@sea-snake
sea-snake force-pushed the feat/authorize-prompt-none branch from 68ae7c4 to 42cb712 Compare August 20, 2026 13:08
@sea-snake
sea-snake force-pushed the feat/ii-session-delegation-rpc branch from e24e8c7 to 0deb842 Compare August 20, 2026 13:52
@sea-snake
sea-snake force-pushed the feat/authorize-prompt-none branch from 42cb712 to 477ccf4 Compare August 20, 2026 13:52
@sea-snake
sea-snake force-pushed the feat/ii-session-delegation-rpc branch from 0deb842 to dac7405 Compare August 20, 2026 15:24
@sea-snake
sea-snake force-pushed the feat/authorize-prompt-none branch from 477ccf4 to 1603372 Compare August 20, 2026 15:24
@sea-snake
sea-snake force-pushed the feat/ii-session-delegation-rpc branch from dac7405 to d8a2574 Compare August 20, 2026 16:10
@sea-snake
sea-snake force-pushed the feat/authorize-prompt-none branch from 1603372 to 1c236ef Compare August 20, 2026 16:10
@sea-snake
sea-snake force-pushed the feat/ii-session-delegation-rpc branch from d8a2574 to 7a11ac2 Compare August 20, 2026 17:24
Comment thread src/frontend/src/routes/(new-styling)/authorize/+layout.svelte Outdated
Comment thread src/frontend/src/lib/stores/channelHandlers/sessionDelegation.ts
sea-snake and others added 11 commits September 10, 2026 17:25
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
@sea-snake sea-snake closed this Sep 10, 2026
@sea-snake sea-snake reopened this Sep 10, 2026
@sea-snake sea-snake closed this Sep 10, 2026
sea-snake and others added 13 commits September 10, 2026 19:01
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature:silent-reauth-redirect Design: silent re-auth over the redirect transport

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants