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(/