From a6ac15daabfd4843e305b26c381bcf5cf459dc2b Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Thu, 11 Jun 2026 07:31:44 +0200 Subject: [PATCH] fix(idp): passkey login degrades on WebView/insecure contexts instead of crashing (#556) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stale Android System WebViews (de-Googled phones, #46/#556 arc) lack crypto.randomUUID and a secure context. Two problems, two fixes: 1. The login page's inline JS called `crypto.randomUUID()` to build a `visitorId` — missing on those WebViews, so 'Sign in with Passkey' threw 'crypto.randomUUID is not a function'. Unlike jspod#65 (where the call lives inside the imported solid-oidc library, forcing a polyfill), JSS OWNS the call site: authenticationOptions already mints the challengeKey via Node crypto (always available) and returns it, and the client already echoes options.challengeKey on verify. The browser call was pure redundancy — removed; the options request now sends {}. 2. Both passkey ceremonies would otherwise fail deep with a cryptic WebAuthn error on a WebView that can't do WebAuthn at all. Added an up-front secure-context + PublicKeyCredential + credentials.get/ create guard to loginWithPasskey and registerPasskey that steers the user to the password form (login) or 'Skip for now' (prompt) — both already on the page. Goes beyond jspod's polyfill, which could only stop the randomUUID crash, not the WebAuthn-unavailable one. Tests (test/passkey-webauthn-degrade.test.js, 5): no randomUUID() call survives in the served JS, empty options body is sent, both pages carry the guard, every inline script parses (escaping regression guard), and — the functional proof — the options endpoint returns a usable challengeKey for an EMPTY body, so dropping the client call is complete, not a regression. First direct passkey-endpoint coverage (noted missing when #78 closed). 958/958 passing. Closes #556. --- src/idp/views.js | 27 +++++- test/passkey-webauthn-degrade.test.js | 118 ++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 test/passkey-webauthn-degrade.test.js diff --git a/src/idp/views.js b/src/idp/views.js index 828ba97e..b49879fe 100644 --- a/src/idp/views.js +++ b/src/idp/views.js @@ -265,12 +265,26 @@ export function loginPage(uid, clientId, error = null, passkeyEnabled = true, sc var INTERACTION_UID = '${safeUid}'; async function loginWithPasskey() { + // Passkeys require WebAuthn + a secure context. Stale Android + // System WebViews (common on de-Googled phones, #556) and plain + // http origins lack it. Detect up front and steer the user to the + // password form right below instead of failing deep in the + // ceremony with a cryptic error. + if (!window.isSecureContext || !window.PublicKeyCredential || + !(navigator.credentials && navigator.credentials.get)) { + alert('Passkeys aren\\'t available in this browser. Please sign in with your username and password below.'); + return; + } try { - // Get authentication options + // Get authentication options. No client-side correlation id is + // sent — the server mints the challengeKey (always-available + // Node crypto) and returns it; we echo options.challengeKey on + // verify. This deliberately avoids a browser crypto.randomUUID + // call that old WebViews lack (#556). const optionsRes = await fetch('/idp/passkey/login/options', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ visitorId: crypto.randomUUID() }) + body: JSON.stringify({}) }); const options = await optionsRes.json(); if (options.error) { @@ -1032,6 +1046,15 @@ export function passkeyPromptPage(uid, accountId) { var PASSKEY_ICON = '${passkeyIconEscaped}'; async function registerPasskey() { + // Same WebAuthn / secure-context gate as the login page (#556): + // on a stale WebView the create() ceremony would fail cryptically. + // Steer to "Skip for now" instead of disabling the button on a + // path that can't succeed. + if (!window.isSecureContext || !window.PublicKeyCredential || + !(navigator.credentials && navigator.credentials.create)) { + alert('Passkeys aren\\'t available in this browser. Tap "Skip for now" to continue.'); + return; + } const btn = document.getElementById('addBtn'); btn.disabled = true; btn.textContent = 'Setting up...'; diff --git a/test/passkey-webauthn-degrade.test.js b/test/passkey-webauthn-degrade.test.js new file mode 100644 index 00000000..63f2471e --- /dev/null +++ b/test/passkey-webauthn-degrade.test.js @@ -0,0 +1,118 @@ +/** + * Passkey login degrades on WebView/insecure contexts (#556). + * + * Two problems on stale Android System WebViews (de-Googled phones): + * 1. The login page's inline JS called `crypto.randomUUID()` to make a + * `visitorId` — missing on old WebViews (Chromium <92 / non-secure), + * so "Sign in with Passkey" threw `crypto.randomUUID is not a + * function`. Unlike jspod (#65), where the call lives inside the + * imported solid-oidc library, JSS OWNS the call site: the server + * already mints the challengeKey (Node crypto, always available) + * and returns it, so the browser call was redundant. Removed. + * 2. Both passkey ceremonies (login get(), prompt create()) would + * fail deep with a cryptic WebAuthn error on a WebView that can't + * do WebAuthn at all. Now guarded with a secure-context / + * PublicKeyCredential check that steers the user to the password + * form (login) or "Skip for now" (prompt). + */ + +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert'; +import { loginPage, passkeyPromptPage } from '../src/idp/views.js'; +import { createServer } from '../src/server.js'; +import { createServer as createNetServer } from 'net'; +import fs from 'fs-extra'; + +const TEST_HOST = 'localhost'; +const DATA_DIR = './test-data-passkey-degrade'; + +function getAvailablePort() { + return new Promise((resolve, reject) => { + const srv = createNetServer(); + srv.on('error', reject); + srv.listen(0, TEST_HOST, () => { + const port = srv.address().port; + srv.close(() => resolve(port)); + }); + }); +} + +function inlineScripts(html) { + return [...html.matchAll(/