Skip to content
Source
Sign inSign up

[Bug]: Browser passkey aborts replace an explicit null reason with AbortError #35956

Send to my agent
Closedcoygeek(GitHub profile, opens in a new tab) opened this issue 1 comment

Summary

When a page cancels a browser passkey request with an explicit null reason, the bridge rejects with a new AbortError instead of preserving the supplied reason. This affects both credential creation and sign-in, including signals cancelled before the call and during the operation. A dependency-free test importing the production bridge reproduced all four cases while non-null custom-reason controls passed.

Before submitting

  • Related issues: listed under Additional context; open and closed issue and PR searches found no matching null-reason report.
  • Removed credentials, tokens, private paths, and sensitive data.

Area

apps/desktop

Observed behavior

Calling AbortController.abort(null) leaves signal.reason === null, but both wrapped credential methods reject with a DOMException named AbortError. A non-null object reason is returned unchanged. The already-aborted cases do not call the fake native backend, so the error substitution occurs in the renderer bridge before any native result.

Reproduction

  1. Check out the commit below and save the script as repro-abort.mjs outside the checkout.
  2. Run node repro-abort.mjs /path/to/t3code with Node supporting direct TypeScript imports.
  3. Observe the custom-reason controls passing and null-reason checks failing for both create() and get(), before and during operation.

The run was deterministic: all four tested null-reason combinations failed in one execution. The script uses the production bridge with minimal WebIDL constructor shims and a pending fake IPC promise. It creates disposable environment directories, removes them on exit, changes no checkout files, and opens no native sheet. It needs only Node; exit 1 means the defect was reproduced, 0 means the expected behavior holds, and 2 means the setup or a control failed.

Reproduction script: repro-abort.mjs
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { resolve, join } from 'node:path';
import { pathToFileURL } from 'node:url';
console.log(`Node ${process.version}; ${process.platform} ${process.arch}`);
const scratch = mkdtempSync(join(tmpdir(), 'passkey-abort-'));
for (const name of ['HOME','USERPROFILE','APPDATA','LOCALAPPDATA','XDG_CONFIG_HOME','XDG_DATA_HOME','XDG_CACHE_HOME','XDG_STATE_HOME','T3CODE_HOME','TMPDIR']) {
  process.env[name] = join(scratch, name); mkdirSync(process.env[name]);
}
let status = 2;
try {
  if (!process.argv[2]) throw new Error('Pass the checkout path');
  const { installPasskeyBridge } = await import(pathToFileURL(resolve(process.argv[2], 'apps/desktop/src/preview/PasskeyBridge.ts')));
  globalThis.CredentialsContainer = class { async create() { return null; } async get() { return null; } };
  globalThis.PublicKeyCredential = class {};
  globalThis.AuthenticatorAttestationResponse = class {};
  globalThis.AuthenticatorAssertionResponse = class {};
  let calls = 0;
  installPasskeyBridge(async () => { calls++; return new Promise(() => {}); });
  const credentials = new CredentialsContainer();
  const publicKey = { challenge: new Uint8Array([1]), rp: { name: 'Example' }, user: { id: new Uint8Array([2]), name: 'user', displayName: 'User' }, pubKeyCredParams: [{ type: 'public-key', alg: -7 }] };
  const abortedWith = async (method, reason, before) => {
    const controller = new AbortController();
    if (before) controller.abort(reason);
    const promise = credentials[method]({ publicKey, signal: controller.signal }).then(() => ({ resolved: true }), error => ({ error }));
    if (!before) controller.abort(reason);
    return promise;
  };
  const marker = { cancelled: true };
  let controls = true, failures = 0;
  for (const method of ['create','get']) for (const before of [true,false]) {
    const control = await abortedWith(method, marker, before);
    controls &&= control.error === marker;
    const beforeCalls = calls;
    const result = await abortedWith(method, null, before);
    const pass = !result.resolved && result.error === null;
    console.log(`${method} ${before ? 'already-aborted' : 'in-flight'}: custom reason preserved=${control.error === marker}; null preserved=${pass}; actual=${result.error?.name ?? String(result.error)}`);
    if (before && calls !== beforeCalls) throw new Error('Already-aborted request invoked native backend');
    if (!pass) failures++;
  }
  if (!controls) throw new Error('Custom-reason control failed');
  status = failures ? 1 : 0;
} catch (error) { console.error(error.message); }
finally { rmSync(scratch, { recursive: true, force: true }); }
console.log(status === 1 ? 'RESULT: defect reproduced' : status === 0 ? 'RESULT: expected behavior' : 'RESULT: inconclusive');
process.exitCode = status;

