Skip to content

Commit d04104b

Browse files
committed
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
1 parent b71b527 commit d04104b

5 files changed

Lines changed: 61 additions & 19 deletions

File tree

src/main/resources/application-prd.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,5 +37,9 @@ management:
3737
show-details: never # Don't expose detailed health info in production
3838

3939
user:
40+
webauthn:
41+
rpId: ${WEBAUTHN_RP_ID:example.com}
42+
rpName: ${WEBAUTHN_RP_NAME:Spring User Framework Demo}
43+
allowedOrigins: ${WEBAUTHN_ALLOWED_ORIGINS:https://example.com}
4044
security:
4145
disableCSRFdURIs: # No CSRF disabled URIs in production for better security

src/main/resources/static/js/user/login.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ document.addEventListener("DOMContentLoaded", () => {
2727
window.location.href = redirectUrl;
2828
} catch (error) {
2929
console.error("Passkey authentication failed:", error);
30-
showMessage(null, "Passkey authentication failed: " + error.message, "alert-danger");
30+
showMessage(null, "Passkey authentication failed. Please try again.", "alert-danger");
3131
passkeyBtn.disabled = false;
3232
passkeyBtn.innerHTML = '<i class="bi bi-key me-2"></i> Sign in with Passkey';
3333
}

src/main/resources/static/js/user/webauthn-authenticate.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,15 @@ export async function authenticateWithPasskey() {
7373
});
7474

7575
if (!finishResponse.ok) {
76-
const error = await finishResponse.text();
77-
throw new Error(error || 'Authentication failed');
76+
let msg = 'Authentication failed';
77+
try {
78+
const data = await finishResponse.json();
79+
msg = data.message || msg;
80+
} catch {
81+
const text = await finishResponse.text();
82+
if (text) msg = text;
83+
}
84+
throw new Error(msg);
7885
}
7986

8087
// Spring Security returns { authenticated: true, redirectUrl: "..." }

src/main/resources/static/js/user/webauthn-manage.js

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { showMessage } from '/js/shared.js';
77

88
const csrfHeader = getCsrfHeaderName();
99
const csrfToken = getCsrfToken();
10+
let renameModalInstance;
1011

1112
/**
1213
* Load and display user's passkeys.
@@ -35,6 +36,15 @@ export async function loadPasskeys() {
3536
}
3637
}
3738

39+
/**
40+
* Format a date string safely, returning 'Unknown' for invalid values.
41+
*/
42+
function formatDate(dateStr) {
43+
if (!dateStr) return 'Unknown';
44+
const date = new Date(dateStr);
45+
return isNaN(date) ? 'Unknown' : date.toLocaleDateString();
46+
}
47+
3848
/**
3949
* Display credentials in UI.
4050
*/
@@ -51,19 +61,19 @@ function displayCredentials(container, credentials) {
5161
<strong class="d-inline-block text-truncate" style="max-width: 100%;">${escapeHtml(cred.label || 'Unnamed Passkey')}</strong>
5262
<br>
5363
<small class="text-muted">
54-
Created: ${new Date(cred.created).toLocaleDateString()}
55-
${cred.lastUsed ? ' | Last used: ' + new Date(cred.lastUsed).toLocaleDateString() : ' | Never used'}
64+
Created: ${formatDate(cred.created)}
65+
${cred.lastUsed ? ' | Last used: ' + formatDate(cred.lastUsed) : ' | Never used'}
5666
</small>
5767
<br>
5868
${cred.backupEligible
5969
? '<span class="badge bg-success">Synced</span>'
6070
: '<span class="badge bg-warning text-dark">Device-bound</span>'}
6171
</div>
6272
<div class="flex-shrink-0">
63-
<button class="btn btn-sm btn-outline-secondary me-1" onclick="window.renamePasskey('${escapeHtml(cred.id)}', '${escapeHtml(cred.label || '')}')">
73+
<button class="btn btn-sm btn-outline-secondary me-1" data-action="rename" data-id="${escapeHtml(cred.id)}" data-label="${escapeHtml(cred.label || '')}">
6474
<i class="bi bi-pencil"></i> Rename
6575
</button>
66-
<button class="btn btn-sm btn-outline-danger" onclick="window.deletePasskey('${escapeHtml(cred.id)}')">
76+
<button class="btn btn-sm btn-outline-danger" data-action="delete" data-id="${escapeHtml(cred.id)}">
6777
<i class="bi bi-trash"></i> Delete
6878
</button>
6979
</div>
@@ -87,9 +97,11 @@ function renamePasskey(credentialId, currentLabel) {
8797
errorEl.classList.add('d-none');
8898
input.classList.remove('is-invalid');
8999

90-
// Show modal
91-
const modal = new bootstrap.Modal(document.getElementById('renamePasskeyModal'));
92-
modal.show();
100+
// Show modal (reuse cached instance)
101+
if (!renameModalInstance) {
102+
renameModalInstance = new bootstrap.Modal(document.getElementById('renamePasskeyModal'));
103+
}
104+
renameModalInstance.show();
93105

94106
// Focus input when modal is shown
95107
document.getElementById('renamePasskeyModal').addEventListener('shown.bs.modal', () => {
@@ -145,7 +157,7 @@ function renamePasskey(credentialId, currentLabel) {
145157
throw new Error(data.message || 'Failed to rename passkey');
146158
}
147159

148-
modal.hide();
160+
renameModalInstance.hide();
149161
if (globalMessage) {
150162
showMessage(globalMessage, 'Passkey renamed successfully.', 'alert-success');
151163
}
@@ -198,7 +210,7 @@ async function deletePasskey(credentialId) {
198210
} catch (error) {
199211
console.error('Failed to delete passkey:', error);
200212
if (globalMessage) {
201-
showMessage(globalMessage, error.message, 'alert-danger');
213+
showMessage(globalMessage, 'Failed to delete passkey. Please try again.', 'alert-danger');
202214
}
203215
}
204216
}
@@ -221,15 +233,11 @@ async function handleRegisterPasskey() {
221233
} catch (error) {
222234
console.error('Registration error:', error);
223235
if (globalMessage) {
224-
showMessage(globalMessage, 'Failed to register passkey: ' + error.message, 'alert-danger');
236+
showMessage(globalMessage, 'Failed to register passkey. Please try again.', 'alert-danger');
225237
}
226238
}
227239
}
228240

229-
// Expose to global scope for onclick handlers in the credential list
230-
window.renamePasskey = renamePasskey;
231-
window.deletePasskey = deletePasskey;
232-
233241
// Initialize on page load
234242
document.addEventListener('DOMContentLoaded', async () => {
235243
const passkeySection = document.getElementById('passkey-section');
@@ -240,6 +248,22 @@ document.addEventListener('DOMContentLoaded', async () => {
240248
return;
241249
}
242250

251+
// Event delegation for credential list actions
252+
const passkeysList = document.getElementById('passkeys-list');
253+
if (passkeysList) {
254+
passkeysList.addEventListener('click', (event) => {
255+
const button = event.target.closest('button[data-action]');
256+
if (!button) return;
257+
258+
const { action, id, label } = button.dataset;
259+
if (action === 'rename') {
260+
renamePasskey(id, label);
261+
} else if (action === 'delete') {
262+
deletePasskey(id);
263+
}
264+
});
265+
}
266+
243267
// Wire up register button
244268
const registerBtn = document.getElementById('registerPasskeyBtn');
245269
if (registerBtn) {

src/main/resources/static/js/user/webauthn-register.js

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,15 @@ export async function registerPasskey(labelInput) {
7878
});
7979

8080
if (!finishResponse.ok) {
81-
const error = await finishResponse.text();
82-
throw new Error(error || 'Registration failed');
81+
let msg = 'Registration failed';
82+
try {
83+
const data = await finishResponse.json();
84+
msg = data.message || msg;
85+
} catch {
86+
const text = await finishResponse.text();
87+
if (text) msg = text;
88+
}
89+
throw new Error(msg);
8390
}
8491

8592
return credential;

0 commit comments

Comments
 (0)