Add WebAuthn/Passkey authentication support#55
Conversation
- Validate 64-char max on rename before sending to backend - Truncate long labels with ellipsis instead of breaking layout - Prevent buttons from being pushed off-screen by long names
OAuth tokens expire periodically, requiring manual regeneration. API keys are stable and don't expire.
Address code review findings across the WebAuthn/Passkey implementation: - Add WebAuthn production config override in application-prd.yml with env-var-driven rpId, rpName, and allowedOrigins - Cache Bootstrap Modal instance instead of creating per renamePasskey call - Replace raw error.message with user-friendly messages in all user-facing error handlers (login.js, webauthn-manage.js) - Improve error response parsing in authenticate/register to try JSON first with text fallback - Add safe formatDate() helper to handle null/invalid date values - Replace global window.renamePasskey/deletePasskey with event delegation on the credential list container using data attributes
Replace 4.1.1-SNAPSHOT with the stable 4.2.0 release which includes the WebAuthn/Passkey support needed by this branch.
There was a problem hiding this comment.
Pull request overview
Adds WebAuthn/Passkey support to the demo app, enabling passkey registration/management from the profile page and passwordless login from the login page, plus related configuration and documentation updates.
Changes:
- Add passkey registration + management UI (list/rename/delete) and supporting JS modules.
- Add passkey login button + WebAuthn authentication flow on the login page.
- Add WebAuthn configuration (dev + prod overrides), dependency updates, and README docs.
Reviewed changes
Copilot reviewed 12 out of 14 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| src/main/resources/templates/user/update-user.html | Adds passkey management UI section + rename modal; loads passkey management JS module. |
| src/main/resources/templates/user/login.html | Adds passkey login button section (initially hidden). |
| src/main/resources/static/js/user/webauthn-utils.js | Adds CSRF helpers, base64url conversions, WebAuthn support checks, and HTML escaping helper. |
| src/main/resources/static/js/user/webauthn-register.js | Implements authenticated passkey registration flow (options → navigator.credentials.create → finish). |
| src/main/resources/static/js/user/webauthn-manage.js | Implements credential list rendering + rename/delete actions + registration wiring on profile page. |
| src/main/resources/static/js/user/webauthn-authenticate.js | Implements passkey authentication flow (options → navigator.credentials.get → finish) returning redirect URL. |
| src/main/resources/static/js/user/login.js | Shows passkey login button when supported and triggers WebAuthn authentication. |
| src/main/resources/application.yml | Adds user.webauthn defaults and exposes WebAuthn login endpoints as unprotected URIs. |
| src/main/resources/application-prd.yml | Adds production env-var overrides for RP ID/name/origins. |
| build.gradle | Bumps ds-spring-user-framework to 4.2.0 and adds spring-security-webauthn dependency. |
| README.md | Documents passkey configuration, endpoints, and troubleshooting. |
| .gitignore | Ignores curl cookie file. |
| .github/workflows/claude.yml | Updates action secret input to anthropic_api_key. |
| .github/workflows/claude-code-review.yml | Updates action secret input to anthropic_api_key. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const data = await response.json(); | ||
| throw new Error(data.message || 'Failed to rename passkey'); |
There was a problem hiding this comment.
When the rename request fails, this assumes the response body is JSON (await response.json()). If the backend returns an empty body or non-JSON (common for 500/403), this will throw a parsing error and the user will see an unrelated message. Handle the error body defensively (try JSON, then fallback to text) similar to the pattern used in webauthn-register.js/webauthn-authenticate.js.
| const data = await response.json(); | |
| throw new Error(data.message || 'Failed to rename passkey'); | |
| let errorMessage = 'Failed to rename passkey'; | |
| try { | |
| const bodyText = await response.text(); | |
| if (bodyText) { | |
| try { | |
| const data = JSON.parse(bodyText); | |
| if (data && typeof data === 'object' && data.message) { | |
| errorMessage = data.message; | |
| } | |
| } catch (_) { | |
| // Not JSON; use raw text as the message. | |
| errorMessage = bodyText; | |
| } | |
| } | |
| } catch (_) { | |
| // Ignore body parsing errors and fall back to default message. | |
| } | |
| throw new Error(errorMessage); |
| const data = await response.json(); | ||
| throw new Error(data.message || 'Failed to delete passkey'); |
There was a problem hiding this comment.
When the delete request fails, this assumes the response body is JSON (await response.json()). If the backend returns non-JSON or an empty body, the JSON parse will throw and you’ll lose the real failure reason. Parse defensively (try JSON then fallback to text), or at least guard the JSON parse with try/catch.
| const data = await response.json(); | |
| throw new Error(data.message || 'Failed to delete passkey'); | |
| let errorMessage = 'Failed to delete passkey'; | |
| try { | |
| const contentType = response.headers && response.headers.get | |
| ? (response.headers.get('content-type') || '') | |
| : ''; | |
| if (contentType.includes('application/json')) { | |
| const data = await response.json(); | |
| if (data && typeof data.message === 'string' && data.message.trim()) { | |
| errorMessage = data.message; | |
| } | |
| } else { | |
| const text = await response.text(); | |
| if (text && text.trim()) { | |
| errorMessage = text; | |
| } | |
| } | |
| } catch (parseError) { | |
| console.warn('Failed to parse error response for deletePasskey:', parseError); | |
| } | |
| throw new Error(errorMessage); |
| // Show passkey login button if WebAuthn is supported | ||
| const passkeySection = document.getElementById("passkey-login-section"); | ||
| const passkeyBtn = document.getElementById("passkeyLoginBtn"); | ||
|
|
||
| if (passkeySection && isWebAuthnSupported()) { | ||
| passkeySection.style.display = "block"; | ||
|
|
There was a problem hiding this comment.
The repo has Playwright coverage for login flows, but the new passkey login UI/behavior isn’t covered (button visibility when WebAuthn is supported/unsupported, error handling, redirect on success). Consider adding Playwright tests that (1) verify the passkey button is hidden when PublicKeyCredential is unavailable (via page.addInitScript), and (2) verify it becomes visible when supported (even if the full WebAuthn ceremony is mocked).
There was a problem hiding this comment.
This project uses Selenide (not Playwright) for UI tests. WebAuthn/passkey UI tests will be addressed in a follow-up PR, as they require virtual authenticator setup that's out of scope for this initial implementation.
| <!-- Passkey Management Section --> | ||
| <div id="passkey-section" class="card shadow-sm mt-4"> | ||
| <div class="card-header d-flex justify-content-between align-items-center"> | ||
| <h5 class="mb-0"><i class="bi bi-key me-2"></i>Passkeys</h5> | ||
| </div> | ||
| <div class="card-body"> | ||
| <div id="passkeyMessage" class="alert d-none text-center"></div> | ||
|
|
||
| <!-- Register New Passkey --> | ||
| <div class="input-group mb-3"> | ||
| <input type="text" id="passkeyLabel" class="form-control" placeholder="Passkey name (e.g. MacBook Pro)" maxlength="64"> | ||
| <button id="registerPasskeyBtn" type="button" class="btn btn-primary"> | ||
| <i class="bi bi-plus-circle me-1"></i> Add Passkey | ||
| </button> | ||
| </div> | ||
|
|
||
| <!-- Passkey List --> | ||
| <div id="passkeys-list"> | ||
| <p class="text-muted">Loading passkeys...</p> | ||
| </div> | ||
| </div> | ||
| </div> |
There was a problem hiding this comment.
The repo already has Playwright coverage for profile/update-user flows, but the new passkey management section isn’t covered (rendering the section, handling unsupported browsers, and basic list/rename/delete UI states). Consider adding Playwright tests that at least verify the section is replaced with the “browser does not support passkeys” message when WebAuthn is unavailable (via page.addInitScript), and that the management UI renders when supported.
There was a problem hiding this comment.
This project uses Selenide (not Playwright) for UI tests. WebAuthn/passkey UI tests will be addressed in a follow-up PR, as they require virtual authenticator setup that's out of scope for this initial implementation.
| container.innerHTML = credentials.map(cred => ` | ||
| <div class="card mb-2" data-id="${escapeHtml(cred.id)}"> | ||
| <div class="card-body d-flex justify-content-between align-items-center py-2"> | ||
| <div class="me-3" style="min-width: 0;"> | ||
| <strong class="d-inline-block text-truncate" style="max-width: 100%;">${escapeHtml(cred.label || 'Unnamed Passkey')}</strong> | ||
| <br> | ||
| <small class="text-muted"> | ||
| Created: ${formatDate(cred.created)} | ||
| ${cred.lastUsed ? ' | Last used: ' + formatDate(cred.lastUsed) : ' | Never used'} | ||
| </small> | ||
| <br> | ||
| ${cred.backupEligible | ||
| ? '<span class="badge bg-success">Synced</span>' | ||
| : '<span class="badge bg-warning text-dark">Device-bound</span>'} | ||
| </div> | ||
| <div class="flex-shrink-0"> | ||
| <button class="btn btn-sm btn-outline-secondary me-1" data-action="rename" data-id="${escapeHtml(cred.id)}" data-label="${escapeHtml(cred.label || '')}"> | ||
| <i class="bi bi-pencil"></i> Rename | ||
| </button> | ||
| <button class="btn btn-sm btn-outline-danger" data-action="delete" data-id="${escapeHtml(cred.id)}"> | ||
| <i class="bi bi-trash"></i> Delete |
There was a problem hiding this comment.
escapeHtml() is used to sanitize values interpolated into HTML attributes (e.g., data-id="...", data-label="..."), but it does not escape quotes. A credential id/label containing " or ' can break out of the attribute and enable HTML/JS injection. Prefer building these cards with DOM APIs (createElement, setAttribute, textContent) or add an attribute-safe escaping function that encodes at least " and ' in addition to <>& before interpolating into attributes.
| // Show passkey login button if WebAuthn is supported | ||
| const passkeySection = document.getElementById("passkey-login-section"); | ||
| const passkeyBtn = document.getElementById("passkeyLoginBtn"); | ||
|
|
||
| if (passkeySection && isWebAuthnSupported()) { | ||
| passkeySection.style.display = "block"; | ||
|
|
||
| passkeyBtn.addEventListener("click", async () => { | ||
| passkeyBtn.disabled = true; |
There was a problem hiding this comment.
passkeyBtn is used without checking it exists. If the template changes or the button is missing, passkeyBtn.addEventListener(...) will throw and prevent the rest of the login page JS from running. Add a null check for passkeyBtn in the same condition as passkeySection (or query it from within passkeySection).
| } catch (error) { | ||
| console.error("Passkey authentication failed:", error); | ||
| showMessage(null, "Passkey authentication failed. Please try again.", "alert-danger"); | ||
| passkeyBtn.disabled = false; | ||
| passkeyBtn.innerHTML = '<i class="bi bi-key me-2"></i> Sign in with Passkey'; |
There was a problem hiding this comment.
showMessage(null, ...) is a no-op (shared.js returns early when the container is falsy), so passkey authentication failures won’t display any user-visible error. Add an error container to the login page (e.g., an alert div) and pass it to showMessage, or reuse the existing #loginError element when present.
- escapeHtml: encode " and ' after DOM-based escape to prevent attribute breakout in data-* attributes - webauthn-manage: use JSON-with-text-fallback for rename and delete error responses to avoid throwing on non-JSON bodies (500/403) - login: add passkeyBtn null check to prevent TypeError if element is missing from template - login: add #passkeyError alert div and wire showMessage to it so passkey auth failures are visible to the user instead of silently swallowed
# Conflicts: # .github/workflows/claude-code-review.yml
Summary
Adds WebAuthn/Passkey support to the demo application, allowing users to register passkeys and use them for passwordless login.
Based on the initial contribution by @Edamijueda in #51, with substantial rework including UI improvements, security hardening, error handling, production configuration, and documentation.
Co-authored-by: Oluwatobi tobbyzomo221@gmail.com
Test plan