-
Notifications
You must be signed in to change notification settings - Fork 7
Add WebAuthn/Passkey authentication support #55
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
c2c476f
1c99a9a
0c1f810
48c9311
6bf3bf4
3ab9898
b71b527
d04104b
57d6d85
7f36b38
038a7d9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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,30 @@ 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 && passkeyBtn && isWebAuthnSupported()) { | ||
| passkeySection.style.display = "block"; | ||
| const passkeyError = document.getElementById("passkeyError"); | ||
|
|
||
| passkeyBtn.addEventListener("click", async () => { | ||
| passkeyBtn.disabled = true; | ||
|
Comment on lines
14
to
23
|
||
| passkeyBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span> Authenticating...'; | ||
|
|
||
| try { | ||
| const redirectUrl = await authenticateWithPasskey(); | ||
| window.location.href = redirectUrl; | ||
| } catch (error) { | ||
| console.error("Passkey authentication failed:", error); | ||
| showMessage(passkeyError, "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'; | ||
|
Comment on lines
29
to
33
|
||
| } | ||
| }); | ||
| } | ||
| }); | ||
|
|
||
| function validateForm(form) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| /** | ||
| * 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) { | ||
| 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: "..." } | ||
| const authResponse = await finishResponse.json(); | ||
| if (!authResponse || !authResponse.authenticated || !authResponse.redirectUrl) { | ||
| throw new Error('Authentication failed'); | ||
| } | ||
|
|
||
| return authResponse.redirectUrl; | ||
| } |
There was a problem hiding this comment.
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
PublicKeyCredentialis unavailable (viapage.addInitScript), and (2) verify it becomes visible when supported (even if the full WebAuthn ceremony is mocked).There was a problem hiding this comment.
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.