Skip to content
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 10 commits into from
Feb 21, 2024
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) ?? true,
Copy link
Member

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.

Copy link
Contributor Author

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 for assert.

Copy link
Member

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 in count, for example) then it's expanded out. So assert({true, false}) is executed as {assert(true), assert(false)}, and similarly assert({}) becomes {}.

Copy link
Contributor Author

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:

If the input bool is false, assert raises a QueryAssertionError. Otherwise, this function returns true. (emphasis mine)

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.

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