Skip to content

fix(auth-client): keep the redirect flow batched and its derivation origin stable - #124

Merged
sea-snake merged 3 commits into
mainfrom
feat/redirect-flow-robustness
Jul 27, 2026
Merged

sea-snake merged 3 commits into
mainfrom
feat/redirect-flow-robustness

Conversation

@sea-snake

Copy link
Copy Markdown
Contributor

Bumps @icp-sdk/signer to ^5.6.0 and fixes two UrlTransport redirect-flow issues. Both only surface with real network / keygen timing, so the e2e mocks passed while a deployed 1-click SSO + attributes flow hit both.

1. Batch the delegation with a concurrent request

Promise.all([signIn(), requestAttributes()]) should produce one redirect carrying [delegation, attributes]. It instead split into two — and the second (a lone icrc34_delegation) failed on the provider.

Cause: the transport coalesces concurrently-issued requests by flushing on a setTimeout(0) macrotask once no memoize producer is in flight. requestAttributes buffers its request after a quick nonce memoize, but signIn only buffers its delegation after openChannel and an IndexedDB key restore/generate — work the flush didn't wait for. So the flush fired with only the attributes request buffered.

Fix: signIn now (a) starts session-key acquisition before openChannel (so the memoize registers in the same tick), and (b) acquires the key inside a memoize producer, capturing it in a closure so there's no post-producer storage read. The in-flight count stays raised across the keygen, holding the flush until the delegation is buffered too.

2. Preserve the derivation origin across the redirect

The return load reconstructs AuthClient from a query-less callback URL, so options.derivationOrigin (like the identity provider) is gone. A re-issued request would drop it.

Fix: journal it — derivationOrigin: this.#urlTransport.memoize(() => options.derivationOrigin?.toString()). This relies on 5.6.0's memoize returning synchronously for a sync producer, so it's usable directly in the constructor. On replay the original value comes back from the journal. The memoize is unconditional (calling it only when set would desync the call-order journal between the fresh and return loads). The window flow is unaffected — it passes derivationOrigin directly.

Tests

tests/client/auth-client-redirect.test.ts: added a derivation-origin-survives-redirect test; updated the journal-shape assertions for the new slots. The fake transport now mirrors 5.6.0's polymorphic memoize. Full suite green (81), tsc + biome clean.

Requires

@icp-sdk/signer@5.6.0 (the polymorphic memoize + redirect-target persistence).

…rigin stable

Bumps @icp-sdk/signer to ^5.6.0 and fixes two UrlTransport redirect-flow issues
that only surface with real network/keygen timing — e2e mocks missed them, but a
deployed 1-click SSO + attributes flow hit both:

- Batch the delegation with a concurrently issued request (e.g. from
  requestAttributes) into one redirect. signIn now starts session-key
  acquisition before opening the channel and acquires the key inside a memoize
  producer, so the transport's batch flush is held across the async keygen +
  storage write instead of firing while only the faster request is buffered —
  which split the flow into two redirects, the second of which failed.

