Skip to content

Comments

Add WebAuthn/Passkey authentication support#55

Merged
devondragon merged 11 commits intomainfrom
webauthn-test
Feb 22, 2026
Merged

Add WebAuthn/Passkey authentication support#55
devondragon merged 11 commits intomainfrom
webauthn-test

Conversation

@devondragon
Copy link
Owner

Summary

Adds WebAuthn/Passkey support to the demo application, allowing users to register passkeys and use them for passwordless login.

  • WebAuthn passkey registration from user profile page with label management
  • Passwordless passkey login from the login page
  • Passkey management UI (list, rename via Bootstrap modal, delete with confirmation)
  • Production-ready WebAuthn configuration with environment variable overrides
  • Client-side security hardening (event delegation, safe error handling, input validation)
  • Updated framework dependency to stable 4.2.0 release with WebAuthn support
  • README documentation for the passkey feature

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

  • Register a new passkey from the user profile page
  • Verify passkey appears in the credential list with correct label and dates
  • Rename a passkey via the modal dialog
  • Delete a passkey with confirmation
  • Log out and sign back in using "Sign in with Passkey"
  • Verify error states show user-friendly messages (not raw technical errors)
  • Test with WebAuthn unsupported browser — passkey sections should be hidden
  • Verify production profile uses env vars for rpId/allowedOrigins

Edamijueda and others added 9 commits February 15, 2026 03:49
- 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.
Copilot AI review requested due to automatic review settings February 21, 2026 23:46
@devondragon devondragon mentioned this pull request Feb 21, 2026
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 156 to 157
const data = await response.json();
throw new Error(data.message || 'Failed to rename passkey');
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);

Copilot uses AI. Check for mistakes.
Comment on lines 202 to 203
const data = await response.json();
throw new Error(data.message || 'Failed to delete passkey');
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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);

Copilot uses AI. Check for mistakes.
Comment on lines 14 to 20
// 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";

Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +48 to +69
<!-- 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>
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +57 to +77
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
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines 14 to 22
// 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;
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines 28 to 32
} 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';
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
- 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
@devondragon devondragon merged commit fdc4615 into main Feb 22, 2026
5 of 6 checks passed
@devondragon devondragon deleted the webauthn-test branch February 22, 2026 01:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants