Skip to content

Commit

Permalink
Add WebAuthn sign in and sign up flows (#881)
Browse files Browse the repository at this point in the history
  • Loading branch information
scotttrinh committed Mar 6, 2024
1 parent 5f8d3fc commit ce834ee
Show file tree
Hide file tree
Showing 9 changed files with 476 additions and 58 deletions.
10 changes: 10 additions & 0 deletions packages/auth-core/package.json
Expand Up @@ -10,6 +10,16 @@
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./webauthn": {
"types": "./dist/webauthn.d.ts",
"default": "./dist/webauthn.js"
}
},
"license": "Apache-2.0",
"sideEffects": false,
"files": [
Expand Down
1 change: 1 addition & 0 deletions packages/auth-core/src/consts.ts
Expand Up @@ -12,3 +12,4 @@ export type BuiltinOAuthProviderNames =
(typeof builtinOAuthProviderNames)[number];

export const emailPasswordProviderName = "builtin::local_emailpassword";
export const webAuthnProviderName = "builtin::local_webauthn";
133 changes: 84 additions & 49 deletions packages/auth-core/src/core.ts
Expand Up @@ -6,14 +6,18 @@ import * as pkce from "./pkce";
import {
type BuiltinOAuthProviderNames,
emailPasswordProviderName,
webAuthnProviderName,
} from "./consts";

export interface TokenData {
auth_token: string;
identity_id: string | null;
provider_token: string | null;
provider_refresh_token: string | null;
}
import { requestGET, requestPOST } from "./utils";
import type {
RegistrationResponseJSON,
AuthenticationResponseJSON,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
TokenData,
RegistrationResponse,
SignupResponse,
} from "./types";

export class Auth {
/** @internal */
Expand All @@ -40,40 +44,16 @@ export class Auth {
}

/** @internal */
public async _get<T extends any = unknown>(path: string): Promise<T> {
const res = await fetch(new URL(path, this.baseUrl), {
method: "get",
});
if (!res.ok) {
throw new Error(await res.text());
}
if (res.headers.get("content-type")?.startsWith("application/json")) {
return res.json();
}
return null as any;
public async _get<T = unknown>(
path: string,
searchParams?: Record<string, string>
): Promise<T> {
return requestGET<T>(new URL(path, this.baseUrl).href, searchParams);
}

/** @internal */
public async _post<T extends any = unknown>(
path: string,
body?: any
): Promise<T> {
const res = await fetch(new URL(path, this.baseUrl), {
method: "post",
...(body != null
? {
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
}
: undefined),
});
if (!res.ok) {
throw new Error(await res.text());
}
if (res.headers.get("content-type")?.startsWith("application/json")) {
return res.json();
}
return null as any;
public async _post<T = unknown>(path: string, body?: object): Promise<T> {
return requestPOST<T>(new URL(path, this.baseUrl).href, body);
}

async createPKCESession() {
Expand All @@ -82,12 +62,72 @@ export class Auth {
}

getToken(code: string, verifier: string): Promise<TokenData> {
return this._get<TokenData>(
`token?${new URLSearchParams({
code,
return this._get<TokenData>(`token`, { code, verifier });
}

getWebAuthnSignupOptionsUrl(email: string) {
const url = new URL(`webauthn/register/options`, this.baseUrl);
url.searchParams.append("email", email);
return url.href;
}

async signupWithWebAuthn(
email: string,
credentials: RegistrationResponseJSON,
verifyUrl: string,
userHandle: string
): Promise<SignupResponse> {
const { challenge, verifier } = await pkce.createVerifierChallengePair();
const result = await this._post<RegistrationResponse>("webauthn/register", {
provider: webAuthnProviderName,
challenge,
credentials,
email,
verify_url: verifyUrl,
user_handle: userHandle,
});

if ("code" in result) {
return {
status: "complete",
verifier,
}).toString()}`
tokenData: await this.getToken(result.code, verifier),
};
} else {
return { status: "verificationRequired", verifier };
}
}

getWebAuthnSigninOptionsUrl(email: string) {
const url = new URL(`webauthn/authenticate/options`, this.baseUrl);
url.searchParams.append("email", email);
return url.href;
}

async signinWithWebAuthn(
email: string,
assertion: AuthenticationResponseJSON
): Promise<TokenData> {
const { challenge, verifier } = await pkce.createVerifierChallengePair();
const { code } = await this._post<{ code: string }>(
"webauthn/authenticate",
{
provider: webAuthnProviderName,
challenge,
email,
assertion,
}
);

return this.getToken(code, verifier);
}

async verifyWebAuthnSignup(verificationToken: string, verifier: string) {
const { code } = await this._post<{ code: string }>("verify", {
provider: webAuthnProviderName,
verification_token: verificationToken,
});
return this.getToken(code, verifier);
}

async signinWithEmailPassword(email: string, password: string) {
Expand All @@ -105,14 +145,9 @@ export class Auth {
email: string,
password: string,
verifyUrl: string
): Promise<
| { status: "complete"; verifier: string; tokenData: TokenData }
| { status: "verificationRequired"; verifier: string }
> {
): Promise<SignupResponse> {
const { challenge, verifier } = await pkce.createVerifierChallengePair();
const result = await this._post<
{ code: string } | { verification_email_sent_at: string }
>("register", {
const result = await this._post<RegistrationResponse>("register", {
provider: emailPasswordProviderName,
challenge,
email,
Expand Down
36 changes: 28 additions & 8 deletions packages/auth-core/src/crypto.ts
@@ -1,11 +1,3 @@
/* eslint @typescript-eslint/no-var-requires: ["off"] */

// TODO: Drop when Node 18 is EOL: 2025-04-30
if (!globalThis.crypto) {
// tslint:disable-next-line: no-var-requires
globalThis.crypto = require("node:crypto").webcrypto;
}

const BASE64_URL_CHARS =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";

Expand Down Expand Up @@ -35,6 +27,34 @@ export function bytesToBase64Url(bytes: Uint8Array): string {
return base64url;
}

export function base64UrlToBytes(base64url: string): Uint8Array {
// Add padding if necessary
let paddedBase64url = base64url;
const padLength = base64url.length % 4;
if (padLength) {
paddedBase64url += "=".repeat(4 - padLength);
}

const outputArray = [];

for (let i = 0; i < paddedBase64url.length; i += 4) {
const enc1 = BASE64_URL_CHARS.indexOf(paddedBase64url.charAt(i));
const enc2 = BASE64_URL_CHARS.indexOf(paddedBase64url.charAt(i + 1));
const enc3 = BASE64_URL_CHARS.indexOf(paddedBase64url.charAt(i + 2));
const enc4 = BASE64_URL_CHARS.indexOf(paddedBase64url.charAt(i + 3));

const b1 = (enc1 << 2) | (enc2 >> 4);
const b2 = ((enc2 & 15) << 4) | (enc3 >> 2);
const b3 = ((enc3 & 3) << 6) | enc4;

outputArray.push(b1);
if (enc3 !== -1) outputArray.push(b2);
if (enc4 !== -1) outputArray.push(b3);
}

return new Uint8Array(outputArray);
}

export async function sha256(
source: BufferSource | string
): Promise<Uint8Array> {
Expand Down
1 change: 1 addition & 0 deletions packages/auth-core/src/index.ts
@@ -1,3 +1,4 @@
export * from "./core";
export * from "./pkce";
export * from "./consts";
export * from "./types";
86 changes: 86 additions & 0 deletions packages/auth-core/src/types.ts
@@ -0,0 +1,86 @@
export interface PublicKeyCredentialCreationOptionsJSON {
rp: PublicKeyCredentialRpEntity;
user: PublicKeyCredentialUserEntityJSON;
challenge: Base64URLString;
pubKeyCredParams: PublicKeyCredentialParameters[];
timeout?: number;
excludeCredentials?: PublicKeyCredentialDescriptorJSON[];
authenticatorSelection?: AuthenticatorSelectionCriteria;
attestation?: AttestationConveyancePreference;
extensions?: AuthenticationExtensionsClientInputs;
}

export interface PublicKeyCredentialRequestOptionsJSON {
challenge: Base64URLString;
timeout?: number;
rpId?: string;
allowCredentials?: PublicKeyCredentialDescriptorJSON[];
userVerification?: UserVerificationRequirement;
extensions?: AuthenticationExtensionsClientInputs;
}

export interface RegistrationResponseJSON {
id: Base64URLString;
rawId: Base64URLString;
response: AuthenticatorAttestationResponseJSON;
authenticatorAttachment?: AuthenticatorAttachment;
clientExtensionResults: AuthenticationExtensionsClientOutputs;
type: PublicKeyCredentialType;
}

export interface AuthenticationResponseJSON {
id: Base64URLString;
rawId: Base64URLString;
response: AuthenticatorAssertionResponseJSON;
authenticatorAttachment?: AuthenticatorAttachment;
clientExtensionResults: AuthenticationExtensionsClientOutputs;
type: PublicKeyCredentialType;
}

type Base64URLString = string;

export interface PublicKeyCredentialUserEntityJSON {
id: Base64URLString;
name: string;
displayName: string;
}

export interface PublicKeyCredentialDescriptorJSON {
id: Base64URLString;
type: PublicKeyCredentialType;
transports?: AuthenticatorTransport[];
}

export interface AuthenticatorAttestationResponseJSON {
clientDataJSON: Base64URLString;
attestationObject: Base64URLString;
// Optional in L2, but becomes required in L3. Play it safe until L3 becomes Recommendation
authenticatorData?: Base64URLString;
// Optional in L2, but becomes required in L3. Play it safe until L3 becomes Recommendation
transports?: AuthenticatorTransport[];
// Optional in L2, but becomes required in L3. Play it safe until L3 becomes Recommendation
publicKeyAlgorithm?: COSEAlgorithmIdentifier;
publicKey?: Base64URLString;
}

export interface AuthenticatorAssertionResponseJSON {
clientDataJSON: Base64URLString;
authenticatorData: Base64URLString;
signature: Base64URLString;
userHandle?: string;
}

export interface TokenData {
auth_token: string;
identity_id: string | null;
provider_token: string | null;
provider_refresh_token: string | null;
}

export type RegistrationResponse =
| { code: string }
| { verification_email_sent_at: string };

export type SignupResponse =
| { status: "complete"; verifier: string; tokenData: TokenData }
| { status: "verificationRequired"; verifier: string };

0 comments on commit ce834ee

Please sign in to comment.