Expected behavior

The promise must reject with the signal's abort reason, including an explicit null. WebAuthn Level 3 §5.1.3 and §5.1.4.1 require throwing the signal's reason both before authenticator sessions and when a signal aborts during them; §5.6 requires immediate rejection. The DOM signal-abort algorithm substitutes AbortError only for an undefined reason, so null is a valid preserved value.

Impact and scope

Impact: P2 - The public credential API reports the wrong cancellation result for a valid supplied reason, breaking callers that distinguish cancellation by reason identity or value.

This is bounded to the custom browser passkey bridge and explicit null reasons. A tested workaround for page authors is a non-null marker object; its identity survives both timing cases. The script verifies bridge behavior on synthetic requests, not a signed-app authentication. The source enables this bridge only for packaged macOS builds whose metadata enables browser passkeys; currently shipped capability availability is tracked separately in the existing report below. No effect on Chromium-native requests is claimed.

Version

T3 Code commit 59e64eef881632a23f89c4138bcf37f23d92bf93. Repository pins include Electron 44.10.1 and electron-webauthn 1.3.1; neither native runtime was loaded by this reproduction.

Runtime and platform

Observed with Node v26.12.1, macOS darwin, arm64. The bridge was imported directly using Node's TypeScript support. NOT_ENOUGH_INFO: a signed-app run with the managed browser-passkey entitlement was not performed; that would establish end-to-end native scope.

Interface

API: navigator.credentials.create() and navigator.credentials.get() installed by the browser guest preload.

Earliest failure stage

Cleanup or lifecycle

Relevant output or evidence

Node v26.12.1; darwin arm64
create already-aborted: custom reason preserved=true; null preserved=false; actual=AbortError
create in-flight: custom reason preserved=true; null preserved=false; actual=AbortError
get already-aborted: custom reason preserved=true; null preserved=false; actual=AbortError
get in-flight: custom reason preserved=true; null preserved=false; actual=AbortError
RESULT: defect reproduced

The nullish fallback in apps/desktop/src/preview/PasskeyBridge.ts#L197-L198 replaces a legitimate null reason. Both abort paths call it in apps/desktop/src/preview/PasskeyBridge.ts#L205-L230. The supported guest preload installs this bridge in apps/desktop/src/preview-pick-preload.ts#L9-L13.

Additional context

  • https://github.com/pingdotgg/t3code/pull/16952 — introduced this bridge and documents native-sheet cancellation as a known gap; this report concerns the distinct promise rejection value and requires no native cancellation API to reproduce.
  • https://github.com/pingdotgg/t3code/issues/34661 — tracks signed-release capability enablement; this report tests the installed bridge implementation independently of that release prerequisite.
  1. tanstack-source added labelsbug

  2. juliusmarmingecommented

    I tried to reproduce this on the current build and could not.

    On origin/main 0c4b1d8f27, extended the existing test 'rejects as soon as the page aborts, without waiting on the system sheet' in apps/desktop/src/preview/PasskeyBridge.test.ts: credentials.get() with a pending fake IPC then controller.abort(null), and credentials.create() with AbortSignal.abort(null). vp test run src/preview/PasskeyBridge.test.ts (from apps/desktop) fails: 'expected DOMException{AbortError} to equal null'. The already-aborted create() case also rejects with AbortError. Changing abortReason to fall back only when signal.reason is undefined makes all 3 tests pass; that fix was checked locally and not committed. A run in the real app was not possible: the bridge is enabled only in signed, packaged macOS builds with browser passkeys on, which a Linux VM cannot produce. The test imports the production bridge directly.

    Closing as not planned. If it still happens for you, reply with the exact steps and the version you are on and it will be looked at again.

  3. juliusmarminge closed this

Sign in to join this conversation.