{"record":{"id":"8a5f5bc4b4b33d49","repo":"Wei-Shaw/sub2api","slug":"passkey-sign-in-was-cancelled","errorCode":null,"errorMessage":"Passkey sign-in was cancelled","messagePattern":"Passkey sign-in was cancelled","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"warning","filePath":"frontend/src/api/passkey.ts","lineNumber":116,"sourceCode":"    response: {\n      authenticatorData: bufferToBase64URL(response.authenticatorData),\n      clientDataJSON: bufferToBase64URL(response.clientDataJSON),\n      signature: bufferToBase64URL(response.signature),\n      userHandle: bufferToBase64URL(response.userHandle)\n    }\n  }\n}\n\nasync function login(proof?: ActionCaptchaRequestProof): Promise<AuthResponse> {\n  requirePasskeySupport()\n  const { data: begin } = proof\n    ? await apiClient.post<CeremonyOptionsResponse>('/auth/passkey/login/begin', proof)\n    : await apiClient.post<CeremonyOptionsResponse>('/auth/passkey/login/begin')\n  const credential = await navigator.credentials.get({\n    publicKey: requestOptionsFromJSON(begin.options.publicKey)\n  })\n  if (!(credential instanceof PublicKeyCredential)) {\n    throw new Error('Passkey sign-in was cancelled')\n  }\n  const { data } = await apiClient.post<AuthResponse>('/auth/passkey/login/finish', {\n    session_token: begin.session_token,\n    credential: serializeAssertionCredential(credential)\n  })\n  return data\n}\n\nasync function register(name: string, password: string): Promise<PasskeyCredentialSummary> {\n  requirePasskeySupport()\n  const { data: begin } = await apiClient.post<CeremonyOptionsResponse>(\n    '/user/passkeys/register/begin',\n    { password }\n  )\n  const credential = await navigator.credentials.create({\n    publicKey: creationOptionsFromJSON(begin.options.publicKey)\n  })\n  if (!(credential instanceof PublicKeyCredential)) {","sourceCodeStart":98,"sourceCodeEnd":134,"githubUrl":"https://github.com/Wei-Shaw/sub2api/blob/073e92d17178a1ccdb0a27017f572f10c9c7ab62/frontend/src/api/passkey.ts#L98-L134","documentation":"In frontend/src/api/passkey.ts:116, after navigator.credentials.get({publicKey}) resolves, the code checks the result with `credential instanceof PublicKeyCredential`. A null return (user dismissed the browser's authenticator prompt in some browsers) or an unexpected object type fails the check and throws 'Passkey sign-in was cancelled'. Note that most user cancellations actually surface as a thrown NotAllowedError from credentials.get() itself; this branch catches the rarer null-return case.","triggerScenarios":"navigator.credentials.get() for a passkey login resolves with null — happens in some browsers/WebViews when the account picker is dismissed, when an RSA/Android-keystore response returns a plain Credential, or when a cross-platform mismatch makes the browser return a non-PublicKeyCredential object. Also triggered in test environments where credentials.get is mocked to return null or {}.","commonSituations":"Users closing the WebAuthn sheet on Android Chrome; Selenium/Playwright tests with fake credential objects that aren't PublicKeyCredential instances; Safari edge cases where the transaction is cancelled after the platform authenticator UI appears.","solutions":["Catch this error alongside NotAllowedError in the sign-in handler and show a neutral 'cancelled' message with a retry affordance.","Verify the request options from requestOptionsFromJSON(begin.options.publicKey) include challenge, rpId, and allowCredentials correctly — malformed options can make the browser bail.","In automated tests, mock credentials.get to return a real PublicKeyCredential-shaped object and stub the instanceof check, or refactor to duck-typing.","Consider checking `if (!credential)` instead of instanceof so null and wrong-type are handled distinctly."],"exampleFix":"// before\nconst credential = await navigator.credentials.get({ publicKey: requestOptionsFromJSON(begin.options.publicKey) })\nif (!(credential instanceof PublicKeyCredential)) {\n  throw new Error('Passkey sign-in was cancelled')\n}\n\n// after\nconst credential = await navigator.credentials.get({ publicKey: requestOptionsFromJSON(begin.options.publicKey) })\nif (!credential || !(credential instanceof PublicKeyCredential)) {\n  const err = new Error('Passkey sign-in was cancelled')\n  err.name = 'PasskeyCancelled'\n  throw err\n}\n// caller:\ntry { await login() } catch (e) { if (e.name === 'NotAllowedError' || e.name === 'PasskeyCancelled') return retryUi() ; throw e }","handlingStrategy":"try-catch","validationCode":null,"typeGuard":"function isPublicKeyCredential(c: Credential | null): c is PublicKeyCredential {\n  return !!c && c instanceof PublicKeyCredential;\n}","tryCatchPattern":"try {\n  await passkeyLogin();\n} catch (e) {\n  if (e.name === 'NotAllowedError' || e.message === 'Passkey sign-in was cancelled') {\n    showInfo('Sign-in cancelled — try again'); return; // user-driven, not a bug\n  }\n  throw e;\n}","preventionTips":["Treat cancellation (null credential and NotAllowedError) as the same UX path with a retry affordance","Validate begin.options.publicKey contains challenge/rpId/allowCredentials before calling credentials.get","Use virtual authenticators in E2E tests instead of mocking credentials.get with null"],"tags":["webauthn","passkeys","user-cancellation","frontend"],"backgroundTag":null,"analyzedSha":"073e92d17178a1ccdb0a27017f572f10c9c7ab62","analyzedAt":"2026-08-15T14:33:00.750Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}