- Preserve the derivation origin across the redirect. The return load
  reconstructs the client from a query-less callback URL, so
  options.derivationOrigin is gone; journaling it via memoize (now usable in the
  constructor, since 5.6.0's memoize returns synchronously for a sync producer)
  replays the original value so a re-issued request still carries it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sea-snake
sea-snake requested a review from a team as a code owner July 24, 2026 13:07
…origin through it

`AuthClient.memoize` now mirrors the transport's shape — synchronous for a sync
producer, a promise for an async one (awaiting stays safe either way). That lets
the constructor read a memoized value synchronously, so the derivation-origin
journaling drops its `this.#urlTransport ? … : …` ternary and becomes a single
`this.memoize(...)` call — window mode is handled by memoize's own passthrough.
No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

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 updates the auth client’s redirect-mode behavior to keep concurrently-issued requests batched into a single redirect and to persist derivationOrigin across the redirect replay, aligned with the updated @icp-sdk/signer behavior.

Changes:

  • Bump @icp-sdk/signer from ^5.5.0 to ^5.6.0 (lockfile updated accordingly).
  • Adjust AuthClient redirect flow to (a) hold batching across async session-key acquisition and (b) journal derivationOrigin so it replays after returning to a query-less callback URL.
  • Update redirect-flow tests and the fake URL transport to reflect polymorphic memoize behavior and new journal slots.

Reviewed changes

Copilot reviewed 4 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/client/auth-client.ts Reworks redirect flow timing + journals derivation origin; updates memoize API shape.
tests/client/fake-url-transport.ts Updates fake transport memoize to be polymorphic (sync/async) like the real transport.
tests/client/auth-client-redirect.test.ts Adds derivation-origin-survives-redirect coverage; updates journal shape assertions.
package.json Bumps @icp-sdk/signer dependency range to ^5.6.0.
pnpm-lock.yaml Locks @icp-sdk/signer to 5.6.0 and updates snapshot entries.
Files not reviewed (1)
  • pnpm-lock.yaml: Generated file

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/client/auth-client.ts
Comment thread tests/client/fake-url-transport.ts
Comment thread src/client/auth-client.ts
…andled rejection

signIn starts session-key acquisition before awaiting openChannel. If
openChannel (or another await before it is consumed) throws first,
sessionKeyPromise is never awaited, so a later acquisition failure would
surface as an unhandled rejection while background keygen/storage work
continued. Attach a no-op rejection handler when it is started; the await still
observes and propagates a rejection when reached.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sea-snake
sea-snake merged commit e827bf4 into main Jul 27, 2026
10 checks passed
@sea-snake
sea-snake deleted the feat/redirect-flow-robustness branch July 27, 2026 09:05
sea-snake added a commit that referenced this pull request Aug 4, 2026
Closes #120.

## Root cause

The error reported in #120 — `InvalidStateError: Failed to execute
'transaction' on 'IDBDatabase': The database connection is closing` —
means the connection was already closed by the time a storage operation
ran. Nothing in this library (or in `idb`) ever calls `db.close()`, so
the closure comes from the browser: Chrome is known to force-close
IndexedDB connections out from under pages (see e.g. [Dexie
#613](dexie/Dexie.js#613), [localForage
#581](localForage/localForage#581)), and
constant full-page reload churn on Vite localhost dev servers makes this
far more likely than on a deployed asset canister. `IdbKeyVal` cached
one connection for its whole lifetime with no recovery path, so a single
force-close turned every subsequent storage call into this error.

## Changes

- **`IdbKeyVal` now survives force-closed connections**
(`src/client/db.ts`): the connection promise is dropped via `openDB`'s
`terminated` hook when the browser reports an abnormal close, and —
since Chrome does not always fire that event — an `InvalidStateError`
thrown by an operation also drops the dead connection, reopens, and
retries the operation once. A failed open is never cached.
- **`IdbStorage._db` memoizes the in-flight creation**
(`src/client/storage.ts`): concurrent first accesses previously each
started their own `IdbKeyVal.create()`, opening duplicate connections
that leaked (the fix suggested in #120). This became internally
reachable with the 8.0.0 redirect flow, which touches storage
concurrently with session hydration.
- **`signIn()`/`signOut()` wait for session hydration**
(`src/client/auth-client.ts`): both now await the memoized `#init()`
before touching storage, so their writes/deletes cannot interleave with
the constructor's restore reads. In `signIn()` the await is placed after
`openChannel()` — which must stay the first await so a popup opens in
the same tick as the user's click — and after the eager session-key
acquisition start, preserving the same-tick `transport.memoize` batching
hold from #124.

## Tests

- `tests/client/db-recovery.test.ts`: closes the underlying connection
out from under `IdbKeyVal` (via a pass-through `openDB` wrapper) and
verifies reads/writes transparently reopen and succeed. This reproduces
the retry path; the `terminated` event path is not reproducible under
`fake-indexeddb`, which never force-closes.
- `tests/client/storage.test.ts`: verifies concurrent first accesses
share a single `IdbKeyVal.create()` call, and that a failed open is not
cached.

`pnpm exec vitest run` (92 tests), `pnpm codestyle:check`, and `pnpm
build` all pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

3 participants