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 sign in and sign up flows #881

Merged
merged 4 commits into from Mar 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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 };