diff --git a/packages/auth-core/package.json b/packages/auth-core/package.json index f973dc6bc..274ddf780 100644 --- a/packages/auth-core/package.json +++ b/packages/auth-core/package.json @@ -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": [ diff --git a/packages/auth-core/src/consts.ts b/packages/auth-core/src/consts.ts index 335b25f4c..e7098a9df 100644 --- a/packages/auth-core/src/consts.ts +++ b/packages/auth-core/src/consts.ts @@ -12,3 +12,4 @@ export type BuiltinOAuthProviderNames = (typeof builtinOAuthProviderNames)[number]; export const emailPasswordProviderName = "builtin::local_emailpassword"; +export const webAuthnProviderName = "builtin::local_webauthn"; diff --git a/packages/auth-core/src/core.ts b/packages/auth-core/src/core.ts index f89731d0b..fdf882a07 100644 --- a/packages/auth-core/src/core.ts +++ b/packages/auth-core/src/core.ts @@ -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 */ @@ -40,40 +44,16 @@ export class Auth { } /** @internal */ - public async _get(path: string): Promise { - 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( + path: string, + searchParams?: Record + ): Promise { + return requestGET(new URL(path, this.baseUrl).href, searchParams); } /** @internal */ - public async _post( - path: string, - body?: any - ): Promise { - 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(path: string, body?: object): Promise { + return requestPOST(new URL(path, this.baseUrl).href, body); } async createPKCESession() { @@ -82,12 +62,72 @@ export class Auth { } getToken(code: string, verifier: string): Promise { - return this._get( - `token?${new URLSearchParams({ - code, + return this._get(`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 { + const { challenge, verifier } = await pkce.createVerifierChallengePair(); + const result = await this._post("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 { + 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) { @@ -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 { const { challenge, verifier } = await pkce.createVerifierChallengePair(); - const result = await this._post< - { code: string } | { verification_email_sent_at: string } - >("register", { + const result = await this._post("register", { provider: emailPasswordProviderName, challenge, email, diff --git a/packages/auth-core/src/crypto.ts b/packages/auth-core/src/crypto.ts index d18a7c88d..03ef01cf0 100644 --- a/packages/auth-core/src/crypto.ts +++ b/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-_"; @@ -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 { diff --git a/packages/auth-core/src/index.ts b/packages/auth-core/src/index.ts index 0e6d6c87a..5ab5469b0 100644 --- a/packages/auth-core/src/index.ts +++ b/packages/auth-core/src/index.ts @@ -1,3 +1,4 @@ export * from "./core"; export * from "./pkce"; export * from "./consts"; +export * from "./types"; diff --git a/packages/auth-core/src/types.ts b/packages/auth-core/src/types.ts new file mode 100644 index 000000000..25a748695 --- /dev/null +++ b/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 }; diff --git a/packages/auth-core/src/utils.ts b/packages/auth-core/src/utils.ts new file mode 100644 index 000000000..aa3d2a405 --- /dev/null +++ b/packages/auth-core/src/utils.ts @@ -0,0 +1,79 @@ +export async function requestGET( + href: string, + searchParams?: Record, + onFailure?: (errorMessage: string) => Promise +): Promise { + const url = new URL(href); + if (searchParams) { + for (const [key, value] of Object.entries(searchParams)) { + url.searchParams.append(key, value); + } + } + + try { + const response = await fetch(url, { + method: "GET", + headers: { + Accept: "application/json", + }, + }); + + if (!response.ok) { + const bodyText = await response.text(); + if (onFailure) { + return onFailure(bodyText); + } + throw new Error(`Failed to fetch ${href}: ${bodyText}`); + } + + if (response.headers.get("content-type")?.includes("application/json")) { + return response.json(); + } + + return response.text() as ResponseT; + } catch (err) { + if (onFailure) { + return onFailure((err as Error).message); + } + throw err; + } +} + +export async function requestPOST( + href: string, + body?: object, + onFailure?: (errorMessage: string) => Promise +): Promise { + try { + const response = await fetch(href, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + ...(body != null + ? { + body: JSON.stringify(body), + } + : undefined), + }); + + if (!response.ok) { + const bodyText = await response.text(); + if (onFailure) { + return onFailure(bodyText); + } + throw new Error(`Failed to fetch ${href}: ${bodyText}`); + } + + if (response.headers.get("content-type")?.includes("application/json")) { + return response.json(); + } + return response.text() as ResponseT; + } catch (err) { + if (onFailure) { + return onFailure((err as Error).message); + } + throw err; + } +} diff --git a/packages/auth-core/src/webauthn.ts b/packages/auth-core/src/webauthn.ts new file mode 100644 index 000000000..4d3cf7bc4 --- /dev/null +++ b/packages/auth-core/src/webauthn.ts @@ -0,0 +1,159 @@ +import { base64UrlToBytes, bytesToBase64Url } from "./crypto"; +import { webAuthnProviderName } from "./consts"; +import { requestGET, requestPOST } from "./utils"; +import type { + PublicKeyCredentialCreationOptionsJSON, + PublicKeyCredentialRequestOptionsJSON, + SignupResponse, + TokenData, +} from "./types"; + +interface WebAuthnClientOptions { + signupOptionsUrl: string; + signupUrl: string; + signinOptionsUrl: string; + signinUrl: string; + verifyUrl: string; +} + +export class WebAuthnClient { + private readonly signupOptionsUrl: string; + private readonly signupUrl: string; + private readonly signinOptionsUrl: string; + private readonly signinUrl: string; + private readonly verifyUrl: string; + + constructor(options: WebAuthnClientOptions) { + this.signupOptionsUrl = options.signupOptionsUrl; + this.signupUrl = options.signupUrl; + this.signinOptionsUrl = options.signinOptionsUrl; + this.signinUrl = options.signinUrl; + this.verifyUrl = options.verifyUrl; + } + + public async signUp(email: string): Promise { + const options = await requestGET( + this.signupOptionsUrl, + { email }, + async (errorMessage) => { + throw new Error(`Failed to get sign-up options: ${errorMessage}`); + } + ); + + const userHandle = options.user.id; + const credentials = (await navigator.credentials.create({ + publicKey: { + ...options, + challenge: base64UrlToBytes(options.challenge), + user: { + ...options.user, + id: base64UrlToBytes(userHandle), + }, + excludeCredentials: options.excludeCredentials?.map((credential) => ({ + ...credential, + id: base64UrlToBytes(credential.id), + })), + }, + })) as PublicKeyCredential | null; + + if (!credentials) { + throw new Error("Failed to create credentials"); + } + + const credentialsResponse = + credentials.response as AuthenticatorAttestationResponse; + const encodedCredentials = { + authenticatorAttachment: credentials.authenticatorAttachment, + clientExtensionResults: credentials.getClientExtensionResults(), + id: credentials.id, + rawId: bytesToBase64Url(new Uint8Array(credentials.rawId)), + response: { + ...credentialsResponse, + attestationObject: bytesToBase64Url( + new Uint8Array(credentialsResponse.attestationObject) + ), + clientDataJSON: bytesToBase64Url( + new Uint8Array(credentialsResponse.clientDataJSON) + ), + }, + type: credentials.type, + }; + + return await requestPOST( + this.signupUrl, + { + email, + credentials: encodedCredentials, + provider: webAuthnProviderName, + verify_url: this.verifyUrl, + user_handle: userHandle, + }, + async (errorMessage) => { + throw new Error(`Failed to sign up: ${errorMessage}`); + } + ); + } + + async signIn(email: string): Promise { + const options = await requestGET( + this.signinOptionsUrl, + { email }, + async (errorMessage) => { + throw new Error(`Failed to get sign-in options: ${errorMessage}`); + } + ); + + const assertion = (await navigator.credentials.get({ + publicKey: { + ...options, + challenge: base64UrlToBytes(options.challenge), + allowCredentials: options.allowCredentials?.map((credential) => ({ + ...credential, + id: base64UrlToBytes(credential.id), + })), + }, + })) as PublicKeyCredential; + + if (!assertion) { + throw new Error("Failed to sign in"); + } + + const assertionResponse = + assertion.response as AuthenticatorAssertionResponse; + + const encodedAssertion = { + type: assertion.type, + id: assertion.id, + authenticatorAttachments: assertion.authenticatorAttachment, + clientExtensionResults: assertion.getClientExtensionResults(), + rawId: bytesToBase64Url(new Uint8Array(assertion.rawId)), + response: { + authenticatorData: bytesToBase64Url( + new Uint8Array(assertionResponse.authenticatorData) + ), + clientDataJSON: bytesToBase64Url( + new Uint8Array(assertionResponse.clientDataJSON) + ), + signature: bytesToBase64Url( + new Uint8Array(assertionResponse.signature) + ), + userHandle: assertionResponse.userHandle + ? bytesToBase64Url(new Uint8Array(assertionResponse.userHandle)) + : null, + }, + }; + + return await requestPOST( + this.signinUrl, + { + email, + assertion: encodedAssertion, + verify_url: this.verifyUrl, + provider: webAuthnProviderName, + }, + (errorMessage: string) => { + throw new Error(`Failed to sign in: ${errorMessage}`); + } + ); + } +} diff --git a/packages/auth-core/test/crypto.test.ts b/packages/auth-core/test/crypto.test.ts index 8f38a1547..3dbd03b87 100644 --- a/packages/auth-core/test/crypto.test.ts +++ b/packages/auth-core/test/crypto.test.ts @@ -1,5 +1,10 @@ import crypto from "node:crypto"; -import { bytesToBase64Url, sha256, randomBytes } from "../src/crypto"; +import { + bytesToBase64Url, + base64UrlToBytes, + sha256, + randomBytes, +} from "../src/crypto"; describe("crypto", () => { describe("bytesToBase64Url", () => { @@ -11,6 +16,28 @@ describe("crypto", () => { }); }); + describe("base64UrlToBytes", () => { + test("Equivalent to Buffer implementation", () => { + for (let i = 0; i < 100; i++) { + const buffer = crypto.randomBytes(32); + const encoded = buffer.toString("base64url"); + expect(new Uint8Array(Buffer.from(encoded, "base64url"))).toEqual( + base64UrlToBytes(encoded) + ); + } + }); + }); + + describe("round trip base64url", () => { + test("bytesToBase64Url and base64UrlToBytes", () => { + for (let i = 0; i < 100; i++) { + const buffer = new Uint8Array(crypto.randomBytes(32)); + const base64url = bytesToBase64Url(buffer); + expect(buffer).toEqual(base64UrlToBytes(base64url)); + } + }); + }); + describe("sha256", () => { test("Equivalent to Node crypto SHA256 implementation", async () => { for (let i = 0; i < 100; i++) {