Skip to content

Commit

Permalink
Add WebAuthn registration flow
Browse files Browse the repository at this point in the history
  • Loading branch information
scotttrinh committed Feb 7, 2024
1 parent bb41d4a commit d404dab
Show file tree
Hide file tree
Showing 13 changed files with 1,284 additions and 436 deletions.
52 changes: 49 additions & 3 deletions edb/lib/ext/auth.edgeql
Original file line number Diff line number Diff line change
Expand Up @@ -55,17 +55,44 @@ CREATE EXTENSION PACKAGE auth VERSION '1.0' {
};

create type ext::auth::EmailFactor extending ext::auth::Factor {
create required property email: str {
create delegated constraint exclusive;
};
create required property email: str;
create property verified_at: std::datetime;
};

create type ext::auth::EmailPasswordFactor
extending ext::auth::EmailFactor {
alter property email {
create constraint exclusive;
};
create required property password_hash: std::str;
};

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

create constraint exclusive on ((.email, .credential_id));
create constraint exclusive on ((.email, .user_handle));
};

create type ext::auth::WebAuthnRegistrationChallenge
extending ext::auth::Auditable {
create required property challenge: std::bytes {
create constraint exclusive;
};
create required property email: std::str;
create required property user_handle: std::bytes;

create constraint exclusive on ((.user_handle, .email, .challenge));
};

