From c2c476f59b1fb1b7ee061f38806ca50d593dddb4 Mon Sep 17 00:00:00 2001 From: Oluwatobi Date: Sun, 15 Feb 2026 03:04:51 +0100 Subject: [PATCH 01/10] Add WebAuthn passkey registration and login to demo app --- build.gradle | 5 +- docker-compose.yml | 8 + src/main/resources/application.yml | 10 +- src/main/resources/static/js/user/login.js | 25 +++ .../static/js/user/webauthn-authenticate.js | 87 ++++++++ .../static/js/user/webauthn-manage.js | 189 ++++++++++++++++++ .../static/js/user/webauthn-register.js | 86 ++++++++ .../static/js/user/webauthn-utils.js | 75 +++++++ src/main/resources/templates/user/login.html | 7 + .../resources/templates/user/update-user.html | 24 +++ 10 files changed, 513 insertions(+), 3 deletions(-) create mode 100644 src/main/resources/static/js/user/webauthn-authenticate.js create mode 100644 src/main/resources/static/js/user/webauthn-manage.js create mode 100644 src/main/resources/static/js/user/webauthn-register.js create mode 100644 src/main/resources/static/js/user/webauthn-utils.js diff --git a/build.gradle b/build.gradle index 3ae9aec..5bcafd3 100644 --- a/build.gradle +++ b/build.gradle @@ -39,7 +39,10 @@ repositories { dependencies { // DigitalSanctuary Spring User Framework - implementation 'com.digitalsanctuary:ds-spring-user-framework:4.0.3' + implementation 'com.digitalsanctuary:ds-spring-user-framework:4.1.1-SNAPSHOT' + + // WebAuthn support (Passkey authentication) + implementation 'org.springframework.security:spring-security-webauthn' // Spring Boot starters implementation 'org.springframework.boot:spring-boot-starter-actuator' diff --git a/docker-compose.yml b/docker-compose.yml index 928bb7d..92d534a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,14 @@ services: timeout: 5s retries: 3 + myapp-db-adminer: + image: adminer + container_name: springuser-db-adminer + ports: + - "8081:8080" + depends_on: + - myapp-db + mailserver: image: docker.io/mailserver/docker-mailserver:latest container_name: springuser-mail diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index de57286..5da9ea8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -102,9 +102,15 @@ springdoc: user: actuallyDeleteAccount: false # If true, users can delete their own accounts. If false, accounts are disabled instead of deleted. registration: - sendVerificationEmail: true # If true, a verification email will be sent to the user after registration. If false, the user will be automatically verified. + sendVerificationEmail: false # If true, a verification email will be sent to the user after registration. If false, the user will be automatically verified. googleEnabled: false # If true, Google OAuth2 will be enabled for registration. facebookEnabled: false # If true, Facebook OAuth2 will be enabled for registration. + webauthn: + enabled: true + rpId: localhost + rpName: Spring User Framework Demo + allowedOrigins: http://localhost:8080 + audit: logFilePath: /opt/app/logs/user-audit.log # The path to the audit log file. flushOnWrite: false # If true, the audit log will be flushed to disk after every write (less performant). If false, the audit log will be flushed to disk every 10 seconds (more performant). @@ -117,7 +123,7 @@ user: bcryptStrength: 12 # The bcrypt strength to use for password hashing. The higher the number, the longer it takes to hash the password. The default is 12. The minimum is 4. The maximum is 31. testHashTime: true # If true, the test hash time will be logged to the console on startup. This is useful for determining the optimal bcryptStrength value. defaultAction: deny # The default action for all requests. This can be either deny or allow. - unprotectedURIs: /,/index.html,/favicon.ico,/apple-touch-icon-precomposed.png,/css/*,/js/*,/js/user/*,/js/event/*,/js/utils/*,/img/**,/user/registration,/user/resendRegistrationToken,/user/resetPassword,/user/registrationConfirm,/user/changePassword,/user/savePassword,/oauth2/authorization/*,/login,/user/login,/user/login.html,/swagger-ui.html,/swagger-ui/**,/v3/api-docs/**,/event/,/event/list.html,/event/**,/about.html,/error,/error.html # A comma delimited list of URIs that should not be protected by Spring Security if the defaultAction is deny. + unprotectedURIs: /,/index.html,/favicon.ico,/apple-touch-icon-precomposed.png,/css/*,/js/*,/js/user/*,/js/event/*,/js/utils/*,/img/**,/user/registration,/user/resendRegistrationToken,/user/resetPassword,/user/registrationConfirm,/user/changePassword,/user/savePassword,/oauth2/authorization/*,/login,/user/login,/user/login.html,/swagger-ui.html,/swagger-ui/**,/v3/api-docs/**,/event/,/event/list.html,/event/**,/about.html,/error,/error.html,/webauthn/authenticate/**,/login/webauthn # A comma delimited list of URIs that should not be protected by Spring Security if the defaultAction is deny. protectedURIs: /protected.html # A comma delimited list of URIs that should be protected by Spring Security if the defaultAction is allow. disableCSRFdURIs: /no-csrf-test # A comma delimited list of URIs that should not be protected by CSRF protection. This may include API endpoints that need to be called without a CSRF token. diff --git a/src/main/resources/static/js/user/login.js b/src/main/resources/static/js/user/login.js index e8bbba6..34597f1 100644 --- a/src/main/resources/static/js/user/login.js +++ b/src/main/resources/static/js/user/login.js @@ -1,4 +1,6 @@ import { showMessage } from "/js/shared.js"; +import { isWebAuthnSupported } from "/js/user/webauthn-utils.js"; +import { authenticateWithPasskey } from "/js/user/webauthn-authenticate.js"; document.addEventListener("DOMContentLoaded", () => { const form = document.querySelector("form"); @@ -8,6 +10,29 @@ document.addEventListener("DOMContentLoaded", () => { event.preventDefault(); } }); + + // 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; + passkeyBtn.innerHTML = ' Authenticating...'; + + try { + const redirectUrl = await authenticateWithPasskey(); + window.location.href = redirectUrl; + } catch (error) { + console.error("Passkey authentication failed:", error); + showMessage(null, "Passkey authentication failed: " + error.message, "alert-danger"); + passkeyBtn.disabled = false; + passkeyBtn.innerHTML = ' Sign in with Passkey'; + } + }); + } }); function validateForm(form) { diff --git a/src/main/resources/static/js/user/webauthn-authenticate.js b/src/main/resources/static/js/user/webauthn-authenticate.js new file mode 100644 index 0000000..7010969 --- /dev/null +++ b/src/main/resources/static/js/user/webauthn-authenticate.js @@ -0,0 +1,87 @@ +/** + * WebAuthn passkey authentication (login). + */ +import { getCsrfToken, getCsrfHeaderName, base64urlToBuffer, bufferToBase64url } from '/js/user/webauthn-utils.js'; + +/** + * Authenticate with passkey (discoverable credential / usernameless). + */ +export async function authenticateWithPasskey() { + const csrfHeader = getCsrfHeaderName(); + const csrfToken = getCsrfToken(); + + // 1. Request authentication options (challenge) from Spring Security + const optionsResponse = await fetch('/webauthn/authenticate/options', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + [csrfHeader]: csrfToken + } + }); + + if (!optionsResponse.ok) { + throw new Error('Failed to start authentication'); + } + + const options = await optionsResponse.json(); + + // 2. Convert base64url fields to ArrayBuffer + // Spring Security 7 returns options directly (not wrapped in publicKey) + options.challenge = base64urlToBuffer(options.challenge); + + if (options.allowCredentials) { + options.allowCredentials = options.allowCredentials.map(cred => ({ + ...cred, + id: base64urlToBuffer(cred.id) + })); + } + + // 3. Call browser WebAuthn API + const assertion = await navigator.credentials.get({ + publicKey: options + }); + + if (!assertion) { + throw new Error('No assertion returned from authenticator'); + } + + // 4. Convert assertion to JSON in Spring Security's expected format + const assertionJSON = { + id: assertion.id, + rawId: bufferToBase64url(assertion.rawId), + credType: assertion.type, + response: { + authenticatorData: bufferToBase64url(assertion.response.authenticatorData), + clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON), + signature: bufferToBase64url(assertion.response.signature), + userHandle: assertion.response.userHandle + ? bufferToBase64url(assertion.response.userHandle) + : null + }, + clientExtensionResults: assertion.getClientExtensionResults(), + authenticatorAttachment: assertion.authenticatorAttachment + }; + + // 5. Send assertion to Spring Security + const finishResponse = await fetch('/login/webauthn', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + [csrfHeader]: csrfToken + }, + body: JSON.stringify(assertionJSON) + }); + + if (!finishResponse.ok) { + const error = await finishResponse.text(); + throw new Error(error || 'Authentication failed'); + } + + // Spring Security returns { authenticated: true, redirectUrl: "..." } + const authResponse = await finishResponse.json(); + if (!authResponse || !authResponse.authenticated || !authResponse.redirectUrl) { + throw new Error('Authentication failed'); + } + + return authResponse.redirectUrl; +} diff --git a/src/main/resources/static/js/user/webauthn-manage.js b/src/main/resources/static/js/user/webauthn-manage.js new file mode 100644 index 0000000..1e1767c --- /dev/null +++ b/src/main/resources/static/js/user/webauthn-manage.js @@ -0,0 +1,189 @@ +/** + * WebAuthn credential management (list, rename, delete) for the user profile page. + */ +import { getCsrfToken, getCsrfHeaderName, isWebAuthnSupported, escapeHtml } from '/js/user/webauthn-utils.js'; +import { registerPasskey } from '/js/user/webauthn-register.js'; +import { showMessage } from '/js/shared.js'; + +const csrfHeader = getCsrfHeaderName(); +const csrfToken = getCsrfToken(); + +/** + * Load and display user's passkeys. + */ +export async function loadPasskeys() { + const container = document.getElementById('passkeys-list'); + const globalMessage = document.getElementById('passkeyMessage'); + if (!container) return; + + try { + const response = await fetch('/user/webauthn/credentials', { + headers: { [csrfHeader]: csrfToken } + }); + + if (!response.ok) { + throw new Error('Failed to load passkeys'); + } + + const credentials = await response.json(); + displayCredentials(container, credentials); + } catch (error) { + console.error('Failed to load passkeys:', error); + if (globalMessage) { + showMessage(globalMessage, 'Failed to load passkeys.', 'alert-danger'); + } + } +} + +/** + * Display credentials in UI. + */ +function displayCredentials(container, credentials) { + if (credentials.length === 0) { + container.innerHTML = '

No passkeys registered yet.

'; + return; + } + + container.innerHTML = credentials.map(cred => ` +
+
+
+ ${escapeHtml(cred.label || 'Unnamed Passkey')} +
+ + Created: ${new Date(cred.created).toLocaleDateString()} + ${cred.lastUsed ? ' | Last used: ' + new Date(cred.lastUsed).toLocaleDateString() : ' | Never used'} + +
+ ${cred.backupEligible + ? 'Synced' + : 'Device-bound'} +
+
+ + +
+
+
+ `).join(''); +} + +/** + * Rename a passkey. + */ +async function renamePasskey(credentialId) { + const newLabel = prompt('Enter new name for this passkey:'); + if (!newLabel) return; + + const globalMessage = document.getElementById('passkeyMessage'); + + try { + const response = await fetch(`/user/webauthn/credentials/${credentialId}/label`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + [csrfHeader]: csrfToken + }, + body: JSON.stringify({ label: newLabel }) + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.message || 'Failed to rename passkey'); + } + + if (globalMessage) { + showMessage(globalMessage, 'Passkey renamed successfully.', 'alert-success'); + } + loadPasskeys(); + } catch (error) { + console.error('Failed to rename passkey:', error); + if (globalMessage) { + showMessage(globalMessage, error.message, 'alert-danger'); + } + } +} + +/** + * Delete a passkey with confirmation. + */ +async function deletePasskey(credentialId) { + if (!confirm('Are you sure you want to delete this passkey? This action cannot be undone.')) { + return; + } + + const globalMessage = document.getElementById('passkeyMessage'); + + try { + const response = await fetch(`/user/webauthn/credentials/${credentialId}`, { + method: 'DELETE', + headers: { [csrfHeader]: csrfToken } + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.message || 'Failed to delete passkey'); + } + + if (globalMessage) { + showMessage(globalMessage, 'Passkey deleted successfully.', 'alert-success'); + } + loadPasskeys(); + } catch (error) { + console.error('Failed to delete passkey:', error); + if (globalMessage) { + showMessage(globalMessage, error.message, 'alert-danger'); + } + } +} + +/** + * Handle register passkey button click. + */ +async function handleRegisterPasskey() { + const globalMessage = document.getElementById('passkeyMessage'); + const labelInput = document.getElementById('passkeyLabel'); + const label = labelInput ? labelInput.value.trim() : ''; + + try { + await registerPasskey(label || 'My Passkey'); + if (globalMessage) { + showMessage(globalMessage, 'Passkey registered successfully!', 'alert-success'); + } + if (labelInput) labelInput.value = ''; + loadPasskeys(); + } catch (error) { + console.error('Registration error:', error); + if (globalMessage) { + showMessage(globalMessage, 'Failed to register passkey: ' + error.message, 'alert-danger'); + } + } +} + +// Expose to global scope for onclick handlers in the credential list +window.renamePasskey = renamePasskey; +window.deletePasskey = deletePasskey; + +// Initialize on page load +document.addEventListener('DOMContentLoaded', async () => { + const passkeySection = document.getElementById('passkey-section'); + if (!passkeySection) return; + + if (!isWebAuthnSupported()) { + passkeySection.innerHTML = '
Your browser does not support passkeys.
'; + return; + } + + // Wire up register button + const registerBtn = document.getElementById('registerPasskeyBtn'); + if (registerBtn) { + registerBtn.addEventListener('click', handleRegisterPasskey); + } + + // Load existing passkeys + loadPasskeys(); +}); diff --git a/src/main/resources/static/js/user/webauthn-register.js b/src/main/resources/static/js/user/webauthn-register.js new file mode 100644 index 0000000..9311802 --- /dev/null +++ b/src/main/resources/static/js/user/webauthn-register.js @@ -0,0 +1,86 @@ +/** + * WebAuthn passkey registration for authenticated users. + */ +import { getCsrfToken, getCsrfHeaderName, base64urlToBuffer, bufferToBase64url } from '/js/user/webauthn-utils.js'; + +/** + * Register a new passkey for the authenticated user. + */ +export async function registerPasskey(labelInput) { + const credentialName = labelInput || 'My Passkey'; + const csrfHeader = getCsrfHeaderName(); + const csrfToken = getCsrfToken(); + + // 1. Request registration options (challenge) from Spring Security + const optionsResponse = await fetch('/webauthn/register/options', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + [csrfHeader]: csrfToken + } + }); + + if (!optionsResponse.ok) { + throw new Error('Failed to start registration'); + } + + const options = await optionsResponse.json(); + + // 2. Convert base64url fields to ArrayBuffer + // Spring Security 7 returns options directly (not wrapped in publicKey) + options.challenge = base64urlToBuffer(options.challenge); + options.user.id = base64urlToBuffer(options.user.id); + + if (options.excludeCredentials) { + options.excludeCredentials = options.excludeCredentials.map(cred => ({ + ...cred, + id: base64urlToBuffer(cred.id) + })); + } + + // 3. Call browser WebAuthn API + const credential = await navigator.credentials.create({ + publicKey: options + }); + + if (!credential) { + throw new Error('No credential returned from authenticator'); + } + + // 4. Build the registration request in Spring Security's expected format: + // { publicKey: { credential: {...}, label: "..." } } + const registrationRequest = { + publicKey: { + credential: { + id: credential.id, + rawId: bufferToBase64url(credential.rawId), + type: credential.type, + response: { + attestationObject: bufferToBase64url(credential.response.attestationObject), + clientDataJSON: bufferToBase64url(credential.response.clientDataJSON), + transports: credential.response.getTransports ? credential.response.getTransports() : [] + }, + clientExtensionResults: credential.getClientExtensionResults(), + authenticatorAttachment: credential.authenticatorAttachment + }, + label: credentialName + } + }; + + // 5. Send credential to Spring Security + const finishResponse = await fetch('/webauthn/register', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + [csrfHeader]: csrfToken + }, + body: JSON.stringify(registrationRequest) + }); + + if (!finishResponse.ok) { + const error = await finishResponse.text(); + throw new Error(error || 'Registration failed'); + } + + return credential; +} diff --git a/src/main/resources/static/js/user/webauthn-utils.js b/src/main/resources/static/js/user/webauthn-utils.js new file mode 100644 index 0000000..e9a2f33 --- /dev/null +++ b/src/main/resources/static/js/user/webauthn-utils.js @@ -0,0 +1,75 @@ +/** + * WebAuthn utility functions for base64url encoding/decoding and browser support checks. + */ + +/** + * Get CSRF token from meta tag. + */ +export function getCsrfToken() { + const meta = document.querySelector('meta[name="_csrf"]'); + return meta ? meta.getAttribute('content') : ''; +} + +/** + * Get CSRF header name from meta tag. + */ +export function getCsrfHeaderName() { + const meta = document.querySelector('meta[name="_csrf_header"]'); + return meta ? meta.getAttribute('content') : 'X-CSRF-TOKEN'; +} + +/** + * Convert base64url string to ArrayBuffer. + */ +export function base64urlToBuffer(base64url) { + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/'); + const padLen = (4 - (base64.length % 4)) % 4; + const padded = base64 + '='.repeat(padLen); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +} + +/** + * Convert ArrayBuffer to base64url string. + */ +export function bufferToBase64url(buffer) { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + const base64 = btoa(binary); + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +/** + * Check if WebAuthn is supported in this browser. + */ +export function isWebAuthnSupported() { + return window.PublicKeyCredential !== undefined && + navigator.credentials !== undefined; +} + +/** + * Check if platform authenticator is available (TouchID, FaceID, Windows Hello). + */ +export async function isPlatformAuthenticatorAvailable() { + if (!isWebAuthnSupported()) { + return false; + } + return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); +} + +/** + * Escape HTML to prevent XSS. + */ +export function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} diff --git a/src/main/resources/templates/user/login.html b/src/main/resources/templates/user/login.html index ed2eefc..ec64752 100644 --- a/src/main/resources/templates/user/login.html +++ b/src/main/resources/templates/user/login.html @@ -43,6 +43,13 @@
Log in with
Login with Keycloak + + +

or

diff --git a/src/main/resources/templates/user/update-user.html b/src/main/resources/templates/user/update-user.html index b1f9da9..b627634 100644 --- a/src/main/resources/templates/user/update-user.html +++ b/src/main/resources/templates/user/update-user.html @@ -45,6 +45,29 @@

Update Profile

+ +
+
+
Passkeys
+
+
+
+ + +
+ + +
+ + +
+

Loading passkeys...

+
+
+
+
Change Password @@ -54,6 +77,7 @@

Update Profile

+
From 1c99a9a4b23bd79cf38f0f7a4fdb6235294ae7e1 Mon Sep 17 00:00:00 2001 From: Oluwatobi Date: Sun, 15 Feb 2026 04:48:43 +0100 Subject: [PATCH 02/10] made a little refactoring --- docker-compose.yml | 8 -------- src/main/resources/application.yml | 2 +- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 92d534a..928bb7d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,14 +21,6 @@ services: timeout: 5s retries: 3 - myapp-db-adminer: - image: adminer - container_name: springuser-db-adminer - ports: - - "8081:8080" - depends_on: - - myapp-db - mailserver: image: docker.io/mailserver/docker-mailserver:latest container_name: springuser-mail diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5da9ea8..ebf93b1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -102,7 +102,7 @@ springdoc: user: actuallyDeleteAccount: false # If true, users can delete their own accounts. If false, accounts are disabled instead of deleted. registration: - sendVerificationEmail: false # If true, a verification email will be sent to the user after registration. If false, the user will be automatically verified. + sendVerificationEmail: true # If true, a verification email will be sent to the user after registration. If false, the user will be automatically verified. googleEnabled: false # If true, Google OAuth2 will be enabled for registration. facebookEnabled: false # If true, Facebook OAuth2 will be enabled for registration. webauthn: From 0c1f81046a3778e8ee16a34160188d079f0b1b44 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 21 Feb 2026 13:38:37 -0700 Subject: [PATCH 03/10] Fix passkey label overflow and add rename length validation - 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 --- .../resources/static/js/user/webauthn-manage.js | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/main/resources/static/js/user/webauthn-manage.js b/src/main/resources/static/js/user/webauthn-manage.js index 1e1767c..b30776e 100644 --- a/src/main/resources/static/js/user/webauthn-manage.js +++ b/src/main/resources/static/js/user/webauthn-manage.js @@ -47,8 +47,8 @@ function displayCredentials(container, credentials) { container.innerHTML = credentials.map(cred => `
-
- ${escapeHtml(cred.label || 'Unnamed Passkey')} +
+ ${escapeHtml(cred.label || 'Unnamed Passkey')}
Created: ${new Date(cred.created).toLocaleDateString()} @@ -59,7 +59,7 @@ function displayCredentials(container, credentials) { ? 'Synced' : 'Device-bound'}
-
+
@@ -76,9 +76,17 @@ function displayCredentials(container, credentials) { * Rename a passkey. */ async function renamePasskey(credentialId) { - const newLabel = prompt('Enter new name for this passkey:'); + const newLabel = prompt('Enter new name for this passkey (max 64 characters):'); if (!newLabel) return; + if (newLabel.length > 64) { + const globalMsg = document.getElementById('passkeyMessage'); + if (globalMsg) { + showMessage(globalMsg, 'Passkey name is too long (max 64 characters).', 'alert-danger'); + } + return; + } + const globalMessage = document.getElementById('passkeyMessage'); try { From 48c9311d7973a1257cfd4e0a8ba0d9b80108c7d1 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 21 Feb 2026 13:43:44 -0700 Subject: [PATCH 04/10] Add cookies.txt to .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 77b4da8..beca067 100644 --- a/.gitignore +++ b/.gitignore @@ -145,3 +145,6 @@ playwright/test-results/ playwright/playwright-report/ playwright/reports/ playwright/.env + +# Curl cookie files +cookies.txt From 6bf3bf42585eb340f74f80deae7360322a08866e Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 21 Feb 2026 13:49:18 -0700 Subject: [PATCH 05/10] Replace rename passkey JS prompt with Bootstrap modal dialog --- .../static/js/user/webauthn-manage.js | 128 +++++++++++++----- .../resources/templates/user/update-user.html | 24 ++++ 2 files changed, 115 insertions(+), 37 deletions(-) diff --git a/src/main/resources/static/js/user/webauthn-manage.js b/src/main/resources/static/js/user/webauthn-manage.js index b30776e..fc8f586 100644 --- a/src/main/resources/static/js/user/webauthn-manage.js +++ b/src/main/resources/static/js/user/webauthn-manage.js @@ -60,7 +60,7 @@ function displayCredentials(container, credentials) { : 'Device-bound'}
-
+ + +
Change Password From 3ab98988facb43c54f4cd28a77060194751cc4df Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 21 Feb 2026 13:59:15 -0700 Subject: [PATCH 06/10] Add WebAuthn/Passkey documentation to README --- README.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/README.md b/README.md index fe67fe4..14748ff 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,8 @@ This version uses: - **Authentication & Security** - Username/password authentication + - WebAuthn/Passkey passwordless login (biometrics, security keys) + - Passkey management (register, rename, delete) - OAuth2 login with Google, Facebook, and Keycloak - Role-based access control - CSRF protection @@ -376,6 +378,39 @@ To enable SSO: Then update your OAuth2 providers' callback URLs to use the ngrok domain. +--- + +#### **WebAuthn / Passkeys** + +The demo app includes full WebAuthn/Passkey support for passwordless login. Users can register passkeys (biometrics, security keys) from their profile page and use them to log in without a password. + +**Configuration** (in `application.yml`): +```yaml +user: + webauthn: + enabled: true # Enable passkey support + rpId: localhost # Must match your domain + rpName: Spring User Framework Demo # Display name shown during registration + allowedOrigins: http://localhost:8080 # Must match browser origin exactly +``` + +**Important**: You must also add the WebAuthn endpoints to your unprotected URIs: +```yaml +user: + security: + unprotectedURIs: ...,/webauthn/authenticate/**,/login/webauthn +``` + +**How it works:** +- **Register a passkey**: Log in with username/password, go to your profile page, and click "Add Passkey" +- **Log in with passkey**: On the login page, click the "Sign in with a Passkey" button +- **Manage passkeys**: From your profile page, rename or delete registered passkeys + +**Development notes:** +- HTTP works on `localhost` without HTTPS +- For testing on other devices, use ngrok (`ngrok http 8080`) and update `rpId` and `allowedOrigins` to match the ngrok domain +- The database tables (`user_entities`, `user_credentials`) are created automatically by Hibernate + ### Environment Variables For production deployments, use environment variables instead of hardcoding values: @@ -640,6 +675,18 @@ Solution: 4. Verify Keycloak realm and client settings ``` +#### WebAuthn/Passkey Issues +**Problem**: Passkey registration or login fails +``` +Solution: +1. Verify user.webauthn.enabled is true in application.yml +2. Check that rpId matches your domain (localhost for local dev) +3. Ensure allowedOrigins matches the exact browser URL (including port) +4. Verify /webauthn/authenticate/** and /login/webauthn are in unprotectedURIs +5. For non-localhost testing, HTTPS is required - use ngrok +6. Check browser console for WebAuthn API errors +``` + #### Email Not Sending **Problem**: Registration emails not received ``` From b71b527022d54d35aece91785ba1d7f59bbadd46 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 21 Feb 2026 15:26:02 -0700 Subject: [PATCH 07/10] Switch Claude actions from OAuth token to API key auth OAuth tokens expire periodically, requiring manual regeneration. API keys are stable and don't expire. --- .github/workflows/claude-code-review.yml | 3 +-- .github/workflows/claude.yml | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 205b0fe..b3b46dd 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -35,7 +35,7 @@ jobs: id: claude-review uses: anthropics/claude-code-action@v1 with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} prompt: | REPO: ${{ github.repository }} PR NUMBER: ${{ github.event.pull_request.number }} @@ -54,4 +54,3 @@ jobs: # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://docs.claude.com/en/docs/claude-code/cli-reference for available options claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' - diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 412cef9..93907d3 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -34,7 +34,7 @@ jobs: id: claude uses: anthropics/claude-code-action@v1 with: - claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} # This is an optional setting that allows Claude to read CI results on PRs additional_permissions: | @@ -47,4 +47,3 @@ jobs: # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://docs.claude.com/en/docs/claude-code/cli-reference for available options # claude_args: '--allowed-tools Bash(gh pr:*)' - From d04104bc2349cccd8cbfcad224bd7097a2daaff0 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 21 Feb 2026 16:13:57 -0700 Subject: [PATCH 08/10] fix(webauthn): harden WebAuthn JS and add production config 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 --- src/main/resources/application-prd.yml | 4 ++ src/main/resources/static/js/user/login.js | 2 +- .../static/js/user/webauthn-authenticate.js | 11 +++- .../static/js/user/webauthn-manage.js | 52 ++++++++++++++----- .../static/js/user/webauthn-register.js | 11 +++- 5 files changed, 61 insertions(+), 19 deletions(-) diff --git a/src/main/resources/application-prd.yml b/src/main/resources/application-prd.yml index ac765be..1dc15ca 100644 --- a/src/main/resources/application-prd.yml +++ b/src/main/resources/application-prd.yml @@ -37,5 +37,9 @@ management: show-details: never # Don't expose detailed health info in production user: + webauthn: + rpId: ${WEBAUTHN_RP_ID:example.com} + rpName: ${WEBAUTHN_RP_NAME:Spring User Framework Demo} + allowedOrigins: ${WEBAUTHN_ALLOWED_ORIGINS:https://example.com} security: disableCSRFdURIs: # No CSRF disabled URIs in production for better security \ No newline at end of file diff --git a/src/main/resources/static/js/user/login.js b/src/main/resources/static/js/user/login.js index 34597f1..6de7a81 100644 --- a/src/main/resources/static/js/user/login.js +++ b/src/main/resources/static/js/user/login.js @@ -27,7 +27,7 @@ document.addEventListener("DOMContentLoaded", () => { window.location.href = redirectUrl; } catch (error) { console.error("Passkey authentication failed:", error); - showMessage(null, "Passkey authentication failed: " + error.message, "alert-danger"); + showMessage(null, "Passkey authentication failed. Please try again.", "alert-danger"); passkeyBtn.disabled = false; passkeyBtn.innerHTML = ' Sign in with Passkey'; } diff --git a/src/main/resources/static/js/user/webauthn-authenticate.js b/src/main/resources/static/js/user/webauthn-authenticate.js index 7010969..6aa8ad2 100644 --- a/src/main/resources/static/js/user/webauthn-authenticate.js +++ b/src/main/resources/static/js/user/webauthn-authenticate.js @@ -73,8 +73,15 @@ export async function authenticateWithPasskey() { }); if (!finishResponse.ok) { - const error = await finishResponse.text(); - throw new Error(error || 'Authentication failed'); + let msg = 'Authentication failed'; + try { + const data = await finishResponse.json(); + msg = data.message || msg; + } catch { + const text = await finishResponse.text(); + if (text) msg = text; + } + throw new Error(msg); } // Spring Security returns { authenticated: true, redirectUrl: "..." } diff --git a/src/main/resources/static/js/user/webauthn-manage.js b/src/main/resources/static/js/user/webauthn-manage.js index fc8f586..3aee820 100644 --- a/src/main/resources/static/js/user/webauthn-manage.js +++ b/src/main/resources/static/js/user/webauthn-manage.js @@ -7,6 +7,7 @@ import { showMessage } from '/js/shared.js'; const csrfHeader = getCsrfHeaderName(); const csrfToken = getCsrfToken(); +let renameModalInstance; /** * Load and display user's passkeys. @@ -35,6 +36,15 @@ export async function loadPasskeys() { } } +/** + * Format a date string safely, returning 'Unknown' for invalid values. + */ +function formatDate(dateStr) { + if (!dateStr) return 'Unknown'; + const date = new Date(dateStr); + return isNaN(date) ? 'Unknown' : date.toLocaleDateString(); +} + /** * Display credentials in UI. */ @@ -51,8 +61,8 @@ function displayCredentials(container, credentials) { ${escapeHtml(cred.label || 'Unnamed Passkey')}
- Created: ${new Date(cred.created).toLocaleDateString()} - ${cred.lastUsed ? ' | Last used: ' + new Date(cred.lastUsed).toLocaleDateString() : ' | Never used'} + Created: ${formatDate(cred.created)} + ${cred.lastUsed ? ' | Last used: ' + formatDate(cred.lastUsed) : ' | Never used'}
${cred.backupEligible @@ -60,10 +70,10 @@ function displayCredentials(container, credentials) { : 'Device-bound'}
- -
@@ -87,9 +97,11 @@ function renamePasskey(credentialId, currentLabel) { errorEl.classList.add('d-none'); input.classList.remove('is-invalid'); - // Show modal - const modal = new bootstrap.Modal(document.getElementById('renamePasskeyModal')); - modal.show(); + // Show modal (reuse cached instance) + if (!renameModalInstance) { + renameModalInstance = new bootstrap.Modal(document.getElementById('renamePasskeyModal')); + } + renameModalInstance.show(); // Focus input when modal is shown document.getElementById('renamePasskeyModal').addEventListener('shown.bs.modal', () => { @@ -145,7 +157,7 @@ function renamePasskey(credentialId, currentLabel) { throw new Error(data.message || 'Failed to rename passkey'); } - modal.hide(); + renameModalInstance.hide(); if (globalMessage) { showMessage(globalMessage, 'Passkey renamed successfully.', 'alert-success'); } @@ -198,7 +210,7 @@ async function deletePasskey(credentialId) { } catch (error) { console.error('Failed to delete passkey:', error); if (globalMessage) { - showMessage(globalMessage, error.message, 'alert-danger'); + showMessage(globalMessage, 'Failed to delete passkey. Please try again.', 'alert-danger'); } } } @@ -221,15 +233,11 @@ async function handleRegisterPasskey() { } catch (error) { console.error('Registration error:', error); if (globalMessage) { - showMessage(globalMessage, 'Failed to register passkey: ' + error.message, 'alert-danger'); + showMessage(globalMessage, 'Failed to register passkey. Please try again.', 'alert-danger'); } } } -// Expose to global scope for onclick handlers in the credential list -window.renamePasskey = renamePasskey; -window.deletePasskey = deletePasskey; - // Initialize on page load document.addEventListener('DOMContentLoaded', async () => { const passkeySection = document.getElementById('passkey-section'); @@ -240,6 +248,22 @@ document.addEventListener('DOMContentLoaded', async () => { return; } + // Event delegation for credential list actions + const passkeysList = document.getElementById('passkeys-list'); + if (passkeysList) { + passkeysList.addEventListener('click', (event) => { + const button = event.target.closest('button[data-action]'); + if (!button) return; + + const { action, id, label } = button.dataset; + if (action === 'rename') { + renamePasskey(id, label); + } else if (action === 'delete') { + deletePasskey(id); + } + }); + } + // Wire up register button const registerBtn = document.getElementById('registerPasskeyBtn'); if (registerBtn) { diff --git a/src/main/resources/static/js/user/webauthn-register.js b/src/main/resources/static/js/user/webauthn-register.js index 9311802..340697c 100644 --- a/src/main/resources/static/js/user/webauthn-register.js +++ b/src/main/resources/static/js/user/webauthn-register.js @@ -78,8 +78,15 @@ export async function registerPasskey(labelInput) { }); if (!finishResponse.ok) { - const error = await finishResponse.text(); - throw new Error(error || 'Registration failed'); + let msg = 'Registration failed'; + try { + const data = await finishResponse.json(); + msg = data.message || msg; + } catch { + const text = await finishResponse.text(); + if (text) msg = text; + } + throw new Error(msg); } return credential; From 57d6d8501bae7d840da9f6cf32eb105f47e41eb6 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 21 Feb 2026 16:46:35 -0700 Subject: [PATCH 09/10] build: upgrade ds-spring-user-framework to 4.2.0 stable release Replace 4.1.1-SNAPSHOT with the stable 4.2.0 release which includes the WebAuthn/Passkey support needed by this branch. --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 5bcafd3..4f64bed 100644 --- a/build.gradle +++ b/build.gradle @@ -39,7 +39,7 @@ repositories { dependencies { // DigitalSanctuary Spring User Framework - implementation 'com.digitalsanctuary:ds-spring-user-framework:4.1.1-SNAPSHOT' + implementation 'com.digitalsanctuary:ds-spring-user-framework:4.2.0' // WebAuthn support (Passkey authentication) implementation 'org.springframework.security:spring-security-webauthn' From 7f36b38b30a69745f585492ccd3bae9dda82f1d6 Mon Sep 17 00:00:00 2001 From: Devon Hillard Date: Sat, 21 Feb 2026 17:05:14 -0700 Subject: [PATCH 10/10] fix(webauthn): address PR #55 code review feedback - 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 --- src/main/resources/static/js/user/login.js | 5 +++-- .../static/js/user/webauthn-manage.js | 22 +++++++++++++++---- .../static/js/user/webauthn-utils.js | 2 +- src/main/resources/templates/user/login.html | 2 ++ 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/main/resources/static/js/user/login.js b/src/main/resources/static/js/user/login.js index 6de7a81..c608ecf 100644 --- a/src/main/resources/static/js/user/login.js +++ b/src/main/resources/static/js/user/login.js @@ -15,8 +15,9 @@ document.addEventListener("DOMContentLoaded", () => { const passkeySection = document.getElementById("passkey-login-section"); const passkeyBtn = document.getElementById("passkeyLoginBtn"); - if (passkeySection && isWebAuthnSupported()) { + if (passkeySection && passkeyBtn && isWebAuthnSupported()) { passkeySection.style.display = "block"; + const passkeyError = document.getElementById("passkeyError"); passkeyBtn.addEventListener("click", async () => { passkeyBtn.disabled = true; @@ -27,7 +28,7 @@ document.addEventListener("DOMContentLoaded", () => { window.location.href = redirectUrl; } catch (error) { console.error("Passkey authentication failed:", error); - showMessage(null, "Passkey authentication failed. Please try again.", "alert-danger"); + showMessage(passkeyError, "Passkey authentication failed. Please try again.", "alert-danger"); passkeyBtn.disabled = false; passkeyBtn.innerHTML = ' Sign in with Passkey'; } diff --git a/src/main/resources/static/js/user/webauthn-manage.js b/src/main/resources/static/js/user/webauthn-manage.js index 3aee820..acddb48 100644 --- a/src/main/resources/static/js/user/webauthn-manage.js +++ b/src/main/resources/static/js/user/webauthn-manage.js @@ -153,8 +153,15 @@ function renamePasskey(credentialId, currentLabel) { }); if (!response.ok) { - const data = await response.json(); - throw new Error(data.message || 'Failed to rename passkey'); + let msg = 'Failed to rename passkey'; + try { + const data = await response.json(); + msg = data.message || msg; + } catch { + const text = await response.text(); + if (text) msg = text; + } + throw new Error(msg); } renameModalInstance.hide(); @@ -199,8 +206,15 @@ async function deletePasskey(credentialId) { }); if (!response.ok) { - const data = await response.json(); - throw new Error(data.message || 'Failed to delete passkey'); + let msg = 'Failed to delete passkey'; + try { + const data = await response.json(); + msg = data.message || msg; + } catch { + const text = await response.text(); + if (text) msg = text; + } + throw new Error(msg); } if (globalMessage) { diff --git a/src/main/resources/static/js/user/webauthn-utils.js b/src/main/resources/static/js/user/webauthn-utils.js index e9a2f33..2db4e6b 100644 --- a/src/main/resources/static/js/user/webauthn-utils.js +++ b/src/main/resources/static/js/user/webauthn-utils.js @@ -71,5 +71,5 @@ export function escapeHtml(text) { if (!text) return ''; const div = document.createElement('div'); div.textContent = text; - return div.innerHTML; + return div.innerHTML.replace(/"/g, '"').replace(/'/g, '''); } diff --git a/src/main/resources/templates/user/login.html b/src/main/resources/templates/user/login.html index ec64752..ca1505c 100644 --- a/src/main/resources/templates/user/login.html +++ b/src/main/resources/templates/user/login.html @@ -20,6 +20,8 @@ + +