diff --git a/lib/ts/recipe/webauthn/index.ts b/lib/ts/recipe/webauthn/index.ts index e3dd279..d943f20 100644 --- a/lib/ts/recipe/webauthn/index.ts +++ b/lib/ts/recipe/webauthn/index.ts @@ -18,7 +18,14 @@ import { GeneralErrorResponse, User } from "../../types"; import { getNormalisedUserContext } from "../../utils"; import { RecipeFunctionOptions } from "../recipeModule/types"; import Recipe from "./recipe"; -import { CredentialPayload, ResidentKey, UserInput, UserVerification } from "./types"; +import { + CredentialPayload, + ResidentKey, + UserInput, + UserVerification, + RegistrationOptions, + AuthenticationOptions, +} from "./types"; export default class RecipeWrapper { static init(config?: UserInput) { @@ -39,7 +46,7 @@ export default class RecipeWrapper { * * @returns `{ status: "OK", ...}` if successful along a description of the created webauthn details (challenge, etc.) */ - static registerOptions( + static getRegisterOptions( input: { options?: RecipeFunctionOptions; userContext: any } & ( | { email: string } | { recoverAccountToken: string } @@ -90,7 +97,7 @@ export default class RecipeWrapper { fetchResponse: Response; } > { - return Recipe.getInstanceOrThrow().recipeImplementation.registerOptions({ + return Recipe.getInstanceOrThrow().recipeImplementation.getRegisterOptions({ ...input, userContext: getNormalisedUserContext(input?.userContext), }); @@ -108,7 +115,7 @@ export default class RecipeWrapper { * * @returns `{ status: "OK", ...}` if successful along a description of the webauthn options (challenge, etc.) */ - static signInOptions(input: { email: string; options?: RecipeFunctionOptions; userContext: any }): Promise< + static getSignInOptions(input: { email: string; options?: RecipeFunctionOptions; userContext: any }): Promise< | { status: "OK"; webauthnGeneratedOptionsId: string; @@ -123,7 +130,7 @@ export default class RecipeWrapper { } | GeneralErrorResponse > { - return Recipe.getInstanceOrThrow().recipeImplementation.signInOptions({ + return Recipe.getInstanceOrThrow().recipeImplementation.getSignInOptions({ ...input, userContext: getNormalisedUserContext(input?.userContext), }); @@ -217,14 +224,14 @@ export default class RecipeWrapper { * * @returns `{ status: "OK", ...}` if successful along with a boolean indicating existence */ - static emailExists(input: { email: string; options?: RecipeFunctionOptions; userContext: any }): Promise< + static getEmailExists(input: { email: string; options?: RecipeFunctionOptions; userContext: any }): Promise< | { status: "OK"; exists: boolean; } | GeneralErrorResponse > { - return Recipe.getInstanceOrThrow().recipeImplementation.emailExists({ + return Recipe.getInstanceOrThrow().recipeImplementation.getEmailExists({ ...input, userContext: input?.userContext, }); @@ -298,6 +305,45 @@ export default class RecipeWrapper { }); } + /** + * Register credential with the passed options by using native webauthn functions. + * + * It uses `@simplewebauthn/browser` to make the webauthn calls. + * + * @param registrationOptions Options to pass for the registration. + * + * @returns `{ status: "OK", ...}` if successful along with registration response received + */ + static registerCredential(input: { registrationOptions: RegistrationOptions }): Promise< + | { + status: "OK"; + registrationResponse: RegistrationResponseJSON; + } + | { status: "AUTHENTICATOR_ALREADY_REGISTERED" } + | { status: "FAILED_TO_REGISTER_USER"; error: any } + > { + return Recipe.getInstanceOrThrow().recipeImplementation.registerCredential(input); + } + + /** + * Authenticate the credential with the passed options by using native webauthn functions. + * + * It uses `@simplewebauthn/browser` to make the webauthn calls. + * + * @param authenticationOptions Options to pass for the authentication. + * + * @returns `{ status: "OK", ...}` if successful along with authentication response received + */ + static authenticateCredential(input: { authenticationOptions: AuthenticationOptions }): Promise< + | { + status: "OK"; + authenticationResponse: AuthenticationResponseJSON; + } + | { status: "FAILED_TO_AUTHENTICATE_USER"; error: any } + > { + return Recipe.getInstanceOrThrow().recipeImplementation.authenticateCredential(input); + } + /** * Register the new device and signup the user with the passed email ID. * @@ -311,7 +357,11 @@ export default class RecipeWrapper { * * @returns `{ status: "OK", ...}` if successful along a description of the user details (id, etc.) and email */ - static registerAndSignUp(input: { email: string; options?: RecipeFunctionOptions; userContext: any }): Promise< + static registerCredentialWithSignUp(input: { + email: string; + options?: RecipeFunctionOptions; + userContext: any; + }): Promise< | { status: "OK"; user: User; @@ -338,8 +388,9 @@ export default class RecipeWrapper { | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string; fetchResponse: Response } | { status: "EMAIL_ALREADY_EXISTS_ERROR"; fetchResponse: Response } | { status: "AUTHENTICATOR_ALREADY_REGISTERED" } + | { status: "FAILED_TO_REGISTER_USER"; error: any } > { - return Recipe.getInstanceOrThrow().recipeImplementation.registerAndSignUp({ + return Recipe.getInstanceOrThrow().recipeImplementation.registerCredentialWithSignUp({ ...input, userContext: input?.userContext, }); @@ -358,7 +409,11 @@ export default class RecipeWrapper { * * @returns `{ status: "OK", ...}` if successful along a description of the user details (id, etc.) and email */ - static authenticateAndSignIn(input: { email: string; options?: RecipeFunctionOptions; userContext: any }): Promise< + static authenticateCredentialWithSignIn(input: { + email: string; + options?: RecipeFunctionOptions; + userContext: any; + }): Promise< | { status: "OK"; user: User; @@ -374,9 +429,10 @@ export default class RecipeWrapper { reason: string; fetchResponse: Response; } + | { status: "FAILED_TO_AUTHENTICATE_USER"; error: any } | GeneralErrorResponse > { - return Recipe.getInstanceOrThrow().recipeImplementation.authenticateAndSignIn({ + return Recipe.getInstanceOrThrow().recipeImplementation.authenticateCredentialWithSignIn({ ...input, userContext: input?.userContext, }); @@ -395,7 +451,7 @@ export default class RecipeWrapper { * * @returns `{ status: "OK", ...}` if successful along a description of the user details (id, etc.) and email */ - static registerAndRecoverAccount(input: { + static registerCredentialWithRecoverAccount(input: { recoverAccountToken: string; options?: RecipeFunctionOptions; userContext: any; @@ -421,8 +477,9 @@ export default class RecipeWrapper { | { status: "INVALID_GENERATED_OPTIONS_ERROR"; fetchResponse: Response } | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string; fetchResponse: Response } | { status: "AUTHENTICATOR_ALREADY_REGISTERED" } + | { status: "FAILED_TO_REGISTER_USER"; error: any } > { - return Recipe.getInstanceOrThrow().recipeImplementation.registerAndRecoverAccount({ + return Recipe.getInstanceOrThrow().recipeImplementation.registerCredentialWithRecoverAccount({ ...input, userContext: input?.userContext, }); @@ -430,27 +487,31 @@ export default class RecipeWrapper { } const init = RecipeWrapper.init; -const registerOptions = RecipeWrapper.registerOptions; -const signInOptions = RecipeWrapper.signInOptions; +const getRegisterOptions = RecipeWrapper.getRegisterOptions; +const getSignInOptions = RecipeWrapper.getSignInOptions; const signUp = RecipeWrapper.signUp; const signIn = RecipeWrapper.signIn; -const emailExists = RecipeWrapper.emailExists; +const getEmailExists = RecipeWrapper.getEmailExists; const generateRecoverAccountToken = RecipeWrapper.generateRecoverAccountToken; const recoverAccount = RecipeWrapper.recoverAccount; -const registerAndSignup = RecipeWrapper.registerAndSignUp; -const authenticateAndSignIn = RecipeWrapper.authenticateAndSignIn; -const registerAndRecoverAccount = RecipeWrapper.registerAndRecoverAccount; +const registerCredentialWithSignUp = RecipeWrapper.registerCredentialWithSignUp; +const authenticateCredentialWithSignIn = RecipeWrapper.authenticateCredentialWithSignIn; +const registerCredentialWithRecoverAccount = RecipeWrapper.registerCredentialWithRecoverAccount; +const registerCredential = RecipeWrapper.registerCredential; +const authenticateCredential = RecipeWrapper.authenticateCredential; export { init, - registerOptions, - signInOptions, + getRegisterOptions, + getSignInOptions, signUp, signIn, - emailExists, + getEmailExists, generateRecoverAccountToken, recoverAccount, - registerAndSignup, - authenticateAndSignIn, - registerAndRecoverAccount, + registerCredentialWithSignUp, + authenticateCredentialWithSignIn, + registerCredentialWithRecoverAccount, + registerCredential, + authenticateCredential, }; diff --git a/lib/ts/recipe/webauthn/recipeImplementation.ts b/lib/ts/recipe/webauthn/recipeImplementation.ts index 32040fa..40fe27c 100644 --- a/lib/ts/recipe/webauthn/recipeImplementation.ts +++ b/lib/ts/recipe/webauthn/recipeImplementation.ts @@ -32,7 +32,7 @@ export default function getRecipeImplementation( const querier = new Querier(recipeImplInput.recipeId, recipeImplInput.appInfo); return { - registerOptions: async function ({ + getRegisterOptions: async function ({ options, userContext, email, @@ -115,7 +115,7 @@ export default function getRecipeImplementation( fetchResponse, }; }, - signInOptions: async function ({ email, options, userContext }) { + getSignInOptions: async function ({ email, options, userContext }) { const { jsonBody, fetchResponse } = await querier.post< | { status: "OK"; @@ -244,7 +244,7 @@ export default function getRecipeImplementation( fetchResponse, }; }, - emailExists: async function ({ email, options, userContext }) { + getEmailExists: async function ({ email, options, userContext }) { const { jsonBody, fetchResponse } = await querier.get< | { status: "OK"; @@ -354,9 +354,29 @@ export default function getRecipeImplementation( fetchResponse, }; }, - registerAndSignUp: async function ({ email, options, userContext }) { + registerCredential: async function ({ registrationOptions }) { + let registrationResponse: RegistrationResponseJSON; + try { + registrationResponse = await startRegistration({ optionsJSON: registrationOptions }); + } catch (error: any) { + if (error.name === "InvalidStateError") { + return { status: "AUTHENTICATOR_ALREADY_REGISTERED" }; + } + + return { + status: "FAILED_TO_REGISTER_USER", + error: error, + }; + } + + return { + status: "OK", + registrationResponse, + }; + }, + registerCredentialWithSignUp: async function ({ email, options, userContext }) { // Get the registration options by using the passed email ID. - const registrationOptions = await this.registerOptions({ options, userContext, email }); + const registrationOptions = await this.getRegisterOptions({ options, userContext, email }); if (registrationOptions?.status !== "OK") { // If we did not get an OK status, we need to return the error as is. @@ -371,56 +391,65 @@ export default function getRecipeImplementation( } // We should have received a valid registration options response. - let registrationResponse: RegistrationResponseJSON; - try { - registrationResponse = await startRegistration({ optionsJSON: registrationOptions }); - } catch (error: any) { - if (error.name === "InvalidStateError") { - return { status: "AUTHENTICATOR_ALREADY_REGISTERED" }; - } - - throw error; + const registerCredentialResponse = await this.registerCredential({ registrationOptions }); + if (registerCredentialResponse.status !== "OK") { + return registerCredentialResponse; } // We should have a valid registration response for the passed credentials // and we are good to go ahead and verify them. return await this.signUp({ webauthnGeneratedOptionsId: registrationOptions.webauthnGeneratedOptionsId, - credential: registrationResponse, + credential: registerCredentialResponse.registrationResponse, options, userContext, }); }, - authenticateAndSignIn: async function ({ email, options, userContext }) { + authenticateCredential: async function ({ authenticationOptions }) { + let authenticationResponse: AuthenticationResponseJSON; + try { + authenticationResponse = await startAuthentication({ optionsJSON: authenticationOptions }); + } catch (error: any) { + return { + status: "FAILED_TO_AUTHENTICATE_USER", + error: error, + }; + } + + return { + status: "OK", + authenticationResponse: authenticationResponse, + }; + }, + authenticateCredentialWithSignIn: async function ({ email, options, userContext }) { // Make a call to get the sign in options using the entered email ID. - const signInOptions = await this.signInOptions({ email, options, userContext }); + const signInOptions = await this.getSignInOptions({ email, options, userContext }); if (signInOptions?.status !== "OK") { // We want to return the error as is if status was not "OK" return signInOptions; } // We should have the options ready and are good to start the authentication - let authenticationResponse: AuthenticationResponseJSON; - try { - authenticationResponse = await startAuthentication({ optionsJSON: signInOptions }); - } catch (error: any) { - // TODO: Do we need to do something with the error besides throwing it? - throw error; + const authenticateCredentialResponse = await this.authenticateCredential({ + authenticationOptions: signInOptions, + }); + if (authenticateCredentialResponse.status !== "OK") { + return authenticateCredentialResponse; } // We should have a valid authentication response at this point so we can // go ahead and sign in the user. return await this.signIn({ webauthnGeneratedOptionsId: signInOptions.webauthnGeneratedOptionsId, - credential: authenticationResponse, + credential: authenticateCredentialResponse.authenticationResponse, options: options, userContext: userContext, }); }, - registerAndRecoverAccount: async function ({ recoverAccountToken, options, userContext }) { + registerCredentialWithRecoverAccount: async function ({ recoverAccountToken, options, userContext }) { // Get the registration options based on the recoverAccountToken and // register the device against the user. - const registrationOptions = await this.registerOptions({ options, userContext, recoverAccountToken }); + const registrationOptions = await this.getRegisterOptions({ options, userContext, recoverAccountToken }); if (registrationOptions?.status !== "OK") { // If we did not get an OK status, we need to return the error as is. @@ -435,21 +464,15 @@ export default function getRecipeImplementation( } // We should have received a valid registration options response. - let registrationResponse: RegistrationResponseJSON; - try { - registrationResponse = await startRegistration({ optionsJSON: registrationOptions }); - } catch (error: any) { - if (error.name === "InvalidStateError") { - return { status: "AUTHENTICATOR_ALREADY_REGISTERED" }; - } - - throw error; + const registerCredentialResponse = await this.registerCredential({ registrationOptions }); + if (registerCredentialResponse.status !== "OK") { + return registerCredentialResponse; } return await this.recoverAccount({ token: recoverAccountToken, webauthnGeneratedOptionsId: registrationOptions.webauthnGeneratedOptionsId, - credential: registrationResponse, + credential: registerCredentialResponse.registrationResponse, options, userContext, }); diff --git a/lib/ts/recipe/webauthn/types.ts b/lib/ts/recipe/webauthn/types.ts index 4b5873e..75e3169 100644 --- a/lib/ts/recipe/webauthn/types.ts +++ b/lib/ts/recipe/webauthn/types.ts @@ -76,44 +76,55 @@ export type CredentialPayload = { type: "public-key"; }; +export type RegistrationOptions = { + status: "OK"; + webauthnGeneratedOptionsId: string; + rp: { + id: string; + name: string; + }; + user: { + id: string; + name: string; + displayName: string; + }; + challenge: string; + timeout: number; + excludeCredentials: { + id: string; + type: "public-key"; + transports: ("ble" | "hybrid" | "internal" | "nfc" | "usb")[]; + }[]; + attestation: "none" | "indirect" | "direct" | "enterprise"; + pubKeyCredParams: { + alg: number; + type: "public-key"; + }[]; + authenticatorSelection: { + requireResidentKey: boolean; + residentKey: ResidentKey; + userVerification: UserVerification; + }; + fetchResponse: Response; +}; + +export type AuthenticationOptions = { + status: "OK"; + webauthnGeneratedOptionsId: string; + challenge: string; + timeout: number; + userVerification: UserVerification; + fetchResponse: Response; +}; + export type RecipeInterface = { - registerOptions: ( + getRegisterOptions: ( input: { options?: RecipeFunctionOptions; userContext: any } & ( | { email: string } | { recoverAccountToken: string } ) ) => Promise< - | { - status: "OK"; - webauthnGeneratedOptionsId: string; - rp: { - id: string; - name: string; - }; - user: { - id: string; - name: string; - displayName: string; - }; - challenge: string; - timeout: number; - excludeCredentials: { - id: string; - type: "public-key"; - transports: ("ble" | "hybrid" | "internal" | "nfc" | "usb")[]; - }[]; - attestation: "none" | "indirect" | "direct" | "enterprise"; - pubKeyCredParams: { - alg: number; - type: "public-key"; - }[]; - authenticatorSelection: { - requireResidentKey: boolean; - residentKey: ResidentKey; - userVerification: UserVerification; - }; - fetchResponse: Response; - } + | RegistrationOptions | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR"; fetchResponse: Response; @@ -128,15 +139,8 @@ export type RecipeInterface = { fetchResponse: Response; } >; - signInOptions: (input: { email: string; options?: RecipeFunctionOptions; userContext: any }) => Promise< - | { - status: "OK"; - webauthnGeneratedOptionsId: string; - challenge: string; - timeout: number; - userVerification: UserVerification; - fetchResponse: Response; - } + getSignInOptions: (input: { email: string; options?: RecipeFunctionOptions; userContext: any }) => Promise< + | AuthenticationOptions | { status: "INVALID_GENERATED_OPTIONS_ERROR"; fetchResponse: Response; @@ -185,7 +189,7 @@ export type RecipeInterface = { } | GeneralErrorResponse >; - emailExists: (input: { email: string; options?: RecipeFunctionOptions; userContext: any }) => Promise< + getEmailExists: (input: { email: string; options?: RecipeFunctionOptions; userContext: any }) => Promise< | { status: "OK"; exists: boolean; @@ -225,7 +229,26 @@ export type RecipeInterface = { | { status: "INVALID_GENERATED_OPTIONS_ERROR"; fetchResponse: Response } | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string; fetchResponse: Response } >; - registerAndSignUp: (input: { email: string; options?: RecipeFunctionOptions; userContext: any }) => Promise< + registerCredential: (input: { registrationOptions: RegistrationOptions }) => Promise< + | { + status: "OK"; + registrationResponse: RegistrationResponseJSON; + } + | { status: "AUTHENTICATOR_ALREADY_REGISTERED" } + | { status: "FAILED_TO_REGISTER_USER"; error: any } + >; + authenticateCredential: (input: { authenticationOptions: AuthenticationOptions }) => Promise< + | { + status: "OK"; + authenticationResponse: AuthenticationResponseJSON; + } + | { status: "FAILED_TO_AUTHENTICATE_USER"; error: any } + >; + registerCredentialWithSignUp: (input: { + email: string; + options?: RecipeFunctionOptions; + userContext: any; + }) => Promise< | { status: "OK"; user: User; @@ -251,8 +274,13 @@ export type RecipeInterface = { | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string; fetchResponse: Response } | { status: "EMAIL_ALREADY_EXISTS_ERROR"; fetchResponse: Response } | { status: "AUTHENTICATOR_ALREADY_REGISTERED" } + | { status: "FAILED_TO_REGISTER_USER"; error: any } >; - authenticateAndSignIn: (input: { email: string; options?: RecipeFunctionOptions; userContext: any }) => Promise< + authenticateCredentialWithSignIn: (input: { + email: string; + options?: RecipeFunctionOptions; + userContext: any; + }) => Promise< | { status: "OK"; user: User; @@ -268,9 +296,10 @@ export type RecipeInterface = { reason: string; fetchResponse: Response; } + | { status: "FAILED_TO_AUTHENTICATE_USER"; error: any } | GeneralErrorResponse >; - registerAndRecoverAccount: (input: { + registerCredentialWithRecoverAccount: (input: { recoverAccountToken: string; options?: RecipeFunctionOptions; userContext: any; @@ -295,5 +324,6 @@ export type RecipeInterface = { | { status: "GENERATED_OPTIONS_NOT_FOUND_ERROR"; fetchResponse: Response } | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string; fetchResponse: Response } | { status: "AUTHENTICATOR_ALREADY_REGISTERED" } + | { status: "FAILED_TO_REGISTER_USER"; error: any } >; };