create type ext::auth::PKCEChallenge extending ext::auth::Auditable {
create required property challenge: std::str {
create constraint exclusive;
Expand Down Expand Up @@ -201,6 +228,25 @@ CREATE EXTENSION PACKAGE auth VERSION '1.0' {
};
};

create type ext::auth::WebAuthnProviderConfig
extending ext::auth::ProviderConfig {
alter property name {
set default := 'builtin::local_webauthn';
set protected := true;
};

create required property relying_party_origin: std::str {
create annotation std::description :=
"The full origin of the sign-in page including protocol and \
port of the application. If using the built-in UI, this \
should be the origin of the EdgeDB server.";
};

create required property require_verification: std::bool {
set default := true;
};
};

create scalar type ext::auth::FlowType extending std::enum<PKCE, Implicit>;

create type ext::auth::UIConfig extending cfg::ConfigObject {
Expand Down
23 changes: 23 additions & 0 deletions edb/server/protocol/auth_ext/_static/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* Decode a base64url encoded string
* @param {string} base64UrlString
* @returns Uint8Array
*/
export function decodeBase64Url(base64UrlString) {
return Uint8Array.from(
atob(base64UrlString.replace(/-/g, "+").replace(/_/g, "/")),
(c) => c.charCodeAt(0)
);
}

/**
* Encode a Uint8Array to a base64url encoded string
* @param {Uint8Array} bytes
* @returns string
*/
export function encodeBase64Url(bytes) {
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
188 changes: 188 additions & 0 deletions edb/server/protocol/auth_ext/_static/webauthn-register.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { decodeBase64Url, encodeBase64Url } from "./utils.js";

document.addEventListener("DOMContentLoaded", () => {
const registerForm = document.getElementById("email-factor");

if (registerForm === null) {
return;
}

registerForm.addEventListener("submit", async (event) => {
if (event.submitter?.id !== "webauthn-signup") {
return;
}
event.preventDefault();

const formData = new FormData(/** @type {HTMLFormElement} */ registerForm);
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");
const verifyUrl = formData.get("verify_url");

if (redirectTo === null) {
throw new Error("Missing redirect_to parameter");
}

try {
const maybeCode = await register({
email,
provider,
challenge,
verifyUrl,
});

const redirectUrl = new URL(redirectTo);
redirectUrl.searchParams.append("isSignup", "true");
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/register/options",
window.location
);
const WEBAUTHN_REGISTER_URL = new URL("../webauthn/register", window.location);

/**
* Register a new 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
* @param {string} props.verifyUrl - URL to verify email after registration
* @returns {Promise<string | null>} - The PKCE code or null if the application
* requires email verification
*/
export async function register({ email, provider, challenge, verifyUrl }) {
// 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 getCreateOptions(email);

// Register the new credential
const credentials = await navigator.credentials.create({
publicKey: {
...options,
challenge: decodeBase64Url(options.challenge),
user: {
...options.user,
id: decodeBase64Url(options.user.id),
},
},
});

// Register the credentials on the server
const registerResult = await registerCredentials({
email,
credentials,
provider,
challenge,
verifyUrl,
});

return registerResult.code ?? null;
}

/**
* Fetch WebAuthn options from the server
* @param {string} email - Email address to register
* @returns {Promise<globalThis.PublicKeyCredentialCreationOptions>}
*/
async function getCreateOptions(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");
}
}

/**
* Register the credentials on the server
* @param {Object} props
* @param {string} props.email
* @param {Object} props.credentials
* @param {string} props.provider
* @param {string} props.challenge
* @param {string} props.verifyUrl
* @returns {Promise<Object>}
*/
async function registerCredentials(props) {
// Credentials include raw bytes, so need to be encoded as base64url
// for transmission
const encodedCredentials = {
...props.credentials,
rawId: encodeBase64Url(new Uint8Array(props.credentials.rawId)),
response: {
...props.credentials.response,
attestationObject: encodeBase64Url(
new Uint8Array(props.credentials.response.attestationObject)
),
clientDataJSON: encodeBase64Url(
new Uint8Array(props.credentials.response.clientDataJSON)
),
},
};

const registerResponse = await fetch(WEBAUTHN_REGISTER_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: props.email,
credentials: encodedCredentials,
provider: props.provider,
challenge: props.challenge,
verify_url: props.verifyUrl,
}),
});

if (!registerResponse.ok) {
console.error(
"Failed to register WebAuthn credentials:",
registerResponse.statusText
);
console.error(await registerResponse.text());
throw new Error("Failed to register WebAuthn credentials");
}

try {
return await registerResponse.json();
} catch (e) {
console.error("Failed to parse WebAuthn registration result:", e);
throw new Error("Failed to parse WebAuthn registration result");
}
}
26 changes: 26 additions & 0 deletions edb/server/protocol/auth_ext/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

from typing import Optional
from dataclasses import dataclass
import urllib.parse


class UIConfig:
Expand All @@ -36,3 +37,28 @@ class AppDetailsConfig:

class ProviderConfig:
name: str


class WebAuthnProviderConfig(ProviderConfig):
relying_party_origin: str
require_verification: bool


@dataclass
class WebAuthnProvider:
name: str
relying_party_origin: str
require_verification: bool

def __init__(
self, name: str, relying_party_origin: str, require_verification: bool
):
self.name = name
self.relying_party_origin = relying_party_origin
self.require_verification = require_verification
parsed_url = urllib.parse.urlparse(self.relying_party_origin)
if parsed_url.hostname is None:
raise ValueError(
"Invalid relying_party_origin, hostname cannot be None"
)
self.relying_party_id = parsed_url.hostname
42 changes: 42 additions & 0 deletions edb/server/protocol/auth_ext/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@

import dataclasses
import datetime
import base64

from typing import Optional, NamedTuple


Expand Down Expand Up @@ -140,3 +142,43 @@ class ProviderConfig(NamedTuple):
client_id: str
secret: str
additional_scope: Optional[str]


@dataclasses.dataclass
class WebAuthnFactor:
id: str
created_at: datetime.datetime
modified_at: datetime.datetime
identity: LocalIdentity
email: str
verified_at: Optional[datetime.datetime]
user_handle: bytes
credential_id: bytes
public_key: bytes

def __init__(
self,
*,
id,
created_at,
modified_at,
identity,
email,
verified_at,
user_handle,
credential_id,
public_key,
):
self.id = id
self.created_at = created_at
self.modified_at = modified_at
self.identity = (
LocalIdentity(**identity)
if isinstance(identity, dict)
else identity
)
self.email = email
self.verified_at = verified_at
self.user_handle = base64.b64decode(user_handle)
self.credential_id = base64.b64decode(credential_id)
self.public_key = base64.b64decode(public_key)
Loading

0 comments on commit d404dab

Please sign in to comment.