Skip to content

Commit

Permalink
Add WebAuthn authentication flow (#6792)
Browse files Browse the repository at this point in the history
  • Loading branch information
scotttrinh authored Feb 21, 2024
1 parent 11b18f1 commit bbec7fc
Show file tree
Hide file tree
Showing 8 changed files with 913 additions and 56 deletions.
24 changes: 21 additions & 3 deletions edb/lib/ext/auth.edgeql
Original file line number Diff line number Diff line change
Expand Up @@ -68,16 +68,24 @@ CREATE EXTENSION PACKAGE auth VERSION '1.0' {
};

create type ext::auth::WebAuthnFactor extending ext::auth::EmailFactor {
create required property user_handle: std::bytes {
create constraint exclusive;
};
create required property user_handle: std::bytes;
create required property credential_id: std::bytes {
create constraint exclusive;
};
create required property public_key: std::bytes {
create constraint exclusive;
};

create trigger email_shares_user_handle after insert for each do (
std::assert(
__new__.user_handle = (
select detached ext::auth::WebAuthnFactor
filter .email = __new__.email
and not .id = __new__.id
).user_handle,
message := "user_handle must be the same for a given email"
)
);
create constraint exclusive on ((.email, .credential_id));
};

Expand All @@ -92,6 +100,16 @@ CREATE EXTENSION PACKAGE auth VERSION '1.0' {
create constraint exclusive on ((.user_handle, .email, .challenge));
};

create type ext::auth::WebAuthnAuthenticationChallenge
extending ext::auth::Auditable {
create required property challenge: std::bytes {
create constraint exclusive;
};
create required link factor: ext::auth::WebAuthnFactor {
create constraint exclusive;
};
};

create type ext::auth::PKCEChallenge extending ext::auth::Auditable {
create required property challenge: std::str {
create constraint exclusive;
Expand Down
32 changes: 32 additions & 0 deletions edb/server/protocol/auth_ext/_static/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,35 @@ export function encodeBase64Url(bytes) {
.replace(/\//g, "_")
.replace(/=/g, "");
}

/**
* Parse an HTTP Response object. Allows passing in custom handlers for
* different status codes and error.type values
*
* @param {Response} response
* @param {Function[]=} handlers
*/
export async function parseResponseAsJSON(response, handlers = []) {
const bodyText = await response.text();

if (!response.ok) {
let error;
try {
error = JSON.parse(bodyText);
} catch (e) {
throw new Error(
`Failed to parse body as JSON. Status: ${response.status} ${response.statusText}. Body: ${bodyText}`
);
}

for (const handler of handlers) {
handler(response, error);
}

throw new Error(
`Response was not OK. Status: ${response.status} ${response.statusText}. Body: ${bodyText}`
);
}

return response.json();
}
202 changes: 202 additions & 0 deletions edb/server/protocol/auth_ext/_static/webauthn-authenticate.js
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");
},
]);
}
18 changes: 18 additions & 0 deletions edb/server/protocol/auth_ext/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,3 +182,21 @@ def __init__(
self.user_handle = base64.b64decode(user_handle)
self.credential_id = base64.b64decode(credential_id)
self.public_key = base64.b64decode(public_key)


@dataclasses.dataclass
class WebAuthnAuthenticationChallenge:
id: str
created_at: datetime.datetime
modified_at: datetime.datetime
challenge: bytes
factor: WebAuthnFactor

def __init__(self, *, id, created_at, modified_at, challenge, factor):
self.id = id
self.created_at = created_at
self.modified_at = modified_at
self.challenge = base64.b64decode(challenge)
self.factor = (
WebAuthnFactor(**factor) if isinstance(factor, dict) else factor
)
Loading

0 comments on commit bbec7fc

Please sign in to comment.