From e189fe4e1e03b4d510e36f3d03ea7a524aab64e0 Mon Sep 17 00:00:00 2001 From: jrmccannon Date: Wed, 13 Mar 2024 15:18:22 -0500 Subject: [PATCH 01/13] formatting page. --- src/passwordless.ts | 334 ++++++++++++++++++++++---------------------- 1 file changed, 167 insertions(+), 167 deletions(-) diff --git a/src/passwordless.ts b/src/passwordless.ts index 3ec6726..841f586 100644 --- a/src/passwordless.ts +++ b/src/passwordless.ts @@ -8,10 +8,10 @@ import { } from './types'; export interface Config { - apiUrl: string; - apiKey: string; - origin: string; - rpid: string; + apiUrl: string; + apiKey: string; + origin: string; + rpid: string; } export class Client { @@ -19,13 +19,13 @@ export class Client { apiUrl: 'https://v4.passwordless.dev', apiKey: '', origin: window.location.origin, - rpid: window.location.hostname + rpid: window.location.hostname, }; private abortController: AbortController = new AbortController(); - constructor(config: AtLeast) { - Object.assign(this.config, config); - } + constructor(config: AtLeast) { + Object.assign(this.config, config); + } /** * Register a new credential to a user @@ -43,25 +43,25 @@ export class Client { return { error: registration.error } } - registration.data.challenge = base64UrlToArrayBuffer(registration.data.challenge); - registration.data.user.id = base64UrlToArrayBuffer(registration.data.user.id); - registration.data.excludeCredentials?.forEach((cred) => { - cred.id = base64UrlToArrayBuffer(cred.id); - }); - - const credential = await navigator.credentials.create({ - publicKey: registration.data, - }) as PublicKeyCredential; - - if (!credential) { - const error = { - from: "client", - errorCode: "failed_create_credential", - title: "Failed to create credential (navigator.credentials.create returned null)", - }; - console.error(error); - return { error }; - } + registration.data.challenge = base64UrlToArrayBuffer(registration.data.challenge); + registration.data.user.id = base64UrlToArrayBuffer(registration.data.user.id); + registration.data.excludeCredentials?.forEach((cred) => { + cred.id = base64UrlToArrayBuffer(cred.id); + }); + + const credential = await navigator.credentials.create({ + publicKey: registration.data, + }) as PublicKeyCredential; + + if (!credential) { + const error = { + from: "client", + errorCode: "failed_create_credential", + title: "Failed to create credential (navigator.credentials.create returned null)", + }; + console.error(error); + return {error}; + } return await this.registerComplete(credential, registration.session, credentialNickname); @@ -122,23 +122,23 @@ export class Client { return this.signin({ discoverable: true }); } - public abort() { - if (this.abortController) { - this.abortController.abort(); - } + public abort() { + if (this.abortController) { + this.abortController.abort(); } + } - public isPlatformSupported(): Promise { - return isPlatformSupported(); - } + public isPlatformSupported(): Promise { + return isPlatformSupported(); + } - public isBrowserSupported(): boolean { - return isBrowserSupported(); - } + public isBrowserSupported(): boolean { + return isBrowserSupported(); + } - public isAutofillSupported(): Promise { - return isAutofillSupported(); - } + public isAutofillSupported(): Promise { + return isAutofillSupported(); + } private async registerBegin(token: string): PromiseResult { const response = await fetch(`${this.config.apiUrl}/register/begin`, { @@ -147,24 +147,24 @@ export class Client { body: JSON.stringify({ token, RPID: this.config.rpid, - Origin: this.config.origin + Origin: this.config.origin, }), }); - const res = await response.json(); - if (response.ok) { - return res; - } + const res = await response.json(); + if (response.ok) { + return res; + } return { error: { ...res, from: "server" } }; } - private async registerComplete( - credential: PublicKeyCredential, - session: string, - credentialNickname: string, - ): PromiseResult { - const attestationResponse = credential.response as AuthenticatorAttestationResponse; + private async registerComplete( + credential: PublicKeyCredential, + session: string, + credentialNickname: string, + ): PromiseResult { + const attestationResponse = credential.response as AuthenticatorAttestationResponse; const response = await fetch(`${this.config.apiUrl}/register/complete`, { method: 'POST', @@ -191,10 +191,10 @@ export class Client { }), }); - const res = await response.json(); - if (response.ok) { - return res; - } + const res = await response.json(); + if (response.ok) { + return res; + } return { error: { ...res, from: "server" } }; } @@ -221,16 +221,16 @@ export class Client { return signin; } - signin.data.challenge = base64UrlToArrayBuffer(signin.data.challenge); - signin.data.allowCredentials?.forEach((cred) => { - cred.id = base64UrlToArrayBuffer(cred.id); - }); + signin.data.challenge = base64UrlToArrayBuffer(signin.data.challenge); + signin.data.allowCredentials?.forEach((cred) => { + cred.id = base64UrlToArrayBuffer(cred.id); + }); - const credential = await navigator.credentials.get({ - publicKey: signin.data, - mediation: 'autofill' in signinMethod ? "conditional" as CredentialMediationRequirement : undefined, // Typescript doesn't know about 'conditational' yet - signal: this.abortController.signal, - }) as PublicKeyCredential; + const credential = await navigator.credentials.get({ + publicKey: signin.data, + mediation: 'autofill' in signinMethod ? "conditional" as CredentialMediationRequirement : undefined, // Typescript doesn't know about 'conditational' yet + signal: this.abortController.signal, + }) as PublicKeyCredential; const response = await this.signinComplete(credential, signin.session); return response; @@ -245,35 +245,35 @@ export class Client { console.error(caughtError); console.error(error); - return { error }; - } + return {error}; + } + } + + private async signinBegin(signinMethod: SigninMethod): PromiseResult { + const response = await fetch(`${this.config.apiUrl}/signin/begin`, { + method: 'POST', + headers: this.createHeaders(), + body: JSON.stringify({ + userId: "userId" in signinMethod ? signinMethod.userId : undefined, + alias: "alias" in signinMethod ? signinMethod.alias : undefined, + RPID: this.config.rpid, + Origin: this.config.origin, + }), + }); + + const res = await response.json(); + if (response.ok) { + return res; } - - private async signinBegin(signinMethod: SigninMethod): PromiseResult { - const response = await fetch(`${this.config.apiUrl}/signin/begin`, { - method: 'POST', - headers: this.createHeaders(), - body: JSON.stringify({ - userId: "userId" in signinMethod ? signinMethod.userId : undefined, - alias: "alias" in signinMethod ? signinMethod.alias : undefined, - RPID: this.config.rpid, - Origin: this.config.origin, - }), - }); - - const res = await response.json(); - if (response.ok) { - return res; - } return { error: { ...res, from: "server" } }; } - private async signinComplete( - credential: PublicKeyCredential, - session: string, - ): PromiseResult { - const assertionResponse = credential.response as AuthenticatorAssertionResponse; + private async signinComplete( + credential: PublicKeyCredential, + session: string, + ): PromiseResult { + const assertionResponse = credential.response as AuthenticatorAssertionResponse; const response = await fetch(`${this.config.apiUrl}/signin/complete`, { method: 'POST', @@ -302,123 +302,123 @@ export class Client { }), }); - const res = await response.json(); - if (response.ok) { - return res; - } + const res = await response.json(); + if (response.ok) { + return res; + } return { error: { ...res, from: "server" } }; } - private handleAbort() { - this.abort(); - this.abortController = new AbortController(); - } + private handleAbort() { + this.abort(); + this.abortController = new AbortController(); + } - private assertBrowserSupported(): void { - if (!isBrowserSupported()) { - throw new Error('WebAuthn and PublicKeyCredentials are not supported on this browser/device'); - } + private assertBrowserSupported(): void { + if (!isBrowserSupported()) { + throw new Error('WebAuthn and PublicKeyCredentials are not supported on this browser/device'); } + } - private createHeaders(): Record { - return { - ApiKey: this.config.apiKey, - 'Content-Type': 'application/json', - 'Client-Version': 'js-1.1.0' - }; - } + private createHeaders(): Record { + return { + ApiKey: this.config.apiKey, + 'Content-Type': 'application/json', + 'Client-Version': 'js-1.1.0' + }; + } } export async function isPlatformSupported(): Promise { - if (!isBrowserSupported()) return false; - return PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); + if (!isBrowserSupported()) return false; + return PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); } export function isBrowserSupported(): boolean { - return window.PublicKeyCredential !== undefined && typeof window.PublicKeyCredential === 'function'; + return window.PublicKeyCredential !== undefined && typeof window.PublicKeyCredential === 'function'; } export async function isAutofillSupported(): Promise { - const PublicKeyCredential = window.PublicKeyCredential as any; // Typescript lacks support for this - if (!PublicKeyCredential.isConditionalMediationAvailable) return false; - return PublicKeyCredential.isConditionalMediationAvailable() as Promise; + const PublicKeyCredential = window.PublicKeyCredential as any; // Typescript lacks support for this + if (!PublicKeyCredential.isConditionalMediationAvailable) return false; + return PublicKeyCredential.isConditionalMediationAvailable() as Promise; } function base64ToBase64Url(base64: string): string { - return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=*$/g, ''); + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=*$/g, ''); } function base64UrlToBase64(base64Url: string): string { - return base64Url.replace(/-/g, '+').replace(/_/g, '/'); + return base64Url.replace(/-/g, '+').replace(/_/g, '/'); } function base64UrlToArrayBuffer(base64UrlString: string | BufferSource): ArrayBuffer { - // improvement: Remove BufferSource-type and add proper types upstream - if (typeof base64UrlString !== 'string') { - const msg = "Cannot convert from Base64Url to ArrayBuffer: Input was not of type string"; - console.error(msg, base64UrlString); - throw new TypeError(msg); - } - - const base64Unpadded = base64UrlToBase64(base64UrlString); - const paddingNeeded = (4 - (base64Unpadded.length % 4)) % 4; - const base64Padded = base64Unpadded.padEnd(base64Unpadded.length + paddingNeeded, "="); - - const binary = window.atob(base64Padded); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - - return bytes; + // improvement: Remove BufferSource-type and add proper types upstream + if (typeof base64UrlString !== 'string') { + const msg = "Cannot convert from Base64Url to ArrayBuffer: Input was not of type string"; + console.error(msg, base64UrlString); + throw new TypeError(msg); + } + + const base64Unpadded = base64UrlToBase64(base64UrlString); + const paddingNeeded = (4 - (base64Unpadded.length % 4)) % 4; + const base64Padded = base64Unpadded.padEnd(base64Unpadded.length + paddingNeeded, "="); + + const binary = window.atob(base64Padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + + return bytes; } function arrayBufferToBase64Url(buffer: ArrayBuffer | Uint8Array): string { - const uint8Array = (() => { - if (Array.isArray(buffer)) return Uint8Array.from(buffer); - if (buffer instanceof ArrayBuffer) return new Uint8Array(buffer); - if (buffer instanceof Uint8Array) return buffer; - - const msg = "Cannot convert from ArrayBuffer to Base64Url. Input was not of type ArrayBuffer, Uint8Array or Array"; - console.error(msg, buffer); - throw new Error(msg); - })(); - - let string = ''; - for (let i = 0; i < uint8Array.byteLength; i++) { - string += String.fromCharCode(uint8Array[i]); - } - - const base64String = window.btoa(string); - return base64ToBase64Url(base64String); + const uint8Array = (() => { + if (Array.isArray(buffer)) return Uint8Array.from(buffer); + if (buffer instanceof ArrayBuffer) return new Uint8Array(buffer); + if (buffer instanceof Uint8Array) return buffer; + + const msg = "Cannot convert from ArrayBuffer to Base64Url. Input was not of type ArrayBuffer, Uint8Array or Array"; + console.error(msg, buffer); + throw new Error(msg); + })(); + + let string = ''; + for (let i = 0; i < uint8Array.byteLength; i++) { + string += String.fromCharCode(uint8Array[i]); + } + + const base64String = window.btoa(string); + return base64ToBase64Url(base64String); } type ErrorWithMessage = { - message: string + message: string } function isErrorWithMessage(error: unknown): error is ErrorWithMessage { - return ( - typeof error === 'object' && - error !== null && - 'message' in error && - typeof (error as Record).message === 'string' - ) + return ( + typeof error === 'object' && + error !== null && + 'message' in error && + typeof (error as Record).message === 'string' + ) } function toErrorWithMessage(maybeError: unknown): ErrorWithMessage { - if (isErrorWithMessage(maybeError)) return maybeError - - try { - return new Error(JSON.stringify(maybeError)) - } catch { - // fallback in case there's an error stringifying the maybeError - // like with circular references for example. - return new Error(String(maybeError)) - } + if (isErrorWithMessage(maybeError)) return maybeError + + try { + return new Error(JSON.stringify(maybeError)) + } catch { + // fallback in case there's an error stringifying the maybeError + // like with circular references for example. + return new Error(String(maybeError)) + } } function getErrorMessage(error: unknown) { - return toErrorWithMessage(error).message + return toErrorWithMessage(error).message } \ No newline at end of file From f14b8ca6e9ef9be9d714435f920b7d48974df85a Mon Sep 17 00:00:00 2001 From: jrmccannon Date: Wed, 13 Mar 2024 15:20:06 -0500 Subject: [PATCH 02/13] moved transform into the signInBegin method to clean up method noise. --- src/passwordless.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/passwordless.ts b/src/passwordless.ts index 841f586..c697a31 100644 --- a/src/passwordless.ts +++ b/src/passwordless.ts @@ -221,11 +221,6 @@ export class Client { return signin; } - signin.data.challenge = base64UrlToArrayBuffer(signin.data.challenge); - signin.data.allowCredentials?.forEach((cred) => { - cred.id = base64UrlToArrayBuffer(cred.id); - }); - const credential = await navigator.credentials.get({ publicKey: signin.data, mediation: 'autofill' in signinMethod ? "conditional" as CredentialMediationRequirement : undefined, // Typescript doesn't know about 'conditational' yet @@ -263,7 +258,15 @@ export class Client { const res = await response.json(); if (response.ok) { - return res; + return { + ...res, + data: { + ...res.data, + allowCredentials: res.data.allowCredentials?.map((cred: PublicKeyCredentialDescriptor) => { + return {...cred, id: base64UrlToArrayBuffer(cred.id)}; + }) + } + }; } return { error: { ...res, from: "server" } }; From 4a5dc3b0fbd281e6d1cfadcfcd70c2218fc34170 Mon Sep 17 00:00:00 2001 From: jrmccannon Date: Wed, 13 Mar 2024 16:25:49 -0500 Subject: [PATCH 03/13] added step up. --- src/passwordless.ts | 68 +++++++++++++++++++++++++++++++++++++++++++++ src/types.ts | 28 +++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/src/passwordless.ts b/src/passwordless.ts index c697a31..a578f5c 100644 --- a/src/passwordless.ts +++ b/src/passwordless.ts @@ -4,6 +4,8 @@ import { RegisterBeginResponse, SigninBeginResponse, SigninMethod, + StepupContext, + StepupRequest, TokenResponse } from './types'; @@ -244,6 +246,72 @@ export class Client { } } + public async stepup(stepup: StepupRequest) { + try { + this.assertBrowserSupported(); + this.handleAbort(); + + if (!stepup.signinMethod) { + stepup.signinMethod = {discoverable: true}; + } + + const signin = await this.signinBegin(stepup.signinMethod); + + if (signin.error) { + return signin; + } + + const credential = await navigator.credentials.get({ + publicKey: signin.data, + mediation: 'autofill' in stepup.signinMethod ? "conditional" as CredentialMediationRequirement : undefined, // Typescript doesn't know about 'conditional' yet + signal: this.abortController.signal, + }) as PublicKeyCredential; + + return await this.stepupComplete(credential, signin.session, stepup.context); + } catch (caughtError: any) { + const errorMessage = getErrorMessage(caughtError); + const error = { + from: "client", + errorCode: "unknown", + title: errorMessage, + }; + console.error(caughtError); + console.error(error); + + return {error}; + } + } + + private async stepupComplete(credential: PublicKeyCredential, session: string, context: StepupContext) { + const assertionResponse = credential.response as AuthenticatorAssertionResponse; + + const response = await fetch(`${this.config.apiUrl}/stepup`, { + method: 'POST', + headers: this.createHeaders(), + body: JSON.stringify({ + session: session, + context: context, + response: { + id: credential.id, + type: credential.type, + rawId: arrayBufferToBase64Url(new Uint8Array(credential.rawId)), + extensions: credential.getClientExtensionResults(), + response: { + authenticatorData: arrayBufferToBase64Url(assertionResponse.authenticatorData), + clientDataJson: arrayBufferToBase64Url(assertionResponse.clientDataJSON), + signature: arrayBufferToBase64Url(assertionResponse.signature), + }, + }, + RPID: this.config.rpid, + Origin: this.config.origin, + }) + }); + + return response.ok + ? response.json() + : {error: {...response, from: "server"}}; + } + private async signinBegin(signinMethod: SigninMethod): PromiseResult { const response = await fetch(`${this.config.apiUrl}/signin/begin`, { method: 'POST', diff --git a/src/types.ts b/src/types.ts index 582cf20..2378ecc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,7 +1,35 @@ export type AtLeast = Partial & Pick; +/** + * Represents a sign-in method. + */ export type SigninMethod = { userId: string } | { alias: string } | { autofill: boolean } | { discoverable: boolean }; +/** + * + */ +export interface StepupRequest { + signinMethod: SigninMethod; + context: StepupContext; +} + +/** + * Represents the context for step-up authentication. + * @interface + */ +export interface StepupContext { + /** + * + */ + context: string; + /** + * Time to Live (TTL) in seconds. + * + * @type {number} + */ + ttl: number; +} + export type RegisterBeginResponse = { session: string; data: PublicKeyCredentialCreationOptions; From 94f3e109706ab9c2738d687d8427b2519c9ff229 Mon Sep 17 00:00:00 2001 From: jrmccannon Date: Mon, 22 Apr 2024 15:49:30 -0500 Subject: [PATCH 04/13] updated step up to new endpoints. --- src/passwordless.ts | 47 +++++++++++---------------------------------- src/types.ts | 19 +----------------- 2 files changed, 12 insertions(+), 54 deletions(-) diff --git a/src/passwordless.ts b/src/passwordless.ts index a578f5c..1c4b04e 100644 --- a/src/passwordless.ts +++ b/src/passwordless.ts @@ -4,7 +4,6 @@ import { RegisterBeginResponse, SigninBeginResponse, SigninMethod, - StepupContext, StepupRequest, TokenResponse } from './types'; @@ -246,16 +245,16 @@ export class Client { } } - public async stepup(stepup: StepupRequest) { + public async stepup(stepup: StepupRequest, callback: (token: string | undefined, args?: any) => any) { try { this.assertBrowserSupported(); this.handleAbort(); if (!stepup.signinMethod) { - stepup.signinMethod = {discoverable: true}; + throw new Error("You need to provide the signInMethod"); } - const signin = await this.signinBegin(stepup.signinMethod); + const signin = await this.signinBegin(stepup.signinMethod, stepup.purpose); if (signin.error) { return signin; @@ -267,7 +266,11 @@ export class Client { signal: this.abortController.signal, }) as PublicKeyCredential; - return await this.stepupComplete(credential, signin.session, stepup.context); + const signInComplete = await this.signinComplete(credential, signin.session); + + return callback === undefined || callback === null + ? signInComplete + : callback(signInComplete.token); } catch (caughtError: any) { const errorMessage = getErrorMessage(caughtError); const error = { @@ -282,37 +285,7 @@ export class Client { } } - private async stepupComplete(credential: PublicKeyCredential, session: string, context: StepupContext) { - const assertionResponse = credential.response as AuthenticatorAssertionResponse; - - const response = await fetch(`${this.config.apiUrl}/stepup`, { - method: 'POST', - headers: this.createHeaders(), - body: JSON.stringify({ - session: session, - context: context, - response: { - id: credential.id, - type: credential.type, - rawId: arrayBufferToBase64Url(new Uint8Array(credential.rawId)), - extensions: credential.getClientExtensionResults(), - response: { - authenticatorData: arrayBufferToBase64Url(assertionResponse.authenticatorData), - clientDataJson: arrayBufferToBase64Url(assertionResponse.clientDataJSON), - signature: arrayBufferToBase64Url(assertionResponse.signature), - }, - }, - RPID: this.config.rpid, - Origin: this.config.origin, - }) - }); - - return response.ok - ? response.json() - : {error: {...response, from: "server"}}; - } - - private async signinBegin(signinMethod: SigninMethod): PromiseResult { + private async signinBegin(signinMethod: SigninMethod, purpose?: string): PromiseResult { const response = await fetch(`${this.config.apiUrl}/signin/begin`, { method: 'POST', headers: this.createHeaders(), @@ -321,6 +294,7 @@ export class Client { alias: "alias" in signinMethod ? signinMethod.alias : undefined, RPID: this.config.rpid, Origin: this.config.origin, + purpose: purpose }), }); @@ -330,6 +304,7 @@ export class Client { ...res, data: { ...res.data, + challenge: base64UrlToArrayBuffer(res.data.challenge), allowCredentials: res.data.allowCredentials?.map((cred: PublicKeyCredentialDescriptor) => { return {...cred, id: base64UrlToArrayBuffer(cred.id)}; }) diff --git a/src/types.ts b/src/types.ts index 2378ecc..376633f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,24 +10,7 @@ export type SigninMethod = { userId: string } | { alias: string } | { autofill: */ export interface StepupRequest { signinMethod: SigninMethod; - context: StepupContext; -} - -/** - * Represents the context for step-up authentication. - * @interface - */ -export interface StepupContext { - /** - * - */ - context: string; - /** - * Time to Live (TTL) in seconds. - * - * @type {number} - */ - ttl: number; + purpose: string; } export type RegisterBeginResponse = { From 8dd726a4fbd1e57a30ba0a331214f2147cb821e2 Mon Sep 17 00:00:00 2001 From: jrmccannon Date: Tue, 23 Apr 2024 09:54:04 -0500 Subject: [PATCH 05/13] Making callback optional. --- src/passwordless.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/passwordless.ts b/src/passwordless.ts index 1c4b04e..dbe65f0 100644 --- a/src/passwordless.ts +++ b/src/passwordless.ts @@ -245,7 +245,17 @@ export class Client { } } - public async stepup(stepup: StepupRequest, callback: (token: string | undefined, args?: any) => any) { + /** + * Performs a step-up authentication process. This is essentially an overload for the sign-in workflow. It allows for + * a user authentication to be given a purpose or context for the sign-in, enabling a "step-up" authentication flow. + * + * @param {object} stepup - The step-up request object. This includes the sign-in method and the purpose of the authentication + * @param {function} callback - The optional callback function to handle the result of the step-up process. + * Receives the token as the first argument and optional additional arguments. + * @returns {object} - The result of the step-up process. If a callback function is provided, it returns + * the result of the callback function; otherwise, it returns the result directly. + */ + public async stepup(stepup: StepupRequest, callback?: (token: string | undefined, args?: any) => any) { try { this.assertBrowserSupported(); this.handleAbort(); From 9ab1c7344ca72e47dbd930dce869cd51b86ceec0 Mon Sep 17 00:00:00 2001 From: jrmccannon Date: Tue, 23 Apr 2024 09:56:03 -0500 Subject: [PATCH 06/13] Correcting doc obj --- src/passwordless.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/passwordless.ts b/src/passwordless.ts index dbe65f0..d6baff1 100644 --- a/src/passwordless.ts +++ b/src/passwordless.ts @@ -249,7 +249,7 @@ export class Client { * Performs a step-up authentication process. This is essentially an overload for the sign-in workflow. It allows for * a user authentication to be given a purpose or context for the sign-in, enabling a "step-up" authentication flow. * - * @param {object} stepup - The step-up request object. This includes the sign-in method and the purpose of the authentication + * @param {StepupRequest} stepup - The step-up request object. This includes the sign-in method and the purpose of the authentication * @param {function} callback - The optional callback function to handle the result of the step-up process. * Receives the token as the first argument and optional additional arguments. * @returns {object} - The result of the step-up process. If a callback function is provided, it returns From a2e8e0c060160629a85f7f6363f4e4af79ed903d Mon Sep 17 00:00:00 2001 From: jrmccannon Date: Tue, 23 Apr 2024 09:59:21 -0500 Subject: [PATCH 07/13] adding doc for StepupRequest --- src/types.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/types.ts b/src/types.ts index 376633f..b489108 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,7 +6,9 @@ export type AtLeast = Partial & Pick; export type SigninMethod = { userId: string } | { alias: string } | { autofill: boolean } | { discoverable: boolean }; /** + * Represents a step-up request to initiate a specific action or operation. * + * @interface StepupRequest */ export interface StepupRequest { signinMethod: SigninMethod; From 40d56af325b6ac5fd2be21d0fa29eab1a16f548f Mon Sep 17 00:00:00 2001 From: jrmccannon Date: Thu, 16 May 2024 09:10:36 -0500 Subject: [PATCH 08/13] Changed formatting to 4 space tabs --- src/passwordless.ts | 438 ++++++++++++++++++++++---------------------- 1 file changed, 219 insertions(+), 219 deletions(-) diff --git a/src/passwordless.ts b/src/passwordless.ts index d6baff1..ec08857 100644 --- a/src/passwordless.ts +++ b/src/passwordless.ts @@ -9,10 +9,10 @@ import { } from './types'; export interface Config { - apiUrl: string; - apiKey: string; - origin: string; - rpid: string; + apiUrl: string; + apiKey: string; + origin: string; + rpid: string; } export class Client { @@ -24,9 +24,9 @@ export class Client { }; private abortController: AbortController = new AbortController(); - constructor(config: AtLeast) { - Object.assign(this.config, config); - } + constructor(config: AtLeast) { + Object.assign(this.config, config); + } /** * Register a new credential to a user @@ -44,25 +44,25 @@ export class Client { return { error: registration.error } } - registration.data.challenge = base64UrlToArrayBuffer(registration.data.challenge); - registration.data.user.id = base64UrlToArrayBuffer(registration.data.user.id); - registration.data.excludeCredentials?.forEach((cred) => { - cred.id = base64UrlToArrayBuffer(cred.id); - }); - - const credential = await navigator.credentials.create({ - publicKey: registration.data, - }) as PublicKeyCredential; - - if (!credential) { - const error = { - from: "client", - errorCode: "failed_create_credential", - title: "Failed to create credential (navigator.credentials.create returned null)", - }; - console.error(error); - return {error}; - } + registration.data.challenge = base64UrlToArrayBuffer(registration.data.challenge); + registration.data.user.id = base64UrlToArrayBuffer(registration.data.user.id); + registration.data.excludeCredentials?.forEach((cred) => { + cred.id = base64UrlToArrayBuffer(cred.id); + }); + + const credential = await navigator.credentials.create({ + publicKey: registration.data, + }) as PublicKeyCredential; + + if (!credential) { + const error = { + from: "client", + errorCode: "failed_create_credential", + title: "Failed to create credential (navigator.credentials.create returned null)", + }; + console.error(error); + return {error}; + } return await this.registerComplete(credential, registration.session, credentialNickname); @@ -123,23 +123,23 @@ export class Client { return this.signin({ discoverable: true }); } - public abort() { - if (this.abortController) { - this.abortController.abort(); + public abort() { + if (this.abortController) { + this.abortController.abort(); + } } - } - public isPlatformSupported(): Promise { - return isPlatformSupported(); - } + public isPlatformSupported(): Promise { + return isPlatformSupported(); + } - public isBrowserSupported(): boolean { - return isBrowserSupported(); - } + public isBrowserSupported(): boolean { + return isBrowserSupported(); + } - public isAutofillSupported(): Promise { - return isAutofillSupported(); - } + public isAutofillSupported(): Promise { + return isAutofillSupported(); + } private async registerBegin(token: string): PromiseResult { const response = await fetch(`${this.config.apiUrl}/register/begin`, { @@ -152,20 +152,20 @@ export class Client { }), }); - const res = await response.json(); - if (response.ok) { - return res; - } + const res = await response.json(); + if (response.ok) { + return res; + } return { error: { ...res, from: "server" } }; } - private async registerComplete( - credential: PublicKeyCredential, - session: string, - credentialNickname: string, - ): PromiseResult { - const attestationResponse = credential.response as AuthenticatorAttestationResponse; + private async registerComplete( + credential: PublicKeyCredential, + session: string, + credentialNickname: string, + ): PromiseResult { + const attestationResponse = credential.response as AuthenticatorAttestationResponse; const response = await fetch(`${this.config.apiUrl}/register/complete`, { method: 'POST', @@ -192,10 +192,10 @@ export class Client { }), }); - const res = await response.json(); - if (response.ok) { - return res; - } + const res = await response.json(); + if (response.ok) { + return res; + } return { error: { ...res, from: "server" } }; } @@ -222,11 +222,11 @@ export class Client { return signin; } - const credential = await navigator.credentials.get({ - publicKey: signin.data, - mediation: 'autofill' in signinMethod ? "conditional" as CredentialMediationRequirement : undefined, // Typescript doesn't know about 'conditational' yet - signal: this.abortController.signal, - }) as PublicKeyCredential; + const credential = await navigator.credentials.get({ + publicKey: signin.data, + mediation: 'autofill' in signinMethod ? "conditional" as CredentialMediationRequirement : undefined, // Typescript doesn't know about 'conditational' yet + signal: this.abortController.signal, + }) as PublicKeyCredential; const response = await this.signinComplete(credential, signin.session); return response; @@ -241,95 +241,95 @@ export class Client { console.error(caughtError); console.error(error); - return {error}; - } - } - - /** - * Performs a step-up authentication process. This is essentially an overload for the sign-in workflow. It allows for - * a user authentication to be given a purpose or context for the sign-in, enabling a "step-up" authentication flow. - * - * @param {StepupRequest} stepup - The step-up request object. This includes the sign-in method and the purpose of the authentication - * @param {function} callback - The optional callback function to handle the result of the step-up process. - * Receives the token as the first argument and optional additional arguments. - * @returns {object} - The result of the step-up process. If a callback function is provided, it returns - * the result of the callback function; otherwise, it returns the result directly. - */ - public async stepup(stepup: StepupRequest, callback?: (token: string | undefined, args?: any) => any) { - try { - this.assertBrowserSupported(); - this.handleAbort(); - - if (!stepup.signinMethod) { - throw new Error("You need to provide the signInMethod"); - } - - const signin = await this.signinBegin(stepup.signinMethod, stepup.purpose); - - if (signin.error) { - return signin; - } - - const credential = await navigator.credentials.get({ - publicKey: signin.data, - mediation: 'autofill' in stepup.signinMethod ? "conditional" as CredentialMediationRequirement : undefined, // Typescript doesn't know about 'conditional' yet - signal: this.abortController.signal, - }) as PublicKeyCredential; - - const signInComplete = await this.signinComplete(credential, signin.session); - - return callback === undefined || callback === null - ? signInComplete - : callback(signInComplete.token); - } catch (caughtError: any) { - const errorMessage = getErrorMessage(caughtError); - const error = { - from: "client", - errorCode: "unknown", - title: errorMessage, - }; - console.error(caughtError); - console.error(error); - - return {error}; + return {error}; + } } - } - - private async signinBegin(signinMethod: SigninMethod, purpose?: string): PromiseResult { - const response = await fetch(`${this.config.apiUrl}/signin/begin`, { - method: 'POST', - headers: this.createHeaders(), - body: JSON.stringify({ - userId: "userId" in signinMethod ? signinMethod.userId : undefined, - alias: "alias" in signinMethod ? signinMethod.alias : undefined, - RPID: this.config.rpid, - Origin: this.config.origin, - purpose: purpose - }), - }); - - const res = await response.json(); - if (response.ok) { - return { - ...res, - data: { - ...res.data, - challenge: base64UrlToArrayBuffer(res.data.challenge), - allowCredentials: res.data.allowCredentials?.map((cred: PublicKeyCredentialDescriptor) => { - return {...cred, id: base64UrlToArrayBuffer(cred.id)}; - }) + + /** + * Performs a step-up authentication process. This is essentially an overload for the sign-in workflow. It allows for + * a user authentication to be given a purpose or context for the sign-in, enabling a "step-up" authentication flow. + * + * @param {StepupRequest} stepup - The step-up request object. This includes the sign-in method and the purpose of the authentication + * @param {function} callback - The optional callback function to handle the result of the step-up process. + * Receives the token as the first argument and optional additional arguments. + * @returns {object} - The result of the step-up process. If a callback function is provided, it returns + * the result of the callback function; otherwise, it returns the result directly. + */ + public async stepup(stepup: StepupRequest, callback?: (token: string | undefined, args?: any) => any) { + try { + this.assertBrowserSupported(); + this.handleAbort(); + + if (!stepup.signinMethod) { + throw new Error("You need to provide the signInMethod"); + } + + const signin = await this.signinBegin(stepup.signinMethod, stepup.purpose); + + if (signin.error) { + return signin; + } + + const credential = await navigator.credentials.get({ + publicKey: signin.data, + mediation: 'autofill' in stepup.signinMethod ? "conditional" as CredentialMediationRequirement : undefined, // Typescript doesn't know about 'conditional' yet + signal: this.abortController.signal, + }) as PublicKeyCredential; + + const signInComplete = await this.signinComplete(credential, signin.session); + + return callback === undefined || callback === null + ? signInComplete + : callback(signInComplete.token); + } catch (caughtError: any) { + const errorMessage = getErrorMessage(caughtError); + const error = { + from: "client", + errorCode: "unknown", + title: errorMessage, + }; + console.error(caughtError); + console.error(error); + + return {error}; } - }; } + private async signinBegin(signinMethod: SigninMethod, purpose?: string): PromiseResult { + const response = await fetch(`${this.config.apiUrl}/signin/begin`, { + method: 'POST', + headers: this.createHeaders(), + body: JSON.stringify({ + userId: "userId" in signinMethod ? signinMethod.userId : undefined, + alias: "alias" in signinMethod ? signinMethod.alias : undefined, + RPID: this.config.rpid, + Origin: this.config.origin, + purpose: purpose + }), + }); + + const res = await response.json(); + if (response.ok) { + return { + ...res, + data: { + ...res.data, + challenge: base64UrlToArrayBuffer(res.data.challenge), + allowCredentials: res.data.allowCredentials?.map((cred: PublicKeyCredentialDescriptor) => { + return {...cred, id: base64UrlToArrayBuffer(cred.id)}; + }) + } + }; + } + return { error: { ...res, from: "server" } }; } - private async signinComplete( - credential: PublicKeyCredential, - session: string, - ): PromiseResult { - const assertionResponse = credential.response as AuthenticatorAssertionResponse; + private async signinComplete( + credential: PublicKeyCredential, + session: string, + ): PromiseResult { + const assertionResponse = credential.response as AuthenticatorAssertionResponse; const response = await fetch(`${this.config.apiUrl}/signin/complete`, { method: 'POST', @@ -358,123 +358,123 @@ export class Client { }), }); - const res = await response.json(); - if (response.ok) { - return res; - } + const res = await response.json(); + if (response.ok) { + return res; + } return { error: { ...res, from: "server" } }; } - private handleAbort() { - this.abort(); - this.abortController = new AbortController(); - } + private handleAbort() { + this.abort(); + this.abortController = new AbortController(); + } - private assertBrowserSupported(): void { - if (!isBrowserSupported()) { - throw new Error('WebAuthn and PublicKeyCredentials are not supported on this browser/device'); + private assertBrowserSupported(): void { + if (!isBrowserSupported()) { + throw new Error('WebAuthn and PublicKeyCredentials are not supported on this browser/device'); + } } - } - private createHeaders(): Record { - return { - ApiKey: this.config.apiKey, - 'Content-Type': 'application/json', - 'Client-Version': 'js-1.1.0' - }; - } + private createHeaders(): Record { + return { + ApiKey: this.config.apiKey, + 'Content-Type': 'application/json', + 'Client-Version': 'js-1.1.0' + }; + } } export async function isPlatformSupported(): Promise { - if (!isBrowserSupported()) return false; - return PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); + if (!isBrowserSupported()) return false; + return PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); } export function isBrowserSupported(): boolean { - return window.PublicKeyCredential !== undefined && typeof window.PublicKeyCredential === 'function'; + return window.PublicKeyCredential !== undefined && typeof window.PublicKeyCredential === 'function'; } export async function isAutofillSupported(): Promise { - const PublicKeyCredential = window.PublicKeyCredential as any; // Typescript lacks support for this - if (!PublicKeyCredential.isConditionalMediationAvailable) return false; - return PublicKeyCredential.isConditionalMediationAvailable() as Promise; + const PublicKeyCredential = window.PublicKeyCredential as any; // Typescript lacks support for this + if (!PublicKeyCredential.isConditionalMediationAvailable) return false; + return PublicKeyCredential.isConditionalMediationAvailable() as Promise; } function base64ToBase64Url(base64: string): string { - return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=*$/g, ''); + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=*$/g, ''); } function base64UrlToBase64(base64Url: string): string { - return base64Url.replace(/-/g, '+').replace(/_/g, '/'); + return base64Url.replace(/-/g, '+').replace(/_/g, '/'); } function base64UrlToArrayBuffer(base64UrlString: string | BufferSource): ArrayBuffer { - // improvement: Remove BufferSource-type and add proper types upstream - if (typeof base64UrlString !== 'string') { - const msg = "Cannot convert from Base64Url to ArrayBuffer: Input was not of type string"; - console.error(msg, base64UrlString); - throw new TypeError(msg); - } - - const base64Unpadded = base64UrlToBase64(base64UrlString); - const paddingNeeded = (4 - (base64Unpadded.length % 4)) % 4; - const base64Padded = base64Unpadded.padEnd(base64Unpadded.length + paddingNeeded, "="); - - const binary = window.atob(base64Padded); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - - return bytes; + // improvement: Remove BufferSource-type and add proper types upstream + if (typeof base64UrlString !== 'string') { + const msg = "Cannot convert from Base64Url to ArrayBuffer: Input was not of type string"; + console.error(msg, base64UrlString); + throw new TypeError(msg); + } + + const base64Unpadded = base64UrlToBase64(base64UrlString); + const paddingNeeded = (4 - (base64Unpadded.length % 4)) % 4; + const base64Padded = base64Unpadded.padEnd(base64Unpadded.length + paddingNeeded, "="); + + const binary = window.atob(base64Padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + + return bytes; } function arrayBufferToBase64Url(buffer: ArrayBuffer | Uint8Array): string { - const uint8Array = (() => { - if (Array.isArray(buffer)) return Uint8Array.from(buffer); - if (buffer instanceof ArrayBuffer) return new Uint8Array(buffer); - if (buffer instanceof Uint8Array) return buffer; - - const msg = "Cannot convert from ArrayBuffer to Base64Url. Input was not of type ArrayBuffer, Uint8Array or Array"; - console.error(msg, buffer); - throw new Error(msg); - })(); - - let string = ''; - for (let i = 0; i < uint8Array.byteLength; i++) { - string += String.fromCharCode(uint8Array[i]); - } - - const base64String = window.btoa(string); - return base64ToBase64Url(base64String); + const uint8Array = (() => { + if (Array.isArray(buffer)) return Uint8Array.from(buffer); + if (buffer instanceof ArrayBuffer) return new Uint8Array(buffer); + if (buffer instanceof Uint8Array) return buffer; + + const msg = "Cannot convert from ArrayBuffer to Base64Url. Input was not of type ArrayBuffer, Uint8Array or Array"; + console.error(msg, buffer); + throw new Error(msg); + })(); + + let string = ''; + for (let i = 0; i < uint8Array.byteLength; i++) { + string += String.fromCharCode(uint8Array[i]); + } + + const base64String = window.btoa(string); + return base64ToBase64Url(base64String); } type ErrorWithMessage = { - message: string + message: string } function isErrorWithMessage(error: unknown): error is ErrorWithMessage { - return ( - typeof error === 'object' && - error !== null && - 'message' in error && - typeof (error as Record).message === 'string' - ) + return ( + typeof error === 'object' && + error !== null && + 'message' in error && + typeof (error as Record).message === 'string' + ) } function toErrorWithMessage(maybeError: unknown): ErrorWithMessage { - if (isErrorWithMessage(maybeError)) return maybeError - - try { - return new Error(JSON.stringify(maybeError)) - } catch { - // fallback in case there's an error stringifying the maybeError - // like with circular references for example. - return new Error(String(maybeError)) - } + if (isErrorWithMessage(maybeError)) return maybeError + + try { + return new Error(JSON.stringify(maybeError)) + } catch { + // fallback in case there's an error stringifying the maybeError + // like with circular references for example. + return new Error(String(maybeError)) + } } function getErrorMessage(error: unknown) { - return toErrorWithMessage(error).message + return toErrorWithMessage(error).message } \ No newline at end of file From 6e7b525032d2be71bd6329f06918bf89383d1fbd Mon Sep 17 00:00:00 2001 From: jrmccannon Date: Thu, 16 May 2024 09:19:51 -0500 Subject: [PATCH 09/13] formatting --- src/passwordless.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/passwordless.ts b/src/passwordless.ts index ec08857..7056ec8 100644 --- a/src/passwordless.ts +++ b/src/passwordless.ts @@ -61,7 +61,7 @@ export class Client { title: "Failed to create credential (navigator.credentials.create returned null)", }; console.error(error); - return {error}; + return { error }; } return await this.registerComplete(credential, registration.session, credentialNickname); @@ -241,7 +241,7 @@ export class Client { console.error(caughtError); console.error(error); - return {error}; + return { error }; } } @@ -291,7 +291,7 @@ export class Client { console.error(caughtError); console.error(error); - return {error}; + return { error }; } } @@ -316,7 +316,7 @@ export class Client { ...res.data, challenge: base64UrlToArrayBuffer(res.data.challenge), allowCredentials: res.data.allowCredentials?.map((cred: PublicKeyCredentialDescriptor) => { - return {...cred, id: base64UrlToArrayBuffer(cred.id)}; + return { ...cred, id: base64UrlToArrayBuffer(cred.id) }; }) } }; From 0e142ec1a38a1ebcc74cc86214929960aa444ecf Mon Sep 17 00:00:00 2001 From: jrmccannon Date: Mon, 3 Jun 2024 08:40:46 -0500 Subject: [PATCH 10/13] Added a default value for step up if purpose is not provided --- src/passwordless.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/passwordless.ts b/src/passwordless.ts index 7056ec8..c331b62 100644 --- a/src/passwordless.ts +++ b/src/passwordless.ts @@ -264,6 +264,10 @@ export class Client { throw new Error("You need to provide the signInMethod"); } + if (!stepup.purpose) { + stepup.purpose = "step-up"; + } + const signin = await this.signinBegin(stepup.signinMethod, stepup.purpose); if (signin.error) { From fe1a3b088872b6417db5166f95bebf4645ea39a9 Mon Sep 17 00:00:00 2001 From: jrmccannon Date: Tue, 4 Jun 2024 10:02:00 -0500 Subject: [PATCH 11/13] Removed callback. --- src/passwordless.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/passwordless.ts b/src/passwordless.ts index c331b62..4089231 100644 --- a/src/passwordless.ts +++ b/src/passwordless.ts @@ -250,12 +250,10 @@ export class Client { * a user authentication to be given a purpose or context for the sign-in, enabling a "step-up" authentication flow. * * @param {StepupRequest} stepup - The step-up request object. This includes the sign-in method and the purpose of the authentication - * @param {function} callback - The optional callback function to handle the result of the step-up process. - * Receives the token as the first argument and optional additional arguments. - * @returns {object} - The result of the step-up process. If a callback function is provided, it returns - * the result of the callback function; otherwise, it returns the result directly. + * + * @returns {token} - The result of the step-up sign-in process. */ - public async stepup(stepup: StepupRequest, callback?: (token: string | undefined, args?: any) => any) { + public async stepup(stepup: StepupRequest) { try { this.assertBrowserSupported(); this.handleAbort(); @@ -280,11 +278,7 @@ export class Client { signal: this.abortController.signal, }) as PublicKeyCredential; - const signInComplete = await this.signinComplete(credential, signin.session); - - return callback === undefined || callback === null - ? signInComplete - : callback(signInComplete.token); + return await this.signinComplete(credential, signin.session); } catch (caughtError: any) { const errorMessage = getErrorMessage(caughtError); const error = { From 969d1ec3e3f5e0b707f8c21b0855cb520e602b39 Mon Sep 17 00:00:00 2001 From: jrmccannon Date: Mon, 10 Jun 2024 09:58:55 -0500 Subject: [PATCH 12/13] Corrected casing. --- src/passwordless.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/passwordless.ts b/src/passwordless.ts index 4089231..9150ba9 100644 --- a/src/passwordless.ts +++ b/src/passwordless.ts @@ -259,7 +259,7 @@ export class Client { this.handleAbort(); if (!stepup.signinMethod) { - throw new Error("You need to provide the signInMethod"); + throw new Error("You need to provide the signinMethod"); } if (!stepup.purpose) { From 1f509dce3c076ae33f4e7fcfac2929366417f1e1 Mon Sep 17 00:00:00 2001 From: jrmccannon Date: Mon, 10 Jun 2024 09:59:59 -0500 Subject: [PATCH 13/13] Adding result type. --- src/passwordless.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/passwordless.ts b/src/passwordless.ts index 9150ba9..e036731 100644 --- a/src/passwordless.ts +++ b/src/passwordless.ts @@ -253,7 +253,7 @@ export class Client { * * @returns {token} - The result of the step-up sign-in process. */ - public async stepup(stepup: StepupRequest) { + public async stepup(stepup: StepupRequest) : PromiseResult { try { this.assertBrowserSupported(); this.handleAbort();