-
Notifications
You must be signed in to change notification settings - Fork 407
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
Add WebAuthn authentication flow #6792
Merged
Merged
Changes from 8 commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
b38d60e
Add WebAuthn authentication flow
scotttrinh eddebaa
Remove extra b64 roundtrip
scotttrinh 52999d2
Allow WebAuthnFactors to share user_handle
scotttrinh 1a63e08
More useful error for missing email verification
scotttrinh fc34ac4
Get WebAuthnFactor by exclusive properties
scotttrinh 40d6362
CQA
scotttrinh 3dd2036
Use specific error for missing or invalid state
scotttrinh 1431063
Every WebAuthnFactor email shares a user_handle
scotttrinh 644e869
Remove unnecessary coalesce
scotttrinh 3f12556
Make violating email_shares_user_handle trigger ISE
scotttrinh File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
202 changes: 202 additions & 0 deletions
202
edb/server/protocol/auth_ext/_static/webauthn-authenticate.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,202 @@ | ||
import { | ||
decodeBase64Url, | ||
encodeBase64Url, | ||
parseResponseAsJSON, | ||
} from "./utils.js"; | ||
|
||
document.addEventListener("DOMContentLoaded", () => { | ||
/** @type {HTMLFormElement | null} */ | ||
const authenticateForm = document.getElementById("email-factor"); | ||
|
||
if (authenticateForm === null) { | ||
return; | ||
} | ||
|
||
authenticateForm.addEventListener("submit", async (event) => { | ||
if (event.submitter?.id !== "webauthn-signin") { | ||
return; | ||
} | ||
event.preventDefault(); | ||
|
||
const formData = new FormData( | ||
/** @type {HTMLFormElement} */ authenticateForm | ||
); | ||
const email = formData.get("email"); | ||
const provider = "builtin::local_webauthn"; | ||
const challenge = formData.get("challenge"); | ||
const redirectOnFailure = formData.get("redirect_on_failure"); | ||
const redirectTo = formData.get("redirect_to"); | ||
|
||
if (redirectTo === null) { | ||
throw new Error("Missing redirect_to parameter"); | ||
} | ||
|
||
try { | ||
const maybeCode = await authenticate({ | ||
email, | ||
provider, | ||
challenge, | ||
}); | ||
|
||
const redirectUrl = new URL(redirectTo); | ||
if (maybeCode !== null) { | ||
redirectUrl.searchParams.append("code", maybeCode); | ||
} | ||
|
||
window.location.href = redirectUrl.href; | ||
} catch (error) { | ||
console.error("Failed to register WebAuthn credentials:", error); | ||
const url = new URL(redirectOnFailure ?? redirectTo); | ||
url.searchParams.append("error", error.message); | ||
window.location.href = url.href; | ||
} | ||
}); | ||
}); | ||
|
||
const WEBAUTHN_OPTIONS_URL = new URL( | ||
"../webauthn/authenticate/options", | ||
window.location | ||
); | ||
const WEBAUTHN_AUTHENTICATE_URL = new URL( | ||
"../webauthn/authenticate", | ||
window.location | ||
); | ||
/** | ||
* Authenticate an existing WebAuthn credential for the given email address | ||
* @param {Object} props - The properties for registration | ||
* @param {string} props.email - Email address to register | ||
* @param {string} props.provider - WebAuthn provider | ||
* @param {string} props.challenge - PKCE challenge | ||
* @returns {Promise<string | null>} - The PKCE code or null if the application | ||
* requires email verification | ||
*/ | ||
export async function authenticate({ email, provider, challenge }) { | ||
// Check if WebAuthn is supported | ||
if (!window.PublicKeyCredential) { | ||
console.error("WebAuthn is not supported in this browser."); | ||
return; | ||
} | ||
|
||
// Fetch WebAuthn options from the server | ||
const options = await getAuthenticateOptions(email); | ||
|
||
// Get the existing credentials assertion | ||
const assertion = await navigator.credentials.get({ | ||
publicKey: { | ||
...options, | ||
challenge: decodeBase64Url(options.challenge), | ||
allowCredentials: options.allowCredentials.map((credential) => ({ | ||
...credential, | ||
id: decodeBase64Url(credential.id), | ||
})), | ||
}, | ||
}); | ||
|
||
// Register the credentials on the server | ||
const registerResult = await authenticateAssertion({ | ||
email, | ||
assertion, | ||
challenge, | ||
}); | ||
|
||
return registerResult.code ?? null; | ||
} | ||
|
||
/** | ||
* Fetch WebAuthn options from the server | ||
* @param {string} email - Email address to register | ||
* @returns {Promise<globalThis.PublicKeyCredentialCreationOptions>} | ||
*/ | ||
async function getAuthenticateOptions(email) { | ||
const url = new URL(WEBAUTHN_OPTIONS_URL); | ||
url.searchParams.set("email", email); | ||
|
||
const optionsResponse = await fetch(url, { | ||
method: "GET", | ||
}); | ||
|
||
if (!optionsResponse.ok) { | ||
console.error( | ||
"Failed to fetch WebAuthn options:", | ||
optionsResponse.statusText | ||
); | ||
console.error(await optionsResponse.text()); | ||
throw new Error("Failed to fetch WebAuthn options"); | ||
} | ||
|
||
try { | ||
return await optionsResponse.json(); | ||
} catch (e) { | ||
console.error("Failed to parse WebAuthn options:", e); | ||
throw new Error("Failed to parse WebAuthn options"); | ||
} | ||
} | ||
|
||
/** | ||
* Authenticate the credentials on the server | ||
* @param {Object} props | ||
* @param {string} props.email | ||
* @param {Object} props.assertion | ||
* @param {string} props.provider | ||
* @param {string} props.challenge | ||
* @returns {Promise<Object>} | ||
*/ | ||
async function authenticateAssertion(props) { | ||
// Assertion includes raw bytes, so need to be encoded as base64url | ||
// for transmission | ||
const encodedAssertion = { | ||
...props.assertion, | ||
rawId: encodeBase64Url(new Uint8Array(props.assertion.rawId)), | ||
response: { | ||
...props.assertion.response, | ||
authenticatorData: encodeBase64Url( | ||
new Uint8Array(props.assertion.response.authenticatorData) | ||
), | ||
clientDataJSON: encodeBase64Url( | ||
new Uint8Array(props.assertion.response.clientDataJSON) | ||
), | ||
signature: encodeBase64Url( | ||
new Uint8Array(props.assertion.response.signature) | ||
), | ||
userHandle: props.assertion.response.userHandle | ||
? encodeBase64Url(new Uint8Array(props.assertion.response.userHandle)) | ||
: null, | ||
}, | ||
}; | ||
|
||
const authenticateResponse = await fetch(WEBAUTHN_AUTHENTICATE_URL, { | ||
method: "POST", | ||
headers: { | ||
"Content-Type": "application/json", | ||
}, | ||
body: JSON.stringify({ | ||
email: props.email, | ||
assertion: encodedAssertion, | ||
provider: props.provider, | ||
challenge: props.challenge, | ||
}), | ||
}); | ||
|
||
return await parseResponseAsJSON(authenticateResponse, [ | ||
(response, error) => { | ||
if (response.status === 401 && error?.type === "VerificationRequired") { | ||
console.error( | ||
"User's email is not verified", | ||
response.statusText, | ||
JSON.stringify(error) | ||
); | ||
throw new Error( | ||
"Please verify your email before attempting to sign in." | ||
); | ||
} | ||
}, | ||
(response, error) => { | ||
console.error( | ||
"Failed to authenticate WebAuthn credentials:", | ||
response.statusText, | ||
JSON.stringify(error) | ||
); | ||
throw new Error("Failed to authenticate WebAuthn credentials"); | ||
}, | ||
]); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
Minor thing, but I think
?? true
probably isn't needed. If the result of the select is an empty set, the assertion just won't run anyway.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.
TIL about the empty set behavior of
assert
. I'll add an example to the documentation forassert
.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.
I think it just follows the normal set behaviour, where if the function arg isn't
set of
(like incount
, for example) then it's expanded out. Soassert({true, false})
is executed as{assert(true), assert(false)}
, and similarlyassert({})
becomes{}
.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.
That makes sense. The documentation hints at this to since it says:
I think it'd just be helpful to show some examples of calling this function with a sets to show that. I'll follow up.