diff --git a/CODEOWNERS b/CODEOWNERS index fb25fccaca..a8d159e6cf 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -6,6 +6,7 @@ # MSAL Browser /lib/msal-browser/ @sameerag @tnorling @hectormmg @jo-arroyo @peterzenz @konstantin-msft @lalimasharda @shylasummers +/lib/msal-browser/custom-auth @shenj @yongdiw /samples/msal-browser-samples/ @sameerag @tnorling @hectormmg @jo-arroyo @peterzenz @konstantin-msft @lalimasharda @shylasummers # MSAL Common diff --git a/README.md b/README.md index a5c3808a34..f0d93a1098 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,13 @@ The [`lib`](https://github.com/AzureAD/microsoft-authentication-library-for-js/t - [On-behalf-of Flow](https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-on-behalf-of-flow) - [Microsoft Authentication Library for JavaScript](lib/msal-browser/): A browser-based, framework-agnostic browser library that enables authentication and token acquisition with the Microsoft Identity platform in JavaScript applications. Implements the OAuth 2.0 [Authorization Code Flow with PKCE](https://docs.microsoft.com/azure/active-directory/develop/v2-oauth2-auth-code-flow), and is [OpenID-compliant](https://docs.microsoft.com/azure/active-directory/develop/v2-protocols-oidc). + +- [Native Authentication Support for JavaScript](lib/msal-browser/src/custom_auth/): MSAL also provides native authentication APIs that allow applications to implement a native experience with end-to-end customizable flows in their applications. With native authentication, users are guided through a rich, native, sign-up and sign-in journey without leaving the app. The native authentication feature is available for SPAs on [External ID for customers](https://learn.microsoft.com/en-us/entra/identity-platform/concept-native-authentication). It is recommended to always use the most up-to-date version of the SDK. + + > **Note:** The native authentication feature is currently in preview and is not considered production-stable. Features and APIs may change before general availability. + > + > **Terminology:** In the codebase, the term "Custom Auth" is used instead of "Native Auth". You will find classes, interfaces, and configuration options prefixed with `CustomAuth` (e.g., `CustomAuthPublicClientApplication`, `CustomAuthConfiguration`). Please refer to these when implementing or exploring the native authentication feature in the code. + - [Microsoft Authentication Library for React](lib/msal-react/): A wrapper of the msal-browser library for apps using React. - [Microsoft Authentication Library for Angular](lib/msal-angular/): A wrapper of the msal-browser library for apps using Angular framework. - [Microsoft Authentication Extensions for Node](extensions/msal-node-extensions/): The Microsoft Authentication Extensions for Node offers secure mechanisms for client applications to perform cross-platform token cache serialization and persistence. It gives additional support to the Microsoft Authentication Library for Node (MSAL). diff --git a/change/@azure-msal-browser-3240f33d-ab3b-4b05-9d0f-816c70517f13.json b/change/@azure-msal-browser-3240f33d-ab3b-4b05-9d0f-816c70517f13.json new file mode 100644 index 0000000000..3bb4e1d26f --- /dev/null +++ b/change/@azure-msal-browser-3240f33d-ab3b-4b05-9d0f-816c70517f13.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "Add native authentication feaetures for the external ID", + "packageName": "@azure/msal-browser", + "email": "shen.jian@live.com", + "dependentChangeType": "patch" +} diff --git a/docs/errors.md b/docs/errors.md index 646b222fb3..1a5d838e67 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -751,6 +751,102 @@ msalInstance.acquireTokenSilent(); // This will also no longer throw this error ### `unsupported_method` - This method is not supported in nested app environment. +## Custom Authentication errors + +### HTTP errors + +#### `no_network_connectivity` +- No network connectivity. Check your internet connection. + +#### `failed_send_request` +- Failed to send HTTP request to the server. + +### Configuration errors + +#### `missing_configuration` +- Required configuration is missing for the custom authentication flow. + +#### `invalid_authority` +- The provided authority URL is invalid or not supported for custom authentication. + +#### `invalid_challenge_type` +- The challenge type specified in the configuration is not supported. + +### URL parsing errors + +#### `invalid_url` +- The provided URL could not be parsed or is malformed. + +### User account attribute errors + +#### `invalid_attribute` +- One or more user account attributes provided are invalid or malformed. + +### API errors + +#### `continuation_token_missing` +- The continuation token required for the next step in the authentication flow is missing. + +#### `invalid_response_body` +- The response body from the authentication server is invalid or malformed. + +#### `empty_response` +- The server returned an empty response when data was expected. + +#### `unsupported_challenge_type` +- The challenge type provided is not supported. + +#### `access_token_missing` +- The access token is missing from the authentication response. + +#### `id_token_missing` +- The ID token is missing from the authentication response. + +#### `refresh_token_missing` +- The refresh token is missing from the authentication response. + +#### `invalid_expires_in` +- The token expiration time (expires_in) value is invalid. + +#### `invalid_token_type` +- The token type returned by the server is not supported. + +#### `http_request_failed` +- The HTTP request to the authentication server failed. + +#### `invalid_request` +- The authentication request is malformed or contains invalid parameters. + +#### `user_not_found` +- The specified user could not be found. + +#### `invalid_grant` +- The authorization grant provided is invalid, expired, or revoked. + +#### `credential_required` +- User credentials are required to complete the authentication flow. + +#### `attributes_required` +- Additional user attributes are required to complete the authentication flow. + +#### `user_already_exists` +- A user with the specified identifier already exists. + +#### `invalid_poll_status` +- The polling status returned by the server is invalid. + +#### `password_change_failed` +- The password change operation failed. + +#### `password_reset_timeout` +- The password reset operation timed out. + +#### `client_info_missing` +- Client information is missing from the authentication response. + +#### `expired_token` +- The provided token has expired and cannot be used. + ## Other Errors not thrown by MSAL, such as server or cache errors. diff --git a/lib/msal-browser/package.json b/lib/msal-browser/package.json index 03cac2194e..600dd5af35 100644 --- a/lib/msal-browser/package.json +++ b/lib/msal-browser/package.json @@ -27,6 +27,16 @@ "module": "./dist/index.mjs", "types": "./dist/index.d.ts", "exports": { + "./custom-auth": { + "import": { + "types": "./dist/custom-auth-path/custom_auth/index.d.ts", + "default": "./dist/custom-auth-path/custom_auth/index.mjs" + }, + "require": { + "types": "./lib/custom-auth-path/types/custom_auth/index.d.ts", + "default": "./lib/custom-auth-path/msal-custom-auth.cjs" + } + }, ".": { "import": { "types": "./dist/index.d.ts", diff --git a/lib/msal-browser/rollup.config.js b/lib/msal-browser/rollup.config.js index 0febd37288..72a2c9001e 100644 --- a/lib/msal-browser/rollup.config.js +++ b/lib/msal-browser/rollup.config.js @@ -17,7 +17,7 @@ const fileHeader = `${libraryHeader}\n${useStrictHeader}`; export default [ { - // for es build + // Main SDK - ES build input: "src/index.ts", output: { dir: "dist", @@ -32,17 +32,16 @@ export default [ moduleSideEffects: false, propertyReadSideEffects: false, }, - external: [ - "@azure/msal-common/browser" - ], + external: ["@azure/msal-common/browser"], plugins: [ typescript({ typescript: require("typescript"), tsconfig: "tsconfig.build.json", - }) + }), ], }, { + // Main SDK - CommonJS build input: "src/index.ts", output: [ { @@ -65,10 +64,11 @@ export default [ sourceMap: true, compilerOptions: { outDir: "lib/types" }, }), - createPackageJson({libPath: __dirname}) + createPackageJson({ libPath: __dirname }), ], }, { + // Main SDK - UMD build input: "src/index.ts", output: [ { @@ -90,12 +90,16 @@ export default [ typescript: require("typescript"), tsconfig: "tsconfig.build.json", sourceMap: true, - compilerOptions: { outDir: "lib/types", declaration: false, declarationMap: false }, + compilerOptions: { + outDir: "lib/types", + declaration: false, + declarationMap: false, + }, }), ], }, { - // Minified version of msal + // Main SDK - UMD minified build input: "src/index.ts", output: [ { @@ -117,7 +121,11 @@ export default [ typescript: require("typescript"), tsconfig: "tsconfig.build.json", sourceMap: false, - compilerOptions: { outDir: "lib/types", declaration: false, declarationMap: false }, + compilerOptions: { + outDir: "lib/types", + declaration: false, + declarationMap: false, + }, }), terser({ output: { @@ -126,4 +134,52 @@ export default [ }), ], }, + { + // Custom Auth - ES module build + input: "src/custom_auth/index.ts", + output: { + dir: "dist/custom-auth-path", + preserveModules: true, + preserveModulesRoot: "src", + format: "es", + entryFileNames: "[name].mjs", + banner: fileHeader, + sourcemap: true, + }, + treeshake: { + moduleSideEffects: false, + propertyReadSideEffects: false, + }, + external: ["@azure/msal-common/browser"], + plugins: [ + typescript({ + typescript: require("typescript"), + tsconfig: "tsconfig.custom-auth.build.json", + }), + ], + }, + { + // Custom Auth - CommonJS build + input: "src/custom_auth/index.ts", + output: { + dir: "lib/custom-auth-path", + format: "cjs", + banner: fileHeader, + sourcemap: true, + entryFileNames: "msal-custom-auth.cjs", + inlineDynamicImports: true, + }, + plugins: [ + nodeResolve({ + browser: true, + resolveOnly: ["@azure/msal-common", "tslib"], + }), + typescript({ + typescript: require("typescript"), + tsconfig: "tsconfig.custom-auth.build.json", + sourceMap: true, + compilerOptions: { outDir: "lib/custom-auth-path/types" }, + }), + ], + }, ]; diff --git a/lib/msal-browser/src/custom_auth/CustomAuthActionInputs.ts b/lib/msal-browser/src/custom_auth/CustomAuthActionInputs.ts new file mode 100644 index 0000000000..e90c5ff1f4 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/CustomAuthActionInputs.ts @@ -0,0 +1,37 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { UserAccountAttributes } from "./UserAccountAttributes.js"; + +export type CustomAuthActionInputs = { + correlationId?: string; +}; + +export type AccountRetrievalInputs = CustomAuthActionInputs; + +export type SignInInputs = CustomAuthActionInputs & { + username: string; + password?: string; + scopes?: Array; +}; + +export type SignUpInputs = CustomAuthActionInputs & { + username: string; + password?: string; + attributes?: UserAccountAttributes; +}; + +export type ResetPasswordInputs = CustomAuthActionInputs & { + username: string; +}; + +export type AccessTokenRetrievalInputs = { + forceRefresh: boolean; + scopes?: Array; +}; + +export type SignInWithContinuationTokenInputs = { + scopes?: Array; +}; diff --git a/lib/msal-browser/src/custom_auth/CustomAuthConstants.ts b/lib/msal-browser/src/custom_auth/CustomAuthConstants.ts new file mode 100644 index 0000000000..ba8d7281b5 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/CustomAuthConstants.ts @@ -0,0 +1,50 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { Constants } from "@azure/msal-common/browser"; +import { version } from "../packageMetadata.js"; + +export const GrantType = { + PASSWORD: "password", + OOB: "oob", + CONTINUATION_TOKEN: "continuation_token", + REDIRECT: "redirect", + ATTRIBUTES: "attributes", +} as const; + +export const ChallengeType = { + PASSWORD: "password", + OOB: "oob", + REDIRECT: "redirect", +} as const; + +export const DefaultScopes = [ + Constants.OPENID_SCOPE, + Constants.PROFILE_SCOPE, + Constants.OFFLINE_ACCESS_SCOPE, +] as const; + +export const HttpHeaderKeys = { + CONTENT_TYPE: "Content-Type", + X_MS_REQUEST_ID: "x-ms-request-id", +} as const; + +export const DefaultPackageInfo = { + SKU: "msal.browser", + VERSION: version, + OS: "", + CPU: "", +} as const; + +export const ResetPasswordPollStatus = { + IN_PROGRESS: "in_progress", + SUCCEEDED: "succeeded", + FAILED: "failed", + NOT_STARTED: "not_started", +} as const; + +export const DefaultCustomAuthApiCodeLength = -1; // Default value indicating that the code length is not specified +export const DefaultCustomAuthApiCodeResendIntervalInSec = 300; // seconds +export const PasswordResetPollingTimeoutInMs = 300000; // milliseconds diff --git a/lib/msal-browser/src/custom_auth/CustomAuthPublicClientApplication.ts b/lib/msal-browser/src/custom_auth/CustomAuthPublicClientApplication.ts new file mode 100644 index 0000000000..bd0a16f9b7 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/CustomAuthPublicClientApplication.ts @@ -0,0 +1,158 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { GetAccountResult } from "./get_account/auth_flow/result/GetAccountResult.js"; +import { SignInResult } from "./sign_in/auth_flow/result/SignInResult.js"; +import { SignUpResult } from "./sign_up/auth_flow/result/SignUpResult.js"; +import { ICustomAuthStandardController } from "./controller/ICustomAuthStandardController.js"; +import { CustomAuthStandardController } from "./controller/CustomAuthStandardController.js"; +import { ICustomAuthPublicClientApplication } from "./ICustomAuthPublicClientApplication.js"; +import { + AccountRetrievalInputs, + SignInInputs, + SignUpInputs, + ResetPasswordInputs, +} from "./CustomAuthActionInputs.js"; +import { CustomAuthConfiguration } from "./configuration/CustomAuthConfiguration.js"; +import { CustomAuthOperatingContext } from "./operating_context/CustomAuthOperatingContext.js"; +import { ResetPasswordStartResult } from "./reset_password/auth_flow/result/ResetPasswordStartResult.js"; +import { InvalidConfigurationError } from "./core/error/InvalidConfigurationError.js"; +import { ChallengeType } from "./CustomAuthConstants.js"; +import { PublicClientApplication } from "../app/PublicClientApplication.js"; +import { + InvalidAuthority, + InvalidChallengeType, + MissingConfiguration, +} from "./core/error/InvalidConfigurationErrorCodes.js"; + +export class CustomAuthPublicClientApplication + extends PublicClientApplication + implements ICustomAuthPublicClientApplication +{ + private readonly customAuthController: ICustomAuthStandardController; + + /** + * Creates a new instance of a PublicClientApplication with the given configuration and controller to start Native authentication flows + * @param {CustomAuthConfiguration} config - A configuration object for the PublicClientApplication instance + * @returns {Promise} - A promise that resolves to a CustomAuthPublicClientApplication instance + */ + static async create( + config: CustomAuthConfiguration + ): Promise { + CustomAuthPublicClientApplication.validateConfig(config); + + const customAuthController = new CustomAuthStandardController( + new CustomAuthOperatingContext(config) + ); + + await customAuthController.initialize(); + + const app = new CustomAuthPublicClientApplication( + config, + customAuthController + ); + + return app; + } + + private constructor( + config: CustomAuthConfiguration, + controller: ICustomAuthStandardController + ) { + super(config, controller); + + this.customAuthController = controller; + } + + /** + * Gets the current account from the browser cache. + * @param {AccountRetrievalInputs} accountRetrievalInputs?:AccountRetrievalInputs + * @returns {GetAccountResult} - The result of the get account operation + */ + getCurrentAccount( + accountRetrievalInputs?: AccountRetrievalInputs + ): GetAccountResult { + return this.customAuthController.getCurrentAccount( + accountRetrievalInputs + ); + } + + /** + * Initiates the sign-in flow. + * This method results in sign-in completion, or extra actions (password, code, etc.) required to complete the sign-in. + * Create result with error details if any exception thrown. + * @param {SignInInputs} signInInputs - Inputs for the sign-in flow + * @returns {Promise} - A promise that resolves to SignInResult + */ + signIn(signInInputs: SignInInputs): Promise { + return this.customAuthController.signIn(signInInputs); + } + + /** + * Initiates the sign-up flow. + * This method results in sign-up completion, or extra actions (password, code, etc.) required to complete the sign-up. + * Create result with error details if any exception thrown. + * @param {SignUpInputs} signUpInputs + * @returns {Promise} - A promise that resolves to SignUpResult + */ + signUp(signUpInputs: SignUpInputs): Promise { + return this.customAuthController.signUp(signUpInputs); + } + + /** + * Initiates the reset password flow. + * This method results in triggering extra action (submit code) to complete the reset password. + * Create result with error details if any exception thrown. + * @param {ResetPasswordInputs} resetPasswordInputs - Inputs for the reset password flow + * @returns {Promise} - A promise that resolves to ResetPasswordStartResult + */ + resetPassword( + resetPasswordInputs: ResetPasswordInputs + ): Promise { + return this.customAuthController.resetPassword(resetPasswordInputs); + } + + /** + * Validates the configuration to ensure it is a valid CustomAuthConfiguration object. + * @param {CustomAuthConfiguration} config - The configuration object for the PublicClientApplication. + * @returns {void} + */ + private static validateConfig(config: CustomAuthConfiguration): void { + // Ensure the configuration object has a valid CIAM authority URL. + if (!config) { + throw new InvalidConfigurationError( + MissingConfiguration, + "The configuration is missing." + ); + } + + if (!config.auth?.authority) { + throw new InvalidConfigurationError( + InvalidAuthority, + `The authority URL '${config.auth?.authority}' is not set.` + ); + } + + const challengeTypes = config.customAuth.challengeTypes; + + if (!!challengeTypes && challengeTypes.length > 0) { + challengeTypes.forEach((challengeType) => { + const lowerCaseChallengeType = challengeType.toLowerCase(); + if ( + lowerCaseChallengeType !== ChallengeType.PASSWORD && + lowerCaseChallengeType !== ChallengeType.OOB && + lowerCaseChallengeType !== ChallengeType.REDIRECT + ) { + throw new InvalidConfigurationError( + InvalidChallengeType, + `Challenge type ${challengeType} in the configuration are not valid. Supported challenge types are ${Object.values( + ChallengeType + )}` + ); + } + }); + } + } +} diff --git a/lib/msal-browser/src/custom_auth/ICustomAuthPublicClientApplication.ts b/lib/msal-browser/src/custom_auth/ICustomAuthPublicClientApplication.ts new file mode 100644 index 0000000000..2baa2d63e0 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/ICustomAuthPublicClientApplication.ts @@ -0,0 +1,51 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { GetAccountResult } from "./get_account/auth_flow/result/GetAccountResult.js"; +import { SignInResult } from "./sign_in/auth_flow/result/SignInResult.js"; +import { SignUpResult } from "./sign_up/auth_flow/result/SignUpResult.js"; +import { + AccountRetrievalInputs, + ResetPasswordInputs, + SignInInputs, + SignUpInputs, +} from "./CustomAuthActionInputs.js"; +import { ResetPasswordStartResult } from "./reset_password/auth_flow/result/ResetPasswordStartResult.js"; +import { IPublicClientApplication } from "../app/IPublicClientApplication.js"; + +export interface ICustomAuthPublicClientApplication + extends IPublicClientApplication { + /** + * Gets the current account from the cache. + * @param {AccountRetrievalInputs} accountRetrievalInputs - Inputs for getting the current cached account + * @returns {GetAccountResult} The result of the operation + */ + getCurrentAccount( + accountRetrievalInputs?: AccountRetrievalInputs + ): GetAccountResult; + + /** + * Initiates the sign-in flow. + * @param {SignInInputs} signInInputs - Inputs for the sign-in flow + * @returns {Promise} A promise that resolves to SignInResult + */ + signIn(signInInputs: SignInInputs): Promise; + + /** + * Initiates the sign-up flow. + * @param {SignUpInputs} signUpInputs - Inputs for the sign-up flow + * @returns {Promise} A promise that resolves to SignUpResult + */ + signUp(signUpInputs: SignUpInputs): Promise; + + /** + * Initiates the reset password flow. + * @param {ResetPasswordInputs} resetPasswordInputs - Inputs for the reset password flow + * @returns {Promise} A promise that resolves to ResetPasswordStartResult + */ + resetPassword( + resetPasswordInputs: ResetPasswordInputs + ): Promise; +} diff --git a/lib/msal-browser/src/custom_auth/UserAccountAttributes.ts b/lib/msal-browser/src/custom_auth/UserAccountAttributes.ts new file mode 100644 index 0000000000..6e3213f1b4 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/UserAccountAttributes.ts @@ -0,0 +1,16 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +export type UserAccountAttributes = Record & { + city?: string; + country?: string; + displayName?: string; + givenName?: string; + jobTitle?: string; + postalCode?: string; + state?: string; + streetAddress?: string; + surname?: string; +}; diff --git a/lib/msal-browser/src/custom_auth/configuration/CustomAuthConfiguration.ts b/lib/msal-browser/src/custom_auth/configuration/CustomAuthConfiguration.ts new file mode 100644 index 0000000000..daddb13188 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/configuration/CustomAuthConfiguration.ts @@ -0,0 +1,22 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { + BrowserConfiguration, + Configuration, +} from "../../config/Configuration.js"; + +export type CustomAuthOptions = { + challengeTypes?: Array; + authApiProxyUrl: string; +}; + +export type CustomAuthConfiguration = Configuration & { + customAuth: CustomAuthOptions; +}; + +export type CustomAuthBrowserConfiguration = BrowserConfiguration & { + customAuth: CustomAuthOptions; +}; diff --git a/lib/msal-browser/src/custom_auth/controller/CustomAuthStandardController.ts b/lib/msal-browser/src/custom_auth/controller/CustomAuthStandardController.ts new file mode 100644 index 0000000000..102c337f6a --- /dev/null +++ b/lib/msal-browser/src/custom_auth/controller/CustomAuthStandardController.ts @@ -0,0 +1,509 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { GetAccountResult } from "../get_account/auth_flow/result/GetAccountResult.js"; +import { SignInResult } from "../sign_in/auth_flow/result/SignInResult.js"; +import { SignUpResult } from "../sign_up/auth_flow/result/SignUpResult.js"; +import { + SignInStartParams, + SignInSubmitPasswordParams, +} from "../sign_in/interaction_client/parameter/SignInParams.js"; +import { SignInClient } from "../sign_in/interaction_client/SignInClient.js"; +import { + AccountRetrievalInputs, + SignInInputs, + SignUpInputs, + ResetPasswordInputs, + CustomAuthActionInputs, +} from "../CustomAuthActionInputs.js"; +import { CustomAuthBrowserConfiguration } from "../configuration/CustomAuthConfiguration.js"; +import { CustomAuthOperatingContext } from "../operating_context/CustomAuthOperatingContext.js"; +import { ICustomAuthStandardController } from "./ICustomAuthStandardController.js"; +import { CustomAuthAccountData } from "../get_account/auth_flow/CustomAuthAccountData.js"; +import { UnexpectedError } from "../core/error/UnexpectedError.js"; +import { ResetPasswordStartResult } from "../reset_password/auth_flow/result/ResetPasswordStartResult.js"; +import { CustomAuthAuthority } from "../core/CustomAuthAuthority.js"; +import { DefaultPackageInfo } from "../CustomAuthConstants.js"; +import { + SIGN_IN_CODE_SEND_RESULT_TYPE, + SIGN_IN_PASSWORD_REQUIRED_RESULT_TYPE, +} from "../sign_in/interaction_client/result/SignInActionResult.js"; +import { SignUpClient } from "../sign_up/interaction_client/SignUpClient.js"; +import { CustomAuthInterationClientFactory } from "../core/interaction_client/CustomAuthInterationClientFactory.js"; +import { + SIGN_UP_CODE_REQUIRED_RESULT_TYPE, + SIGN_UP_PASSWORD_REQUIRED_RESULT_TYPE, +} from "../sign_up/interaction_client/result/SignUpActionResult.js"; +import { ICustomAuthApiClient } from "../core/network_client/custom_auth_api/ICustomAuthApiClient.js"; +import { CustomAuthApiClient } from "../core/network_client/custom_auth_api/CustomAuthApiClient.js"; +import { FetchHttpClient } from "../core/network_client/http_client/FetchHttpClient.js"; +import { ResetPasswordClient } from "../reset_password/interaction_client/ResetPasswordClient.js"; +import { NoCachedAccountFoundError } from "../core/error/NoCachedAccountFoundError.js"; +import { + ensureArgumentIsNotEmptyString, + ensureArgumentIsNotNullOrUndefined, +} from "../core/utils/ArgumentValidator.js"; +import { UserAlreadySignedInError } from "../core/error/UserAlreadySignedInError.js"; +import { CustomAuthSilentCacheClient } from "../get_account/interaction_client/CustomAuthSilentCacheClient.js"; +import { UnsupportedEnvironmentError } from "../core/error/UnsupportedEnvironmentError.js"; +import { SignInCodeRequiredState } from "../sign_in/auth_flow/state/SignInCodeRequiredState.js"; +import { SignInPasswordRequiredState } from "../sign_in/auth_flow/state/SignInPasswordRequiredState.js"; +import { SignInCompletedState } from "../sign_in/auth_flow/state/SignInCompletedState.js"; +import { SignUpCodeRequiredState } from "../sign_up/auth_flow/state/SignUpCodeRequiredState.js"; +import { SignUpPasswordRequiredState } from "../sign_up/auth_flow/state/SignUpPasswordRequiredState.js"; +import { ResetPasswordCodeRequiredState } from "../reset_password/auth_flow/state/ResetPasswordCodeRequiredState.js"; +import { StandardController } from "../../controllers/StandardController.js"; + +/* + * Controller for standard native auth operations. + */ +export class CustomAuthStandardController + extends StandardController + implements ICustomAuthStandardController +{ + private readonly signInClient: SignInClient; + private readonly signUpClient: SignUpClient; + private readonly resetPasswordClient: ResetPasswordClient; + private readonly cacheClient: CustomAuthSilentCacheClient; + private readonly customAuthConfig: CustomAuthBrowserConfiguration; + private readonly authority: CustomAuthAuthority; + + /* + * Constructor for CustomAuthStandardController. + * @param operatingContext - The operating context for the controller. + * @param customAuthApiClient - The client to use for custom auth API operations. + */ + constructor( + operatingContext: CustomAuthOperatingContext, + customAuthApiClient?: ICustomAuthApiClient + ) { + super(operatingContext); + + if (!this.isBrowserEnvironment) { + this.logger.verbose( + "The SDK can only be used in a browser environment." + ); + throw new UnsupportedEnvironmentError(); + } + + this.logger = this.logger.clone( + DefaultPackageInfo.SKU, + DefaultPackageInfo.VERSION + ); + this.customAuthConfig = operatingContext.getCustomAuthConfig(); + + this.authority = new CustomAuthAuthority( + this.customAuthConfig.auth.authority, + this.customAuthConfig, + this.networkClient, + this.browserStorage, + this.logger, + this.customAuthConfig.customAuth?.authApiProxyUrl + ); + + const interactionClientFactory = new CustomAuthInterationClientFactory( + this.customAuthConfig, + this.browserStorage, + this.browserCrypto, + this.logger, + this.eventHandler, + this.navigationClient, + this.performanceClient, + customAuthApiClient ?? + new CustomAuthApiClient( + this.authority.getCustomAuthApiDomain(), + this.customAuthConfig.auth.clientId, + new FetchHttpClient(this.logger) + ), + this.authority + ); + + this.signInClient = interactionClientFactory.create(SignInClient); + this.signUpClient = interactionClientFactory.create(SignUpClient); + this.resetPasswordClient = + interactionClientFactory.create(ResetPasswordClient); + this.cacheClient = interactionClientFactory.create( + CustomAuthSilentCacheClient + ); + } + + /* + * Gets the current account from the cache. + * @param accountRetrievalInputs - Inputs for getting the current cached account + * @returns {GetAccountResult} The account result + */ + getCurrentAccount( + accountRetrievalInputs?: AccountRetrievalInputs + ): GetAccountResult { + const correlationId = this.getCorrelationId(accountRetrievalInputs); + try { + this.logger.verbose("Getting current account data.", correlationId); + + const account = this.cacheClient.getCurrentAccount(correlationId); + + if (account) { + this.logger.verbose("Account data found.", correlationId); + + return new GetAccountResult( + new CustomAuthAccountData( + account, + this.customAuthConfig, + this.cacheClient, + this.logger, + correlationId + ) + ); + } + + throw new NoCachedAccountFoundError(correlationId); + } catch (error) { + this.logger.errorPii( + `An error occurred during getting current account: ${error}`, + correlationId + ); + + return GetAccountResult.createWithError(error); + } + } + + /* + * Signs the user in. + * @param signInInputs - Inputs for signing in the user. + * @returns {Promise} The result of the operation. + */ + async signIn(signInInputs: SignInInputs): Promise { + const correlationId = this.getCorrelationId(signInInputs); + + try { + ensureArgumentIsNotNullOrUndefined( + "signInInputs", + signInInputs, + correlationId + ); + + ensureArgumentIsNotEmptyString( + "signInInputs.username", + signInInputs.username, + correlationId + ); + this.ensureUserNotSignedIn(correlationId); + + // start the signin flow + const signInStartParams: SignInStartParams = { + clientId: this.customAuthConfig.auth.clientId, + correlationId: correlationId, + challengeType: + this.customAuthConfig.customAuth.challengeTypes ?? [], + username: signInInputs.username, + password: signInInputs.password, + }; + + this.logger.verbose( + `Starting sign-in flow ${ + !!signInInputs.password ? "with" : "without" + } password.`, + correlationId + ); + + const startResult = await this.signInClient.start( + signInStartParams + ); + + this.logger.verbose("Sign-in flow started.", correlationId); + + if (startResult.type === SIGN_IN_CODE_SEND_RESULT_TYPE) { + // require code + this.logger.verbose( + "Code required for sign-in.", + correlationId + ); + + return new SignInResult( + new SignInCodeRequiredState({ + correlationId: startResult.correlationId, + continuationToken: startResult.continuationToken, + logger: this.logger, + config: this.customAuthConfig, + signInClient: this.signInClient, + cacheClient: this.cacheClient, + username: signInInputs.username, + codeLength: startResult.codeLength, + scopes: signInInputs.scopes ?? [], + }) + ); + } else if ( + startResult.type === SIGN_IN_PASSWORD_REQUIRED_RESULT_TYPE + ) { + // require password + this.logger.verbose( + "Password required for sign-in.", + correlationId + ); + + if (!signInInputs.password) { + this.logger.verbose( + "Password required but not provided. Returning password required state.", + correlationId + ); + + return new SignInResult( + new SignInPasswordRequiredState({ + correlationId: startResult.correlationId, + continuationToken: startResult.continuationToken, + logger: this.logger, + config: this.customAuthConfig, + signInClient: this.signInClient, + cacheClient: this.cacheClient, + username: signInInputs.username, + scopes: signInInputs.scopes ?? [], + }) + ); + } + + this.logger.verbose( + "Submitting password for sign-in.", + correlationId + ); + + // if the password is provided, then try to get token silently. + const submitPasswordParams: SignInSubmitPasswordParams = { + clientId: this.customAuthConfig.auth.clientId, + correlationId: correlationId, + challengeType: + this.customAuthConfig.customAuth.challengeTypes ?? [], + scopes: signInInputs.scopes ?? [], + continuationToken: startResult.continuationToken, + password: signInInputs.password, + username: signInInputs.username, + }; + + const completedResult = await this.signInClient.submitPassword( + submitPasswordParams + ); + + this.logger.verbose("Sign-in flow completed.", correlationId); + + const accountInfo = new CustomAuthAccountData( + completedResult.authenticationResult.account, + this.customAuthConfig, + this.cacheClient, + this.logger, + correlationId + ); + + return new SignInResult( + new SignInCompletedState(), + accountInfo + ); + } + + this.logger.error( + "Unexpected sign-in result type. Returning error.", + correlationId + ); + + throw new UnexpectedError( + "Unknow sign-in result type", + correlationId + ); + } catch (error) { + this.logger.errorPii( + `An error occurred during starting sign-in: ${error}`, + correlationId + ); + + return SignInResult.createWithError(error); + } + } + + /* + * Signs the user up. + * @param signUpInputs - Inputs for signing up the user. + * @returns {Promise} The result of the operation + */ + async signUp(signUpInputs: SignUpInputs): Promise { + const correlationId = this.getCorrelationId(signUpInputs); + + try { + ensureArgumentIsNotNullOrUndefined( + "signUpInputs", + signUpInputs, + correlationId + ); + + ensureArgumentIsNotEmptyString( + "signUpInputs.username", + signUpInputs.username, + correlationId + ); + this.ensureUserNotSignedIn(correlationId); + + this.logger.verbose( + `Starting sign-up flow${ + !!signUpInputs.password + ? ` with ${ + !!signUpInputs.attributes + ? "password and attributes" + : "password" + }` + : "" + }.`, + correlationId + ); + + const startResult = await this.signUpClient.start({ + clientId: this.customAuthConfig.auth.clientId, + correlationId: correlationId, + challengeType: + this.customAuthConfig.customAuth.challengeTypes ?? [], + username: signUpInputs.username, + password: signUpInputs.password, + attributes: signUpInputs.attributes, + }); + + this.logger.verbose("Sign-up flow started.", correlationId); + + if (startResult.type === SIGN_UP_CODE_REQUIRED_RESULT_TYPE) { + // Code required + this.logger.verbose( + "Code required for sign-up.", + correlationId + ); + + return new SignUpResult( + new SignUpCodeRequiredState({ + correlationId: startResult.correlationId, + continuationToken: startResult.continuationToken, + logger: this.logger, + config: this.customAuthConfig, + signInClient: this.signInClient, + signUpClient: this.signUpClient, + cacheClient: this.cacheClient, + username: signUpInputs.username, + codeLength: startResult.codeLength, + codeResendInterval: startResult.interval, + }) + ); + } else if ( + startResult.type === SIGN_UP_PASSWORD_REQUIRED_RESULT_TYPE + ) { + // Password required + this.logger.verbose( + "Password required for sign-up.", + correlationId + ); + + return new SignUpResult( + new SignUpPasswordRequiredState({ + correlationId: startResult.correlationId, + continuationToken: startResult.continuationToken, + logger: this.logger, + config: this.customAuthConfig, + signInClient: this.signInClient, + signUpClient: this.signUpClient, + cacheClient: this.cacheClient, + username: signUpInputs.username, + }) + ); + } + + this.logger.error( + "Unexpected sign-up result type. Returning error.", + correlationId + ); + + throw new UnexpectedError( + "Unknown sign-up result type", + correlationId + ); + } catch (error) { + this.logger.errorPii( + `An error occurred during starting sign-up: ${error}`, + correlationId + ); + + return SignUpResult.createWithError(error); + } + } + + /* + * Resets the user's password. + * @param resetPasswordInputs - Inputs for resetting the user's password. + * @returns {Promise} The result of the operation. + */ + async resetPassword( + resetPasswordInputs: ResetPasswordInputs + ): Promise { + const correlationId = this.getCorrelationId(resetPasswordInputs); + + try { + ensureArgumentIsNotNullOrUndefined( + "resetPasswordInputs", + resetPasswordInputs, + correlationId + ); + + ensureArgumentIsNotEmptyString( + "resetPasswordInputs.username", + resetPasswordInputs.username, + correlationId + ); + this.ensureUserNotSignedIn(correlationId); + + this.logger.verbose("Starting password-reset flow.", correlationId); + + const startResult = await this.resetPasswordClient.start({ + clientId: this.customAuthConfig.auth.clientId, + correlationId: correlationId, + challengeType: + this.customAuthConfig.customAuth.challengeTypes ?? [], + username: resetPasswordInputs.username, + }); + + this.logger.verbose("Password-reset flow started.", correlationId); + + return new ResetPasswordStartResult( + new ResetPasswordCodeRequiredState({ + correlationId: startResult.correlationId, + continuationToken: startResult.continuationToken, + logger: this.logger, + config: this.customAuthConfig, + signInClient: this.signInClient, + resetPasswordClient: this.resetPasswordClient, + cacheClient: this.cacheClient, + username: resetPasswordInputs.username, + codeLength: startResult.codeLength, + }) + ); + } catch (error) { + this.logger.errorPii( + `An error occurred during starting reset-password: ${error}`, + correlationId + ); + + return ResetPasswordStartResult.createWithError(error); + } + } + + private getCorrelationId( + actionInputs: CustomAuthActionInputs | undefined + ): string { + return ( + actionInputs?.correlationId || this.browserCrypto.createNewGuid() + ); + } + + private ensureUserNotSignedIn(correlationId: string): void { + const account = this.getCurrentAccount({ + correlationId: correlationId, + }); + + if (account && !!account.data) { + this.logger.error("User has already signed in.", correlationId); + + throw new UserAlreadySignedInError(correlationId); + } + } +} diff --git a/lib/msal-browser/src/custom_auth/controller/ICustomAuthStandardController.ts b/lib/msal-browser/src/custom_auth/controller/ICustomAuthStandardController.ts new file mode 100644 index 0000000000..9be4ae79ae --- /dev/null +++ b/lib/msal-browser/src/custom_auth/controller/ICustomAuthStandardController.ts @@ -0,0 +1,53 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { GetAccountResult } from "../get_account/auth_flow/result/GetAccountResult.js"; +import { SignInResult } from "../sign_in/auth_flow/result/SignInResult.js"; +import { SignUpResult } from "../sign_up/auth_flow/result/SignUpResult.js"; +import { + AccountRetrievalInputs, + ResetPasswordInputs, + SignInInputs, + SignUpInputs, +} from "../CustomAuthActionInputs.js"; +import { ResetPasswordStartResult } from "../reset_password/auth_flow/result/ResetPasswordStartResult.js"; +import { IController } from "../../controllers/IController.js"; + +/* + * Controller interface for standard authentication operations. + */ +export interface ICustomAuthStandardController extends IController { + /* + * Gets the current account from the cache. + * @param accountRetrievalInputs - Inputs for getting the current cached account + * @returns - The result of the operation + */ + getCurrentAccount( + accountRetrievalInputs?: AccountRetrievalInputs + ): GetAccountResult; + + /* + * Signs the current user out. + * @param signInInputs - Inputs for signing in. + * @returns The result of the operation. + */ + signIn(signInInputs: SignInInputs): Promise; + + /* + * Signs the current user up. + * @param signUpInputs - Inputs for signing up. + * @returns The result of the operation. + */ + signUp(signUpInputs: SignUpInputs): Promise; + + /* + * Resets the password for the current user. + * @param resetPasswordInputs - Inputs for resetting the password. + * @returns The result of the operation. + */ + resetPassword( + resetPasswordInputs: ResetPasswordInputs + ): Promise; +} diff --git a/lib/msal-browser/src/custom_auth/core/CustomAuthAuthority.ts b/lib/msal-browser/src/custom_auth/core/CustomAuthAuthority.ts new file mode 100644 index 0000000000..ae1be3f7cd --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/CustomAuthAuthority.ts @@ -0,0 +1,111 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { + Authority, + AuthorityOptions, + INetworkModule, + Logger, +} from "@azure/msal-common/browser"; +import * as CustomAuthApiEndpoint from "./network_client/custom_auth_api/CustomAuthApiEndpoint.js"; +import { buildUrl } from "./utils/UrlUtils.js"; +import { BrowserConfiguration } from "../../config/Configuration.js"; +import { BrowserCacheManager } from "../../cache/BrowserCacheManager.js"; + +/** + * Authority class which can be used to create an authority object for Custom Auth features. + */ +export class CustomAuthAuthority extends Authority { + /** + * Constructor for the Custom Auth Authority. + * @param authority - The authority URL for the authority. + * @param networkInterface - The network interface implementation to make requests. + * @param cacheManager - The cache manager. + * @param authorityOptions - The options for the authority. + * @param logger - The logger for the authority. + * @param customAuthProxyDomain - The custom auth proxy domain. + */ + constructor( + authority: string, + config: BrowserConfiguration, + networkInterface: INetworkModule, + cacheManager: BrowserCacheManager, + logger: Logger, + private customAuthProxyDomain?: string + ) { + const ciamAuthorityUrl = + CustomAuthAuthority.transformCIAMAuthority(authority); + + const authorityOptions: AuthorityOptions = { + protocolMode: config.system.protocolMode, + OIDCOptions: config.auth.OIDCOptions, + knownAuthorities: config.auth.knownAuthorities, + cloudDiscoveryMetadata: config.auth.cloudDiscoveryMetadata, + authorityMetadata: config.auth.authorityMetadata, + }; + + super( + ciamAuthorityUrl, + networkInterface, + cacheManager, + authorityOptions, + logger, + "" + ); + + // Set the metadata for the authority + const metadataEntity = { + aliases: [this.hostnameAndPort], + preferred_cache: this.getPreferredCache(), + preferred_network: this.hostnameAndPort, + canonical_authority: this.canonicalAuthority, + authorization_endpoint: "", + token_endpoint: this.tokenEndpoint, + end_session_endpoint: "", + issuer: "", + aliasesFromNetwork: false, + endpointsFromNetwork: false, + /* + * give max value to make sure it doesn't expire, + * as we only initiate the authority metadata entity once and it doesn't change + */ + expiresAt: Number.MAX_SAFE_INTEGER, + jwks_uri: "", + }; + const cacheKey = this.cacheManager.generateAuthorityMetadataCacheKey( + metadataEntity.preferred_cache + ); + cacheManager.setAuthorityMetadata(cacheKey, metadataEntity); + } + + /** + * Gets the custom auth endpoint. + * The open id configuration doesn't have the correct endpoint for the auth APIs. + * We need to generate the endpoint manually based on the authority URL. + * @returns The custom auth endpoint + */ + getCustomAuthApiDomain(): string { + /* + * The customAuthProxyDomain is used to resolve the CORS issue when calling the auth APIs. + * If the customAuthProxyDomain is not provided, we will generate the auth API domain based on the authority URL. + */ + return !this.customAuthProxyDomain + ? this.canonicalAuthority + : this.customAuthProxyDomain; + } + + override getPreferredCache(): string { + return this.canonicalAuthorityUrlComponents.HostNameAndPort; + } + + override get tokenEndpoint(): string { + const endpointUrl = buildUrl( + this.getCustomAuthApiDomain(), + CustomAuthApiEndpoint.SIGNIN_TOKEN + ); + + return endpointUrl.href; + } +} diff --git a/lib/msal-browser/src/custom_auth/core/auth_flow/AuthFlowErrorBase.ts b/lib/msal-browser/src/custom_auth/core/auth_flow/AuthFlowErrorBase.ts new file mode 100644 index 0000000000..2ceebef5a5 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/auth_flow/AuthFlowErrorBase.ts @@ -0,0 +1,140 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { + CustomAuthApiError, + RedirectError, +} from "../error/CustomAuthApiError.js"; +import { CustomAuthError } from "../error/CustomAuthError.js"; +import { NoCachedAccountFoundError } from "../error/NoCachedAccountFoundError.js"; +import { InvalidArgumentError } from "../error/InvalidArgumentError.js"; +import * as CustomAuthApiErrorCode from "../network_client/custom_auth_api/types/ApiErrorCodes.js"; +import * as CustomAuthApiSuberror from "../network_client/custom_auth_api/types/ApiSuberrors.js"; +/** + * Base class for all auth flow errors. + */ +export abstract class AuthFlowErrorBase { + constructor(public errorData: CustomAuthError) {} + + protected isUserNotFoundError(): boolean { + return this.errorData.error === CustomAuthApiErrorCode.USER_NOT_FOUND; + } + + protected isUserInvalidError(): boolean { + return ( + (this.errorData instanceof InvalidArgumentError && + this.errorData.errorDescription?.includes("username")) || + (this.errorData instanceof CustomAuthApiError && + !!this.errorData.errorDescription?.includes( + "username parameter is empty or not valid" + ) && + !!this.errorData.errorCodes?.includes(90100)) + ); + } + + protected isUnsupportedChallengeTypeError(): boolean { + return ( + (this.errorData.error === CustomAuthApiErrorCode.INVALID_REQUEST && + (this.errorData.errorDescription?.includes( + "The challenge_type list parameter contains an unsupported challenge type" + ) ?? + false)) || + this.errorData.error === + CustomAuthApiErrorCode.UNSUPPORTED_CHALLENGE_TYPE + ); + } + + protected isPasswordIncorrectError(): boolean { + const isIncorrectPassword = + this.errorData.error === CustomAuthApiErrorCode.INVALID_GRANT && + this.errorData instanceof CustomAuthApiError && + (this.errorData.errorCodes ?? []).includes(50126); + + const isPasswordEmpty = + this.errorData instanceof InvalidArgumentError && + this.errorData.errorDescription?.includes("password") === true; + + return isIncorrectPassword || isPasswordEmpty; + } + + protected isInvalidCodeError(): boolean { + return ( + (this.errorData.error === CustomAuthApiErrorCode.INVALID_GRANT && + this.errorData instanceof CustomAuthApiError && + this.errorData.subError === + CustomAuthApiSuberror.INVALID_OOB_VALUE) || + (this.errorData instanceof InvalidArgumentError && + this.errorData.errorDescription?.includes("code") === true) + ); + } + + protected isRedirectError(): boolean { + return this.errorData instanceof RedirectError; + } + + protected isInvalidNewPasswordError(): boolean { + const invalidPasswordSubErrors = new Set([ + CustomAuthApiSuberror.PASSWORD_BANNED, + CustomAuthApiSuberror.PASSWORD_IS_INVALID, + CustomAuthApiSuberror.PASSWORD_RECENTLY_USED, + CustomAuthApiSuberror.PASSWORD_TOO_LONG, + CustomAuthApiSuberror.PASSWORD_TOO_SHORT, + CustomAuthApiSuberror.PASSWORD_TOO_WEAK, + ]); + + return ( + this.errorData instanceof CustomAuthApiError && + this.errorData.error === CustomAuthApiErrorCode.INVALID_GRANT && + invalidPasswordSubErrors.has(this.errorData.subError ?? "") + ); + } + + protected isUserAlreadyExistsError(): boolean { + return ( + this.errorData instanceof CustomAuthApiError && + this.errorData.error === CustomAuthApiErrorCode.USER_ALREADY_EXISTS + ); + } + + protected isAttributeRequiredError(): boolean { + return ( + this.errorData instanceof CustomAuthApiError && + this.errorData.error === CustomAuthApiErrorCode.ATTRIBUTES_REQUIRED + ); + } + + protected isAttributeValidationFailedError(): boolean { + return ( + (this.errorData instanceof CustomAuthApiError && + this.errorData.error === CustomAuthApiErrorCode.INVALID_GRANT && + this.errorData.subError === + CustomAuthApiSuberror.ATTRIBUTE_VALIATION_FAILED) || + (this.errorData instanceof InvalidArgumentError && + this.errorData.errorDescription?.includes("attributes") === + true) + ); + } + + protected isNoCachedAccountFoundError(): boolean { + return this.errorData instanceof NoCachedAccountFoundError; + } + + protected isTokenExpiredError(): boolean { + return ( + this.errorData instanceof CustomAuthApiError && + this.errorData.error === CustomAuthApiErrorCode.EXPIRED_TOKEN + ); + } +} + +export abstract class AuthActionErrorBase extends AuthFlowErrorBase { + /** + * Checks if the error is due to the expired continuation token. + * @returns {boolean} True if the error is due to the expired continuation token, false otherwise. + */ + isTokenExpired(): boolean { + return this.isTokenExpiredError(); + } +} diff --git a/lib/msal-browser/src/custom_auth/core/auth_flow/AuthFlowResultBase.ts b/lib/msal-browser/src/custom_auth/core/auth_flow/AuthFlowResultBase.ts new file mode 100644 index 0000000000..4edc3b934b --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/auth_flow/AuthFlowResultBase.ts @@ -0,0 +1,55 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthError } from "@azure/msal-common/browser"; +import { CustomAuthError } from "../error/CustomAuthError.js"; +import { MsalCustomAuthError } from "../error/MsalCustomAuthError.js"; +import { UnexpectedError } from "../error/UnexpectedError.js"; +import { AuthFlowErrorBase } from "./AuthFlowErrorBase.js"; +import { AuthFlowStateBase } from "./AuthFlowState.js"; + +/* + * Base class for a result of an authentication operation. + * @typeParam TState - The type of the auth flow state. + * @typeParam TError - The type of error. + * @typeParam TData - The type of the result data. + */ +export abstract class AuthFlowResultBase< + TState extends AuthFlowStateBase, + TError extends AuthFlowErrorBase, + TData = void +> { + /* + *constructor for ResultBase + * @param state - The state. + * @param data - The result data. + */ + constructor(public state: TState, public data?: TData) {} + + /* + * The error that occurred during the authentication operation. + */ + error?: TError; + + /* + * Creates a CustomAuthError with an error. + * @param error - The error that occurred. + * @returns The auth error. + */ + protected static createErrorData(error: unknown): CustomAuthError { + if (error instanceof CustomAuthError) { + return error; + } else if (error instanceof AuthError) { + return new MsalCustomAuthError( + error.errorCode, + error.errorMessage, + error.subError, + error.correlationId + ); + } else { + return new UnexpectedError(error); + } + } +} diff --git a/lib/msal-browser/src/custom_auth/core/auth_flow/AuthFlowState.ts b/lib/msal-browser/src/custom_auth/core/auth_flow/AuthFlowState.ts new file mode 100644 index 0000000000..8224f1ce2c --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/auth_flow/AuthFlowState.ts @@ -0,0 +1,73 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { InvalidArgumentError } from "../error/InvalidArgumentError.js"; +import { CustomAuthBrowserConfiguration } from "../../configuration/CustomAuthConfiguration.js"; +import { Logger } from "@azure/msal-common/browser"; +import { ensureArgumentIsNotEmptyString } from "../utils/ArgumentValidator.js"; +import { DefaultCustomAuthApiCodeLength } from "../../CustomAuthConstants.js"; + +export interface AuthFlowActionRequiredStateParameters { + correlationId: string; + logger: Logger; + config: CustomAuthBrowserConfiguration; + continuationToken?: string; +} + +/** + * Base class for the state of an authentication flow. + */ +export abstract class AuthFlowStateBase {} + +/** + * Base class for the action requried state in an authentication flow. + */ +export abstract class AuthFlowActionRequiredStateBase< + TParameter extends AuthFlowActionRequiredStateParameters +> extends AuthFlowStateBase { + /** + * Creates a new instance of AuthFlowActionRequiredStateBase. + * @param stateParameters The parameters for the auth state. + */ + protected constructor(protected readonly stateParameters: TParameter) { + ensureArgumentIsNotEmptyString( + "correlationId", + stateParameters.correlationId + ); + + super(); + } + + protected ensureCodeIsValid(code: string, codeLength: number): void { + if ( + codeLength !== DefaultCustomAuthApiCodeLength && + (!code || code.length !== codeLength) + ) { + this.stateParameters.logger.error( + "Code parameter is not provided or invalid for authentication flow.", + this.stateParameters.correlationId + ); + + throw new InvalidArgumentError( + "code", + this.stateParameters.correlationId + ); + } + } + + protected ensurePasswordIsNotEmpty(password: string): void { + if (!password) { + this.stateParameters.logger.error( + "Password parameter is not provided for authentication flow.", + this.stateParameters.correlationId + ); + + throw new InvalidArgumentError( + "password", + this.stateParameters.correlationId + ); + } + } +} diff --git a/lib/msal-browser/src/custom_auth/core/error/CustomAuthApiError.ts b/lib/msal-browser/src/custom_auth/core/error/CustomAuthApiError.ts new file mode 100644 index 0000000000..d0dbf33629 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/error/CustomAuthApiError.ts @@ -0,0 +1,41 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { UserAttribute } from "../network_client/custom_auth_api/types/ApiErrorResponseTypes.js"; +import { CustomAuthError } from "./CustomAuthError.js"; + +/** + * Error when no required authentication method by Microsoft Entra is supported + */ +export class RedirectError extends CustomAuthError { + constructor(correlationId?: string) { + super( + "redirect", + "No required authentication method by Microsoft Entra is supported, a fallback to the web-based authentication flow is needed.", + correlationId + ); + Object.setPrototypeOf(this, RedirectError.prototype); + } +} + +/** + * Custom Auth API error. + */ +export class CustomAuthApiError extends CustomAuthError { + constructor( + error: string, + errorDescription: string, + correlationId?: string, + errorCodes?: Array, + subError?: string, + public attributes?: Array, + public continuationToken?: string, + public traceId?: string, + public timestamp?: string + ) { + super(error, errorDescription, correlationId, errorCodes, subError); + Object.setPrototypeOf(this, CustomAuthApiError.prototype); + } +} diff --git a/lib/msal-browser/src/custom_auth/core/error/CustomAuthError.ts b/lib/msal-browser/src/custom_auth/core/error/CustomAuthError.ts new file mode 100644 index 0000000000..16578d080d --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/error/CustomAuthError.ts @@ -0,0 +1,20 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +export class CustomAuthError extends Error { + constructor( + public error: string, + public errorDescription?: string, + public correlationId?: string, + public errorCodes?: Array, + public subError?: string + ) { + super(`${error}: ${errorDescription ?? ""}`); + Object.setPrototypeOf(this, CustomAuthError.prototype); + + this.errorCodes = errorCodes ?? []; + this.subError = subError ?? ""; + } +} diff --git a/lib/msal-browser/src/custom_auth/core/error/HttpError.ts b/lib/msal-browser/src/custom_auth/core/error/HttpError.ts new file mode 100644 index 0000000000..e49500a9b4 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/error/HttpError.ts @@ -0,0 +1,13 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthError } from "./CustomAuthError.js"; + +export class HttpError extends CustomAuthError { + constructor(error: string, message: string, correlationId?: string) { + super(error, message, correlationId); + Object.setPrototypeOf(this, HttpError.prototype); + } +} diff --git a/lib/msal-browser/src/custom_auth/core/error/HttpErrorCodes.ts b/lib/msal-browser/src/custom_auth/core/error/HttpErrorCodes.ts new file mode 100644 index 0000000000..a78da3450e --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/error/HttpErrorCodes.ts @@ -0,0 +1,7 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +export const NoNetworkConnectivity = "no_network_connectivity"; +export const FailedSendRequest = "failed_send_request"; diff --git a/lib/msal-browser/src/custom_auth/core/error/InvalidArgumentError.ts b/lib/msal-browser/src/custom_auth/core/error/InvalidArgumentError.ts new file mode 100644 index 0000000000..7ba7ce0cab --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/error/InvalidArgumentError.ts @@ -0,0 +1,15 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthError } from "./CustomAuthError.js"; + +export class InvalidArgumentError extends CustomAuthError { + constructor(argName: string, correlationId?: string) { + const errorDescription = `The argument '${argName}' is invalid.`; + + super("invalid_argument", errorDescription, correlationId); + Object.setPrototypeOf(this, InvalidArgumentError.prototype); + } +} diff --git a/lib/msal-browser/src/custom_auth/core/error/InvalidConfigurationError.ts b/lib/msal-browser/src/custom_auth/core/error/InvalidConfigurationError.ts new file mode 100644 index 0000000000..42121f706e --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/error/InvalidConfigurationError.ts @@ -0,0 +1,13 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthError } from "./CustomAuthError.js"; + +export class InvalidConfigurationError extends CustomAuthError { + constructor(error: string, message: string, correlationId?: string) { + super(error, message, correlationId); + Object.setPrototypeOf(this, InvalidConfigurationError.prototype); + } +} diff --git a/lib/msal-browser/src/custom_auth/core/error/InvalidConfigurationErrorCodes.ts b/lib/msal-browser/src/custom_auth/core/error/InvalidConfigurationErrorCodes.ts new file mode 100644 index 0000000000..c0f2ae6c4c --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/error/InvalidConfigurationErrorCodes.ts @@ -0,0 +1,8 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +export const MissingConfiguration = "missing_configuration"; +export const InvalidAuthority = "invalid_authority"; +export const InvalidChallengeType = "invalid_challenge_type"; diff --git a/lib/msal-browser/src/custom_auth/core/error/MethodNotImplementedError.ts b/lib/msal-browser/src/custom_auth/core/error/MethodNotImplementedError.ts new file mode 100644 index 0000000000..05f24ec0ca --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/error/MethodNotImplementedError.ts @@ -0,0 +1,15 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthError } from "./CustomAuthError.js"; + +export class MethodNotImplementedError extends CustomAuthError { + constructor(method: string, correlationId?: string) { + const errorDescription = `The method '${method}' is not implemented, please do not use.`; + + super("method_not_implemented", errorDescription, correlationId); + Object.setPrototypeOf(this, MethodNotImplementedError.prototype); + } +} diff --git a/lib/msal-browser/src/custom_auth/core/error/MsalCustomAuthError.ts b/lib/msal-browser/src/custom_auth/core/error/MsalCustomAuthError.ts new file mode 100644 index 0000000000..d9bcc04104 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/error/MsalCustomAuthError.ts @@ -0,0 +1,22 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthError } from "./CustomAuthError.js"; + +export class MsalCustomAuthError extends CustomAuthError { + subError: string | undefined; + + constructor( + error: string, + errorDescription?: string, + subError?: string, + correlationId?: string + ) { + super(error, errorDescription, correlationId); + Object.setPrototypeOf(this, MsalCustomAuthError.prototype); + + this.subError = subError || ""; + } +} diff --git a/lib/msal-browser/src/custom_auth/core/error/NoCachedAccountFoundError.ts b/lib/msal-browser/src/custom_auth/core/error/NoCachedAccountFoundError.ts new file mode 100644 index 0000000000..65bc14ae7d --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/error/NoCachedAccountFoundError.ts @@ -0,0 +1,17 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthError } from "./CustomAuthError.js"; + +export class NoCachedAccountFoundError extends CustomAuthError { + constructor(correlationId?: string) { + super( + "no_cached_account_found", + "No account found in the cache", + correlationId + ); + Object.setPrototypeOf(this, NoCachedAccountFoundError.prototype); + } +} diff --git a/lib/msal-browser/src/custom_auth/core/error/ParsedUrlError.ts b/lib/msal-browser/src/custom_auth/core/error/ParsedUrlError.ts new file mode 100644 index 0000000000..c8dca0a9e1 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/error/ParsedUrlError.ts @@ -0,0 +1,13 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthError } from "./CustomAuthError.js"; + +export class ParsedUrlError extends CustomAuthError { + constructor(error: string, message: string, correlationId?: string) { + super(error, message, correlationId); + Object.setPrototypeOf(this, ParsedUrlError.prototype); + } +} diff --git a/lib/msal-browser/src/custom_auth/core/error/ParsedUrlErrorCodes.ts b/lib/msal-browser/src/custom_auth/core/error/ParsedUrlErrorCodes.ts new file mode 100644 index 0000000000..7c1f0dc10c --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/error/ParsedUrlErrorCodes.ts @@ -0,0 +1,6 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +export const InvalidUrl = "invalid_url"; diff --git a/lib/msal-browser/src/custom_auth/core/error/UnexpectedError.ts b/lib/msal-browser/src/custom_auth/core/error/UnexpectedError.ts new file mode 100644 index 0000000000..d84c6a1e36 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/error/UnexpectedError.ts @@ -0,0 +1,25 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthError } from "./CustomAuthError.js"; + +export class UnexpectedError extends CustomAuthError { + constructor(errorData: unknown, correlationId?: string) { + let errorDescription: string; + + if (errorData instanceof Error) { + errorDescription = errorData.message; + } else if (typeof errorData === "string") { + errorDescription = errorData; + } else if (typeof errorData === "object" && errorData !== null) { + errorDescription = JSON.stringify(errorData); + } else { + errorDescription = "An unexpected error occurred."; + } + + super("unexpected_error", errorDescription, correlationId); + Object.setPrototypeOf(this, UnexpectedError.prototype); + } +} diff --git a/lib/msal-browser/src/custom_auth/core/error/UnsupportedEnvironmentError.ts b/lib/msal-browser/src/custom_auth/core/error/UnsupportedEnvironmentError.ts new file mode 100644 index 0000000000..8952238b49 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/error/UnsupportedEnvironmentError.ts @@ -0,0 +1,17 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthError } from "./CustomAuthError.js"; + +export class UnsupportedEnvironmentError extends CustomAuthError { + constructor(correlationId?: string) { + super( + "unsupported_env", + "The current environment is not browser", + correlationId + ); + Object.setPrototypeOf(this, UnsupportedEnvironmentError.prototype); + } +} diff --git a/lib/msal-browser/src/custom_auth/core/error/UserAccountAttributeError.ts b/lib/msal-browser/src/custom_auth/core/error/UserAccountAttributeError.ts new file mode 100644 index 0000000000..4ef2610d5f --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/error/UserAccountAttributeError.ts @@ -0,0 +1,15 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthError } from "./CustomAuthError.js"; + +export class UserAccountAttributeError extends CustomAuthError { + constructor(error: string, attributeName: string, attributeValue: string) { + const errorDescription = `Failed to set attribute '${attributeName}' with value '${attributeValue}'`; + + super(error, errorDescription); + Object.setPrototypeOf(this, UserAccountAttributeError.prototype); + } +} diff --git a/lib/msal-browser/src/custom_auth/core/error/UserAccountAttributeErrorCodes.ts b/lib/msal-browser/src/custom_auth/core/error/UserAccountAttributeErrorCodes.ts new file mode 100644 index 0000000000..9e552a0236 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/error/UserAccountAttributeErrorCodes.ts @@ -0,0 +1,6 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +export const InvalidAttributeErrorCode = "invalid_attribute"; diff --git a/lib/msal-browser/src/custom_auth/core/error/UserAlreadySignedInError.ts b/lib/msal-browser/src/custom_auth/core/error/UserAlreadySignedInError.ts new file mode 100644 index 0000000000..b2556b04ff --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/error/UserAlreadySignedInError.ts @@ -0,0 +1,17 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthError } from "./CustomAuthError.js"; + +export class UserAlreadySignedInError extends CustomAuthError { + constructor(correlationId?: string) { + super( + "user_already_signed_in", + "The user has already signed in.", + correlationId + ); + Object.setPrototypeOf(this, UserAlreadySignedInError.prototype); + } +} diff --git a/lib/msal-browser/src/custom_auth/core/interaction_client/CustomAuthInteractionClientBase.ts b/lib/msal-browser/src/custom_auth/core/interaction_client/CustomAuthInteractionClientBase.ts new file mode 100644 index 0000000000..efafd5bd43 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/interaction_client/CustomAuthInteractionClientBase.ts @@ -0,0 +1,92 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { ICustomAuthApiClient } from "../network_client/custom_auth_api/ICustomAuthApiClient.js"; +import { MethodNotImplementedError } from "../error/MethodNotImplementedError.js"; +import { CustomAuthAuthority } from "../CustomAuthAuthority.js"; +import { ChallengeType } from "../../CustomAuthConstants.js"; +import { StandardInteractionClient } from "../../../interaction_client/StandardInteractionClient.js"; +import { BrowserConfiguration } from "../../../config/Configuration.js"; +import { BrowserCacheManager } from "../../../cache/BrowserCacheManager.js"; +import { + Constants, + ICrypto, + IPerformanceClient, + Logger, +} from "@azure/msal-common/browser"; +import { EventHandler } from "../../../event/EventHandler.js"; +import { INavigationClient } from "../../../navigation/INavigationClient.js"; +import { RedirectRequest } from "../../../request/RedirectRequest.js"; +import { PopupRequest } from "../../../request/PopupRequest.js"; +import { SsoSilentRequest } from "../../../request/SsoSilentRequest.js"; +import { EndSessionRequest } from "../../../request/EndSessionRequest.js"; +import { ClearCacheRequest } from "../../../request/ClearCacheRequest.js"; +import { AuthenticationResult } from "../../../response/AuthenticationResult.js"; + +export abstract class CustomAuthInteractionClientBase extends StandardInteractionClient { + constructor( + config: BrowserConfiguration, + storageImpl: BrowserCacheManager, + browserCrypto: ICrypto, + logger: Logger, + eventHandler: EventHandler, + navigationClient: INavigationClient, + performanceClient: IPerformanceClient, + protected customAuthApiClient: ICustomAuthApiClient, + protected customAuthAuthority: CustomAuthAuthority + ) { + super( + config, + storageImpl, + browserCrypto, + logger, + eventHandler, + navigationClient, + performanceClient + ); + } + + protected getChallengeTypes( + configuredChallengeTypes: string[] | undefined + ): string { + const challengeType = configuredChallengeTypes ?? []; + if ( + !challengeType.some( + (type) => type.toLowerCase() === ChallengeType.REDIRECT + ) + ) { + challengeType.push(ChallengeType.REDIRECT); + } + return challengeType.join(" "); + } + + protected getScopes(scopes: string[] | undefined): string[] { + if (!!scopes && scopes.length > 0) { + scopes; + } + + return [ + Constants.OPENID_SCOPE, + Constants.PROFILE_SCOPE, + Constants.OFFLINE_ACCESS_SCOPE, + ]; + } + + // It is not necessary to implement this method from base class. + acquireToken( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + request: RedirectRequest | PopupRequest | SsoSilentRequest + ): Promise { + throw new MethodNotImplementedError("SignInClient.acquireToken"); + } + + // It is not necessary to implement this method from base class. + logout( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + request: EndSessionRequest | ClearCacheRequest | undefined + ): Promise { + throw new MethodNotImplementedError("SignInClient.logout"); + } +} diff --git a/lib/msal-browser/src/custom_auth/core/interaction_client/CustomAuthInterationClientFactory.ts b/lib/msal-browser/src/custom_auth/core/interaction_client/CustomAuthInterationClientFactory.ts new file mode 100644 index 0000000000..ede64542ab --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/interaction_client/CustomAuthInterationClientFactory.ts @@ -0,0 +1,57 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { ICustomAuthApiClient } from "../network_client/custom_auth_api/ICustomAuthApiClient.js"; +import { CustomAuthAuthority } from "../CustomAuthAuthority.js"; +import { CustomAuthInteractionClientBase } from "./CustomAuthInteractionClientBase.js"; +import { BrowserConfiguration } from "../../../config/Configuration.js"; +import { BrowserCacheManager } from "../../../cache/BrowserCacheManager.js"; +import { + ICrypto, + IPerformanceClient, + Logger, +} from "@azure/msal-common/browser"; +import { EventHandler } from "../../../event/EventHandler.js"; +import { INavigationClient } from "../../../navigation/INavigationClient.js"; + +export class CustomAuthInterationClientFactory { + constructor( + private config: BrowserConfiguration, + private storageImpl: BrowserCacheManager, + private browserCrypto: ICrypto, + private logger: Logger, + private eventHandler: EventHandler, + private navigationClient: INavigationClient, + private performanceClient: IPerformanceClient, + private customAuthApiClient: ICustomAuthApiClient, + private customAuthAuthority: CustomAuthAuthority + ) {} + + create( + clientConstructor: new ( + config: BrowserConfiguration, + storageImpl: BrowserCacheManager, + browserCrypto: ICrypto, + logger: Logger, + eventHandler: EventHandler, + navigationClient: INavigationClient, + performanceClient: IPerformanceClient, + customAuthApiClient: ICustomAuthApiClient, + customAuthAuthority: CustomAuthAuthority + ) => TClient + ): TClient { + return new clientConstructor( + this.config, + this.storageImpl, + this.browserCrypto, + this.logger, + this.eventHandler, + this.navigationClient, + this.performanceClient, + this.customAuthApiClient, + this.customAuthAuthority + ); + } +} diff --git a/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/BaseApiClient.ts b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/BaseApiClient.ts new file mode 100644 index 0000000000..687c747c87 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/BaseApiClient.ts @@ -0,0 +1,168 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { + ChallengeType, + DefaultPackageInfo, + HttpHeaderKeys, +} from "../../../CustomAuthConstants.js"; +import { IHttpClient } from "../http_client/IHttpClient.js"; +import * as CustomAuthApiErrorCode from "./types/ApiErrorCodes.js"; +import { buildUrl, parseUrl } from "../../utils/UrlUtils.js"; +import { + CustomAuthApiError, + RedirectError, +} from "../../error/CustomAuthApiError.js"; +import { + AADServerParamKeys, + ServerTelemetryManager, +} from "@azure/msal-common/browser"; +import { ApiErrorResponse } from "./types/ApiErrorResponseTypes.js"; + +export abstract class BaseApiClient { + private readonly baseRequestUrl: URL; + + constructor( + baseUrl: string, + private readonly clientId: string, + private httpClient: IHttpClient + ) { + this.baseRequestUrl = parseUrl( + !baseUrl.endsWith("/") ? `${baseUrl}/` : baseUrl + ); + } + + protected async request( + endpoint: string, + data: Record, + telemetryManager: ServerTelemetryManager, + correlationId: string + ): Promise { + const formData = new URLSearchParams({ + client_id: this.clientId, + ...data, + }); + const headers = this.getCommonHeaders(correlationId, telemetryManager); + const url = buildUrl(this.baseRequestUrl.href, endpoint); + + let response: Response; + + try { + response = await this.httpClient.post(url, formData, headers); + } catch (e) { + throw new CustomAuthApiError( + CustomAuthApiErrorCode.HTTP_REQUEST_FAILED, + `Failed to perform '${endpoint}' request: ${e}`, + correlationId + ); + } + + return this.handleApiResponse(response, correlationId); + } + + protected ensureContinuationTokenIsValid( + continuationToken: string | undefined, + correlationId: string + ): void { + if (!continuationToken) { + throw new CustomAuthApiError( + CustomAuthApiErrorCode.CONTINUATION_TOKEN_MISSING, + "Continuation token is missing in the response body", + correlationId + ); + } + } + + private readResponseCorrelationId( + response: Response, + requestCorrelationId: string + ): string { + return ( + response.headers.get(HttpHeaderKeys.X_MS_REQUEST_ID) || + requestCorrelationId + ); + } + + private getCommonHeaders( + correlationId: string, + telemetryManager: ServerTelemetryManager + ): Record { + return { + [HttpHeaderKeys.CONTENT_TYPE]: "application/x-www-form-urlencoded", + [AADServerParamKeys.X_CLIENT_SKU]: DefaultPackageInfo.SKU, + [AADServerParamKeys.X_CLIENT_VER]: DefaultPackageInfo.VERSION, + [AADServerParamKeys.X_CLIENT_OS]: DefaultPackageInfo.OS, + [AADServerParamKeys.X_CLIENT_CPU]: DefaultPackageInfo.CPU, + [AADServerParamKeys.X_CLIENT_CURR_TELEM]: + telemetryManager.generateCurrentRequestHeaderValue(), + [AADServerParamKeys.X_CLIENT_LAST_TELEM]: + telemetryManager.generateLastRequestHeaderValue(), + [AADServerParamKeys.CLIENT_REQUEST_ID]: correlationId, + }; + } + + private async handleApiResponse( + response: Response | undefined, + requestCorrelationId: string + ): Promise { + if (!response) { + throw new CustomAuthApiError( + "empty_response", + "Response is empty", + requestCorrelationId + ); + } + + const correlationId = this.readResponseCorrelationId( + response, + requestCorrelationId + ); + + const responseData = await response.json(); + + if (response.ok) { + // Ensure the response doesn't have redirect challenge type + if ( + typeof responseData === "object" && + responseData.challenge_type === ChallengeType.REDIRECT + ) { + throw new RedirectError(correlationId); + } + + return { + ...responseData, + correlation_id: correlationId, + }; + } + + const responseError = responseData as ApiErrorResponse; + + if (!responseError) { + throw new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_RESPONSE_BODY, + "Response error body is empty or invalid", + correlationId + ); + } + + const attributes = + !!responseError.required_attributes && + responseError.required_attributes.length > 0 + ? responseError.required_attributes + : responseError.invalid_attributes ?? []; + + throw new CustomAuthApiError( + responseError.error, + responseError.error_description, + responseError.correlation_id, + responseError.error_codes, + responseError.suberror, + attributes, + responseError.continuation_token, + responseError.trace_id, + responseError.timestamp + ); + } +} diff --git a/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/CustomAuthApiClient.ts b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/CustomAuthApiClient.ts new file mode 100644 index 0000000000..6ff2ffeac5 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/CustomAuthApiClient.ts @@ -0,0 +1,38 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { ResetPasswordApiClient } from "./ResetPasswordApiClient.js"; +import { SignupApiClient } from "./SignupApiClient.js"; +import { SignInApiClient } from "./SignInApiClient.js"; +import { ICustomAuthApiClient } from "./ICustomAuthApiClient.js"; +import { IHttpClient } from "../http_client/IHttpClient.js"; + +export class CustomAuthApiClient implements ICustomAuthApiClient { + signInApi: SignInApiClient; + signUpApi: SignupApiClient; + resetPasswordApi: ResetPasswordApiClient; + + constructor( + customAuthApiBaseUrl: string, + clientId: string, + httpClient: IHttpClient + ) { + this.signInApi = new SignInApiClient( + customAuthApiBaseUrl, + clientId, + httpClient + ); + this.signUpApi = new SignupApiClient( + customAuthApiBaseUrl, + clientId, + httpClient + ); + this.resetPasswordApi = new ResetPasswordApiClient( + customAuthApiBaseUrl, + clientId, + httpClient + ); + } +} diff --git a/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/CustomAuthApiEndpoint.ts b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/CustomAuthApiEndpoint.ts new file mode 100644 index 0000000000..4b98d345d4 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/CustomAuthApiEndpoint.ts @@ -0,0 +1,18 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +export const SIGNIN_INITIATE = "/oauth2/v2.0/initiate"; +export const SIGNIN_CHALLENGE = "/oauth2/v2.0/challenge"; +export const SIGNIN_TOKEN = "/oauth2/v2.0/token"; + +export const SIGNUP_START = "/signup/v1.0/start"; +export const SIGNUP_CHALLENGE = "/signup/v1.0/challenge"; +export const SIGNUP_CONTINUE = "/signup/v1.0/continue"; + +export const RESET_PWD_START = "/resetpassword/v1.0/start"; +export const RESET_PWD_CHALLENGE = "/resetpassword/v1.0/challenge"; +export const RESET_PWD_CONTINUE = "/resetpassword/v1.0/continue"; +export const RESET_PWD_SUBMIT = "/resetpassword/v1.0/submit"; +export const RESET_PWD_POLL = "/resetpassword/v1.0/poll_completion"; diff --git a/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/ICustomAuthApiClient.ts b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/ICustomAuthApiClient.ts new file mode 100644 index 0000000000..6d4cad1186 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/ICustomAuthApiClient.ts @@ -0,0 +1,13 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { ResetPasswordApiClient } from "./ResetPasswordApiClient.js"; +import { SignupApiClient } from "./SignupApiClient.js"; +import { SignInApiClient } from "./SignInApiClient.js"; +export interface ICustomAuthApiClient { + signInApi: SignInApiClient; + signUpApi: SignupApiClient; + resetPasswordApi: ResetPasswordApiClient; +} diff --git a/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/ResetPasswordApiClient.ts b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/ResetPasswordApiClient.ts new file mode 100644 index 0000000000..11b711ba16 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/ResetPasswordApiClient.ts @@ -0,0 +1,172 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { + GrantType, + ResetPasswordPollStatus, +} from "../../../CustomAuthConstants.js"; +import { CustomAuthApiError } from "../../error/CustomAuthApiError.js"; +import { BaseApiClient } from "./BaseApiClient.js"; +import * as CustomAuthApiEndpoint from "./CustomAuthApiEndpoint.js"; +import * as CustomAuthApiErrorCode from "./types/ApiErrorCodes.js"; +import { + ResetPasswordChallengeRequest, + ResetPasswordContinueRequest, + ResetPasswordPollCompletionRequest, + ResetPasswordStartRequest, + ResetPasswordSubmitRequest, +} from "./types/ApiRequestTypes.js"; +import { + ResetPasswordChallengeResponse, + ResetPasswordContinueResponse, + ResetPasswordPollCompletionResponse, + ResetPasswordStartResponse, + ResetPasswordSubmitResponse, +} from "./types/ApiResponseTypes.js"; + +export class ResetPasswordApiClient extends BaseApiClient { + /** + * Start the password reset flow + */ + async start( + params: ResetPasswordStartRequest + ): Promise { + const result = await this.request( + CustomAuthApiEndpoint.RESET_PWD_START, + { + challenge_type: params.challenge_type, + username: params.username, + }, + params.telemetryManager, + params.correlationId + ); + + this.ensureContinuationTokenIsValid( + result.continuation_token, + params.correlationId + ); + + return result; + } + + /** + * Request a challenge (OTP) to be sent to the user's email + * @param ChallengeResetPasswordRequest Parameters for the challenge request + */ + async requestChallenge( + params: ResetPasswordChallengeRequest + ): Promise { + const result = await this.request( + CustomAuthApiEndpoint.RESET_PWD_CHALLENGE, + { + challenge_type: params.challenge_type, + continuation_token: params.continuation_token, + }, + params.telemetryManager, + params.correlationId + ); + + this.ensureContinuationTokenIsValid( + result.continuation_token, + params.correlationId + ); + + return result; + } + + /** + * Submit the code for verification + * @param ContinueResetPasswordRequest Token from previous response + */ + async continueWithCode( + params: ResetPasswordContinueRequest + ): Promise { + const result = await this.request( + CustomAuthApiEndpoint.RESET_PWD_CONTINUE, + { + continuation_token: params.continuation_token, + grant_type: GrantType.OOB, + oob: params.oob, + }, + params.telemetryManager, + params.correlationId + ); + + this.ensureContinuationTokenIsValid( + result.continuation_token, + params.correlationId + ); + + return result; + } + + /** + * Submit the new password + * @param SubmitResetPasswordResponse Token from previous response + */ + async submitNewPassword( + params: ResetPasswordSubmitRequest + ): Promise { + const result = await this.request( + CustomAuthApiEndpoint.RESET_PWD_SUBMIT, + { + continuation_token: params.continuation_token, + new_password: params.new_password, + }, + params.telemetryManager, + params.correlationId + ); + + this.ensureContinuationTokenIsValid( + result.continuation_token, + params.correlationId + ); + + if (result.poll_interval === 0) { + result.poll_interval = 2; + } + + return result; + } + + /** + * Poll for password reset completion status + * @param continuationToken Token from previous response + */ + async pollCompletion( + params: ResetPasswordPollCompletionRequest + ): Promise { + const result = await this.request( + CustomAuthApiEndpoint.RESET_PWD_POLL, + { + continuation_token: params.continuation_token, + }, + params.telemetryManager, + params.correlationId + ); + + this.ensurePollStatusIsValid(result.status, params.correlationId); + + return result; + } + + protected ensurePollStatusIsValid( + status: string, + correlationId: string + ): void { + if ( + status !== ResetPasswordPollStatus.FAILED && + status !== ResetPasswordPollStatus.IN_PROGRESS && + status !== ResetPasswordPollStatus.SUCCEEDED && + status !== ResetPasswordPollStatus.NOT_STARTED + ) { + throw new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_POLL_STATUS, + `The poll status '${status}' for password reset is invalid`, + correlationId + ); + } + } +} diff --git a/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/SignInApiClient.ts b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/SignInApiClient.ts new file mode 100644 index 0000000000..13599d9595 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/SignInApiClient.ts @@ -0,0 +1,186 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { ServerTelemetryManager } from "@azure/msal-common/browser"; +import { GrantType } from "../../../CustomAuthConstants.js"; +import { CustomAuthApiError } from "../../error/CustomAuthApiError.js"; +import { BaseApiClient } from "./BaseApiClient.js"; +import * as CustomAuthApiEndpoint from "./CustomAuthApiEndpoint.js"; +import * as CustomAuthApiErrorCode from "./types/ApiErrorCodes.js"; +import { + SignInChallengeRequest, + SignInContinuationTokenRequest, + SignInInitiateRequest, + SignInOobTokenRequest, + SignInPasswordTokenRequest, +} from "./types/ApiRequestTypes.js"; +import { + SignInChallengeResponse, + SignInInitiateResponse, + SignInTokenResponse, +} from "./types/ApiResponseTypes.js"; + +export class SignInApiClient extends BaseApiClient { + /** + * Initiates the sign-in flow + * @param username User's email + * @param authMethod 'email-otp' | 'email-password' + */ + async initiate( + params: SignInInitiateRequest + ): Promise { + const result = await this.request( + CustomAuthApiEndpoint.SIGNIN_INITIATE, + { + username: params.username, + challenge_type: params.challenge_type, + }, + params.telemetryManager, + params.correlationId + ); + + this.ensureContinuationTokenIsValid( + result.continuation_token, + params.correlationId + ); + + return result; + } + + /** + * Requests authentication challenge (OTP or password validation) + * @param continuationToken Token from initiate response + * @param authMethod 'email-otp' | 'email-password' + */ + async requestChallenge( + params: SignInChallengeRequest + ): Promise { + const result = await this.request( + CustomAuthApiEndpoint.SIGNIN_CHALLENGE, + { + continuation_token: params.continuation_token, + challenge_type: params.challenge_type, + }, + params.telemetryManager, + params.correlationId + ); + + this.ensureContinuationTokenIsValid( + result.continuation_token, + params.correlationId + ); + + return result; + } + + /** + * Requests security tokens using either password or OTP + * @param continuationToken Token from challenge response + * @param credentials Password or OTP + * @param authMethod 'email-otp' | 'email-password' + */ + async requestTokensWithPassword( + params: SignInPasswordTokenRequest + ): Promise { + return this.requestTokens( + { + continuation_token: params.continuation_token, + grant_type: GrantType.PASSWORD, + scope: params.scope, + password: params.password, + }, + params.telemetryManager, + params.correlationId + ); + } + + async requestTokensWithOob( + params: SignInOobTokenRequest + ): Promise { + return this.requestTokens( + { + continuation_token: params.continuation_token, + scope: params.scope, + oob: params.oob, + grant_type: GrantType.OOB, + }, + params.telemetryManager, + params.correlationId + ); + } + + async requestTokenWithContinuationToken( + params: SignInContinuationTokenRequest + ): Promise { + return this.requestTokens( + { + continuation_token: params.continuation_token, + username: params.username, + scope: params.scope, + grant_type: GrantType.CONTINUATION_TOKEN, + client_info: true, + }, + params.telemetryManager, + params.correlationId + ); + } + + private async requestTokens( + requestData: Record, + telemetryManager: ServerTelemetryManager, + correlationId: string + ): Promise { + // The client_info parameter is required for MSAL to return the uid and utid in the response. + requestData.client_info = true; + + const result = await this.request( + CustomAuthApiEndpoint.SIGNIN_TOKEN, + requestData, + telemetryManager, + correlationId + ); + + SignInApiClient.ensureTokenResponseIsValid(result); + + return result; + } + + private static ensureTokenResponseIsValid( + tokenResponse: SignInTokenResponse + ): void { + let errorCode = ""; + let errorDescription = ""; + + if (!tokenResponse.access_token) { + errorCode = CustomAuthApiErrorCode.ACCESS_TOKEN_MISSING; + errorDescription = "Access token is missing in the response body"; + } else if (!tokenResponse.id_token) { + errorCode = CustomAuthApiErrorCode.ID_TOKEN_MISSING; + errorDescription = "Id token is missing in the response body"; + } else if (!tokenResponse.refresh_token) { + errorCode = CustomAuthApiErrorCode.REFRESH_TOKEN_MISSING; + errorDescription = "Refresh token is missing in the response body"; + } else if (!tokenResponse.expires_in || tokenResponse.expires_in <= 0) { + errorCode = CustomAuthApiErrorCode.INVALID_EXPIRES_IN; + errorDescription = "Expires in is invalid in the response body"; + } else if (tokenResponse.token_type !== "Bearer") { + errorCode = CustomAuthApiErrorCode.INVALID_TOKEN_TYPE; + errorDescription = `Token type '${tokenResponse.token_type}' is invalid in the response body`; + } else if (!tokenResponse.client_info) { + errorCode = CustomAuthApiErrorCode.CLIENT_INFO_MISSING; + errorDescription = "Client info is missing in the response body"; + } + + if (!errorCode && !errorDescription) { + return; + } + + throw new CustomAuthApiError( + errorCode, + errorDescription, + tokenResponse.correlation_id + ); + } +} diff --git a/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/SignupApiClient.ts b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/SignupApiClient.ts new file mode 100644 index 0000000000..8b5b226237 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/SignupApiClient.ts @@ -0,0 +1,141 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { GrantType } from "../../../CustomAuthConstants.js"; +import { BaseApiClient } from "./BaseApiClient.js"; +import * as CustomAuthApiEndpoint from "./CustomAuthApiEndpoint.js"; +import { + SignUpChallengeRequest, + SignUpContinueWithAttributesRequest, + SignUpContinueWithOobRequest, + SignUpContinueWithPasswordRequest, + SignUpStartRequest, +} from "./types/ApiRequestTypes.js"; +import { + SignUpChallengeResponse, + SignUpContinueResponse, + SignUpStartResponse, +} from "./types/ApiResponseTypes.js"; + +export class SignupApiClient extends BaseApiClient { + /** + * Start the sign-up flow + */ + async start(params: SignUpStartRequest): Promise { + const result = await this.request( + CustomAuthApiEndpoint.SIGNUP_START, + { + username: params.username, + ...(params.password && { password: params.password }), + ...(params.attributes && { + attributes: JSON.stringify(params.attributes), + }), + challenge_type: params.challenge_type, + }, + params.telemetryManager, + params.correlationId + ); + + this.ensureContinuationTokenIsValid( + result.continuation_token, + params.correlationId + ); + + return result; + } + + /** + * Request challenge (e.g., OTP) + */ + async requestChallenge( + params: SignUpChallengeRequest + ): Promise { + const result = await this.request( + CustomAuthApiEndpoint.SIGNUP_CHALLENGE, + { + continuation_token: params.continuation_token, + challenge_type: params.challenge_type, + }, + params.telemetryManager, + params.correlationId + ); + + this.ensureContinuationTokenIsValid( + result.continuation_token, + params.correlationId + ); + + return result; + } + + /** + * Continue sign-up flow with code. + */ + async continueWithCode( + params: SignUpContinueWithOobRequest + ): Promise { + const result = await this.request( + CustomAuthApiEndpoint.SIGNUP_CONTINUE, + { + continuation_token: params.continuation_token, + grant_type: GrantType.OOB, + oob: params.oob, + }, + params.telemetryManager, + params.correlationId + ); + + this.ensureContinuationTokenIsValid( + result.continuation_token, + params.correlationId + ); + + return result; + } + + async continueWithPassword( + params: SignUpContinueWithPasswordRequest + ): Promise { + const result = await this.request( + CustomAuthApiEndpoint.SIGNUP_CONTINUE, + { + continuation_token: params.continuation_token, + grant_type: GrantType.PASSWORD, + password: params.password, + }, + params.telemetryManager, + params.correlationId + ); + + this.ensureContinuationTokenIsValid( + result.continuation_token, + params.correlationId + ); + + return result; + } + + async continueWithAttributes( + params: SignUpContinueWithAttributesRequest + ): Promise { + const result = await this.request( + CustomAuthApiEndpoint.SIGNUP_CONTINUE, + { + continuation_token: params.continuation_token, + grant_type: GrantType.ATTRIBUTES, + attributes: JSON.stringify(params.attributes), + }, + params.telemetryManager, + params.correlationId + ); + + this.ensureContinuationTokenIsValid( + result.continuation_token, + params.correlationId + ); + + return result; + } +} diff --git a/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/types/ApiErrorCodes.ts b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/types/ApiErrorCodes.ts new file mode 100644 index 0000000000..0a3250a26c --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/types/ApiErrorCodes.ts @@ -0,0 +1,26 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +export const CONTINUATION_TOKEN_MISSING = "continuation_token_missing"; +export const INVALID_RESPONSE_BODY = "invalid_response_body"; +export const EMPTY_RESPONSE = "empty_response"; +export const UNSUPPORTED_CHALLENGE_TYPE = "unsupported_challenge_type"; +export const ACCESS_TOKEN_MISSING = "access_token_missing"; +export const ID_TOKEN_MISSING = "id_token_missing"; +export const REFRESH_TOKEN_MISSING = "refresh_token_missing"; +export const INVALID_EXPIRES_IN = "invalid_expires_in"; +export const INVALID_TOKEN_TYPE = "invalid_token_type"; +export const HTTP_REQUEST_FAILED = "http_request_failed"; +export const INVALID_REQUEST = "invalid_request"; +export const USER_NOT_FOUND = "user_not_found"; +export const INVALID_GRANT = "invalid_grant"; +export const CREDENTIAL_REQUIRED = "credential_required"; +export const ATTRIBUTES_REQUIRED = "attributes_required"; +export const USER_ALREADY_EXISTS = "user_already_exists"; +export const INVALID_POLL_STATUS = "invalid_poll_status"; +export const PASSWORD_CHANGE_FAILED = "password_change_failed"; +export const PASSWORD_RESET_TIMEOUT = "password_reset_timeout"; +export const CLIENT_INFO_MISSING = "client_info_missing"; +export const EXPIRED_TOKEN = "expired_token"; diff --git a/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/types/ApiErrorResponseTypes.ts b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/types/ApiErrorResponseTypes.ts new file mode 100644 index 0000000000..da24fdf37b --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/types/ApiErrorResponseTypes.ts @@ -0,0 +1,36 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +export interface InvalidAttribute { + name: string; + reason: string; +} + +/** + * Detailed error interface for Microsoft Entra signup errors + */ +export interface ApiErrorResponse { + error: string; + error_description: string; + correlation_id: string; + error_codes?: number[]; + suberror?: string; + continuation_token?: string; + timestamp?: string; + trace_id?: string; + required_attributes?: Array; + invalid_attributes?: Array; +} + +export interface UserAttribute { + name: string; + type?: string; + required?: boolean; + options?: UserAttributeOption; +} + +export interface UserAttributeOption { + regex?: string; +} diff --git a/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/types/ApiRequestTypes.ts b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/types/ApiRequestTypes.ts new file mode 100644 index 0000000000..30d85b4756 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/types/ApiRequestTypes.ts @@ -0,0 +1,91 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { ApiRequestBase } from "./ApiTypesBase.js"; + +/* Sign-in API request types */ +export interface SignInInitiateRequest extends ApiRequestBase { + challenge_type: string; + username: string; +} + +export interface SignInChallengeRequest extends ApiRequestBase { + challenge_type: string; + continuation_token: string; +} + +interface SignInTokenRequestBase extends ApiRequestBase { + continuation_token: string; + scope: string; +} + +export interface SignInPasswordTokenRequest extends SignInTokenRequestBase { + password: string; +} + +export interface SignInOobTokenRequest extends SignInTokenRequestBase { + oob: string; +} + +export interface SignInContinuationTokenRequest extends SignInTokenRequestBase { + username: string; +} + +/* Sign-up API request types */ +export interface SignUpStartRequest extends ApiRequestBase { + username: string; + challenge_type: string; + password?: string; + attributes?: Record; +} + +export interface SignUpChallengeRequest extends ApiRequestBase { + continuation_token: string; + challenge_type: string; +} + +interface SignUpContinueRequestBase extends ApiRequestBase { + continuation_token: string; +} + +export interface SignUpContinueWithOobRequest + extends SignUpContinueRequestBase { + oob: string; +} + +export interface SignUpContinueWithPasswordRequest + extends SignUpContinueRequestBase { + password: string; +} + +export interface SignUpContinueWithAttributesRequest + extends SignUpContinueRequestBase { + attributes: Record; +} + +/* Reset password API request types */ +export interface ResetPasswordStartRequest extends ApiRequestBase { + challenge_type: string; + username: string; +} + +export interface ResetPasswordChallengeRequest extends ApiRequestBase { + challenge_type: string; + continuation_token: string; +} + +export interface ResetPasswordContinueRequest extends ApiRequestBase { + continuation_token: string; + oob: string; +} + +export interface ResetPasswordSubmitRequest extends ApiRequestBase { + continuation_token: string; + new_password: string; +} + +export interface ResetPasswordPollCompletionRequest extends ApiRequestBase { + continuation_token: string; +} diff --git a/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/types/ApiResponseTypes.ts b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/types/ApiResponseTypes.ts new file mode 100644 index 0000000000..2910f01063 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/types/ApiResponseTypes.ts @@ -0,0 +1,66 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { ApiResponseBase } from "./ApiTypesBase.js"; + +interface ContinuousResponse extends ApiResponseBase { + continuation_token?: string; +} + +interface InitiateResponse extends ContinuousResponse { + challenge_type?: string; +} + +interface ChallengeResponse extends ApiResponseBase { + continuation_token?: string; + challenge_type?: string; + binding_method?: string; + challenge_channel?: string; + challenge_target_label?: string; + code_length?: number; +} + +/* Sign-in API response types */ +export type SignInInitiateResponse = InitiateResponse; + +export type SignInChallengeResponse = ChallengeResponse; + +export interface SignInTokenResponse extends ApiResponseBase { + token_type: "Bearer"; + scope: string; + expires_in: number; + access_token: string; + refresh_token: string; + id_token: string; + client_info: string; + ext_expires_in?: number; +} + +/* Sign-up API response types */ +export type SignUpStartResponse = InitiateResponse; + +export interface SignUpChallengeResponse extends ChallengeResponse { + interval?: number; +} + +export type SignUpContinueResponse = InitiateResponse; + +/* Reset password API response types */ +export type ResetPasswordStartResponse = InitiateResponse; + +export type ResetPasswordChallengeResponse = ChallengeResponse; + +export interface ResetPasswordContinueResponse extends ContinuousResponse { + expires_in: number; +} + +export interface ResetPasswordSubmitResponse extends ContinuousResponse { + poll_interval: number; +} + +export interface ResetPasswordPollCompletionResponse + extends ContinuousResponse { + status: string; +} diff --git a/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/types/ApiSuberrors.ts b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/types/ApiSuberrors.ts new file mode 100644 index 0000000000..a233a9aa4d --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/types/ApiSuberrors.ts @@ -0,0 +1,14 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +export const PASSWORD_TOO_WEAK = "password_too_weak"; +export const PASSWORD_TOO_SHORT = "password_too_short"; +export const PASSWORD_TOO_LONG = "password_too_long"; +export const PASSWORD_RECENTLY_USED = "password_recently_used"; +export const PASSWORD_BANNED = "password_banned"; +export const PASSWORD_IS_INVALID = "password_is_invalid"; +export const INVALID_OOB_VALUE = "invalid_oob_value"; +export const ATTRIBUTE_VALIATION_FAILED = "attribute_validation_failed"; +export const NATIVEAUTHAPI_DISABLED = "nativeauthapi_disabled"; diff --git a/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/types/ApiTypesBase.ts b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/types/ApiTypesBase.ts new file mode 100644 index 0000000000..35d8eb8b56 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/types/ApiTypesBase.ts @@ -0,0 +1,15 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { ServerTelemetryManager } from "@azure/msal-common/browser"; + +export type ApiRequestBase = { + correlationId: string; + telemetryManager: ServerTelemetryManager; +}; + +export type ApiResponseBase = { + correlation_id: string; +}; diff --git a/lib/msal-browser/src/custom_auth/core/network_client/http_client/FetchHttpClient.ts b/lib/msal-browser/src/custom_auth/core/network_client/http_client/FetchHttpClient.ts new file mode 100644 index 0000000000..f750802e90 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/network_client/http_client/FetchHttpClient.ts @@ -0,0 +1,86 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { HttpMethod, IHttpClient, RequestBody } from "./IHttpClient.js"; +import { HttpError } from "../../error/HttpError.js"; +import { AADServerParamKeys, Logger } from "@azure/msal-common/browser"; +import { + FailedSendRequest, + NoNetworkConnectivity, +} from "../../error/HttpErrorCodes.js"; + +/** + * Implementation of IHttpClient using fetch. + */ +export class FetchHttpClient implements IHttpClient { + constructor(private logger: Logger) {} + + async sendAsync( + url: string | URL, + options: RequestInit + ): Promise { + const headers = options.headers as Record; + const correlationId = + headers?.[AADServerParamKeys.CLIENT_REQUEST_ID] || undefined; + + try { + this.logger.verbosePii(`Sending request to ${url}`, correlationId); + + const startTime = performance.now(); + const response = await fetch(url, options); + const endTime = performance.now(); + + this.logger.verbosePii( + `Request to '${url}' completed in ${ + endTime - startTime + }ms with status code ${response.status}`, + correlationId + ); + + return response; + } catch (e) { + this.logger.errorPii( + `Failed to send request to ${url}: ${e}`, + correlationId + ); + + if (!window.navigator.onLine) { + throw new HttpError( + NoNetworkConnectivity, + `No network connectivity: ${e}`, + correlationId + ); + } + + throw new HttpError( + FailedSendRequest, + `Failed to send request: ${e}`, + correlationId + ); + } + } + + async post( + url: string | URL, + body: RequestBody, + headers: Record = {} + ): Promise { + return this.sendAsync(url, { + method: HttpMethod.POST, + headers, + body, + }); + } + + async get( + url: string | URL, + headers: Record = {} + ): Promise { + return this.sendAsync(url, { + method: HttpMethod.GET, + headers, + }); + } +} diff --git a/lib/msal-browser/src/custom_auth/core/network_client/http_client/IHttpClient.ts b/lib/msal-browser/src/custom_auth/core/network_client/http_client/IHttpClient.ts new file mode 100644 index 0000000000..43f4a77771 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/network_client/http_client/IHttpClient.ts @@ -0,0 +1,54 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +export type RequestBody = + | string + | ArrayBuffer + | DataView + | Blob + | File + | URLSearchParams + | FormData + | ReadableStream; +/** + * Interface for HTTP client. + */ +export interface IHttpClient { + /** + * Sends a request. + * @param url The URL to send the request to. + * @param options Additional fetch options. + */ + sendAsync(url: string | URL, options: RequestInit): Promise; + + /** + * Sends a POST request. + * @param url The URL to send the request to. + * @param body The body of the request. + * @param headers Optional headers for the request. + */ + post( + url: string | URL, + body: RequestBody, + headers?: Record + ): Promise; + + /** + * Sends a GET request. + * @param url The URL to send the request to. + * @param headers Optional headers for the request. + */ + get(url: string | URL, headers?: Record): Promise; +} + +/** + * Represents an HTTP method type. + */ +export const HttpMethod = { + GET: "GET", + POST: "POST", + PUT: "PUT", + DELETE: "DELETE", +} as const; diff --git a/lib/msal-browser/src/custom_auth/core/telemetry/PublicApiId.ts b/lib/msal-browser/src/custom_auth/core/telemetry/PublicApiId.ts new file mode 100644 index 0000000000..487cf49bbc --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/telemetry/PublicApiId.ts @@ -0,0 +1,37 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +/* + * The public API ids should be claim in the MSAL telemtry tracker. + * All the following ids are hardcoded; so we need to find a way to claim them in the future and update them here. + */ + +// Sign in +export const SIGN_IN_WITH_CODE_START = 100001; +export const SIGN_IN_WITH_PASSWORD_START = 100002; +export const SIGN_IN_SUBMIT_CODE = 100003; +export const SIGN_IN_SUBMIT_PASSWORD = 100004; +export const SIGN_IN_RESEND_CODE = 100005; +export const SIGN_IN_AFTER_SIGN_UP = 100006; +export const SIGN_IN_AFTER_PASSWORD_RESET = 100007; + +// Sign up +export const SIGN_UP_WITH_PASSWORD_START = 100021; +export const SIGN_UP_START = 100022; +export const SIGN_UP_SUBMIT_CODE = 100023; +export const SIGN_UP_SUBMIT_PASSWORD = 100024; +export const SIGN_UP_SUBMIT_ATTRIBUTES = 100025; +export const SIGN_UP_RESEND_CODE = 100026; + +// Password reset +export const PASSWORD_RESET_START = 100041; +export const PASSWORD_RESET_SUBMIT_CODE = 100042; +export const PASSWORD_RESET_SUBMIT_PASSWORD = 100043; +export const PASSWORD_RESET_RESEND_CODE = 100044; + +// Get account +export const ACCOUNT_GET_ACCOUNT = 100061; +export const ACCOUNT_SIGN_OUT = 100062; +export const ACCOUNT_GET_ACCESS_TOKEN = 100063; diff --git a/lib/msal-browser/src/custom_auth/core/utils/ArgumentValidator.ts b/lib/msal-browser/src/custom_auth/core/utils/ArgumentValidator.ts new file mode 100644 index 0000000000..2d5e385ea4 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/utils/ArgumentValidator.ts @@ -0,0 +1,26 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { InvalidArgumentError } from "../error/InvalidArgumentError.js"; + +export function ensureArgumentIsNotNullOrUndefined( + argName: string, + argValue: T | undefined | null, + correlationId?: string +): asserts argValue is T { + if (argValue === null || argValue === undefined) { + throw new InvalidArgumentError(argName, correlationId); + } +} + +export function ensureArgumentIsNotEmptyString( + argName: string, + argValue: string | undefined, + correlationId?: string +): void { + if (!argValue || argValue.trim() === "") { + throw new InvalidArgumentError(argName, correlationId); + } +} diff --git a/lib/msal-browser/src/custom_auth/core/utils/UrlUtils.ts b/lib/msal-browser/src/custom_auth/core/utils/UrlUtils.ts new file mode 100644 index 0000000000..dde2235ca2 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/utils/UrlUtils.ts @@ -0,0 +1,25 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { ParsedUrlError } from "../error/ParsedUrlError.js"; +import { InvalidUrl } from "../error/ParsedUrlErrorCodes.js"; + +export function parseUrl(url: string): URL { + try { + return new URL(url); + } catch (e) { + throw new ParsedUrlError( + InvalidUrl, + `The URL "${url}" is invalid: ${e}` + ); + } +} + +export function buildUrl(baseUrl: string, path: string): URL { + const newBaseUrl = !baseUrl.endsWith("/") ? `${baseUrl}/` : baseUrl; + const newPath = path.startsWith("/") ? path.slice(1) : path; + const url = new URL(newPath, newBaseUrl); + return url; +} diff --git a/lib/msal-browser/src/custom_auth/get_account/auth_flow/CustomAuthAccountData.ts b/lib/msal-browser/src/custom_auth/get_account/auth_flow/CustomAuthAccountData.ts new file mode 100644 index 0000000000..2238a8b1c5 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/get_account/auth_flow/CustomAuthAccountData.ts @@ -0,0 +1,185 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthBrowserConfiguration } from "../../configuration/CustomAuthConfiguration.js"; +import { SignOutResult } from "./result/SignOutResult.js"; +import { GetAccessTokenResult } from "./result/GetAccessTokenResult.js"; +import { CustomAuthSilentCacheClient } from "../interaction_client/CustomAuthSilentCacheClient.js"; +import { NoCachedAccountFoundError } from "../../core/error/NoCachedAccountFoundError.js"; +import { DefaultScopes } from "../../CustomAuthConstants.js"; +import { AccessTokenRetrievalInputs } from "../../CustomAuthActionInputs.js"; +import { + AccountInfo, + CommonSilentFlowRequest, + Constants, + Logger, + TokenClaims, +} from "@azure/msal-common/browser"; +import { SilentRequest } from "../../../request/SilentRequest.js"; +import { + ensureArgumentIsNotEmptyString, + ensureArgumentIsNotNullOrUndefined, +} from "../../core/utils/ArgumentValidator.js"; + +/* + * Account information. + */ +export class CustomAuthAccountData { + constructor( + private readonly account: AccountInfo, + private readonly config: CustomAuthBrowserConfiguration, + private readonly cacheClient: CustomAuthSilentCacheClient, + private readonly logger: Logger, + private readonly correlationId: string + ) { + ensureArgumentIsNotEmptyString("correlationId", correlationId); + ensureArgumentIsNotNullOrUndefined("account", account, correlationId); + } + + /** + * This method triggers a sign-out operation, + * which removes the current account info and its tokens from browser cache. + * If sign-out successfully, redirect the page to postLogoutRedirectUri if provided in the configuration. + * @returns {Promise} The result of the SignOut operation. + */ + async signOut(): Promise { + try { + const currentAccount = this.cacheClient.getCurrentAccount( + this.correlationId + ); + + if (!currentAccount) { + throw new NoCachedAccountFoundError(this.correlationId); + } + + this.logger.verbose("Signing out user", this.correlationId); + + await this.cacheClient.logout({ + correlationId: this.correlationId, + account: currentAccount, + }); + + this.logger.verbose("User signed out", this.correlationId); + + return new SignOutResult(); + } catch (error) { + this.logger.errorPii( + `An error occurred during sign out: ${error}`, + this.correlationId + ); + + return SignOutResult.createWithError(error); + } + } + + getAccount(): AccountInfo { + return this.account; + } + + /** + * Gets the raw id-token of current account. + * Idtoken is only issued if openid scope is present in the scopes parameter when requesting for tokens, + * otherwise will return undefined from the response. + * @returns {string|undefined} The account id-token. + */ + getIdToken(): string | undefined { + return this.account.idToken; + } + + /** + * Gets the id token claims extracted from raw IdToken of current account. + * @returns {AuthTokenClaims|undefined} The token claims. + */ + getClaims(): AuthTokenClaims | undefined { + return this.account.idTokenClaims; + } + + /** + * Gets the access token of current account from browser cache if it is not expired, + * otherwise renew the token using cached refresh token if valid. + * If no refresh token is found or it is expired, then throws error. + * @param {AccessTokenRetrievalInputs} accessTokenRetrievalInputs - The inputs for retrieving the access token. + * @returns {Promise} The result of the operation. + */ + async getAccessToken( + accessTokenRetrievalInputs: AccessTokenRetrievalInputs + ): Promise { + try { + ensureArgumentIsNotNullOrUndefined( + "accessTokenRetrievalInputs", + accessTokenRetrievalInputs, + this.correlationId + ); + + this.logger.verbose("Getting current account.", this.correlationId); + + const currentAccount = this.cacheClient.getCurrentAccount( + this.account.username + ); + + if (!currentAccount) { + throw new NoCachedAccountFoundError(this.correlationId); + } + + this.logger.verbose("Getting access token.", this.correlationId); + + const newScopes = + accessTokenRetrievalInputs.scopes && + accessTokenRetrievalInputs.scopes.length > 0 + ? accessTokenRetrievalInputs.scopes + : [...DefaultScopes]; + const commonSilentFlowRequest = this.createCommonSilentFlowRequest( + currentAccount, + accessTokenRetrievalInputs.forceRefresh, + newScopes + ); + const result = await this.cacheClient.acquireToken( + commonSilentFlowRequest + ); + + this.logger.verbose( + "Successfully got access token from cache.", + this.correlationId + ); + + return new GetAccessTokenResult(result); + } catch (error) { + this.logger.error( + "Failed to get access token from cache.", + this.correlationId + ); + + return GetAccessTokenResult.createWithError(error); + } + } + + private createCommonSilentFlowRequest( + accountInfo: AccountInfo, + forceRefresh: boolean = false, + requestScopes: Array + ): CommonSilentFlowRequest { + const silentRequest: SilentRequest = { + authority: this.config.auth.authority, + correlationId: this.correlationId, + scopes: requestScopes || [], + account: accountInfo, + forceRefresh: forceRefresh || false, + storeInCache: { + idToken: true, + accessToken: true, + refreshToken: true, + }, + }; + + return { + ...silentRequest, + authenticationScheme: Constants.AuthenticationScheme.BEARER, + } as CommonSilentFlowRequest; + } +} + +export type AuthTokenClaims = TokenClaims & { + [key: string]: string | number | string[] | object | undefined | unknown; +}; diff --git a/lib/msal-browser/src/custom_auth/get_account/auth_flow/error_type/GetAccountError.ts b/lib/msal-browser/src/custom_auth/get_account/auth_flow/error_type/GetAccountError.ts new file mode 100644 index 0000000000..33d0a865d2 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/get_account/auth_flow/error_type/GetAccountError.ts @@ -0,0 +1,45 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowErrorBase } from "../../../core/auth_flow/AuthFlowErrorBase.js"; + +/** + * The error class for get account errors. + */ +export class GetAccountError extends AuthFlowErrorBase { + /** + * Checks if the error is due to no cached account found. + * @returns true if the error is due to no cached account found, false otherwise. + */ + isCurrentAccountNotFound(): boolean { + return this.isNoCachedAccountFoundError(); + } +} + +/** + * The error class for sign-out errors. + */ +export class SignOutError extends AuthFlowErrorBase { + /** + * Checks if the error is due to the user is not signed in. + * @returns true if the error is due to the user is not signed in, false otherwise. + */ + isUserNotSignedIn(): boolean { + return this.isNoCachedAccountFoundError(); + } +} + +/** + * The error class for getting the current account access token errors. + */ +export class GetCurrentAccountAccessTokenError extends AuthFlowErrorBase { + /** + * Checks if the error is due to no cached account found. + * @returns true if the error is due to no cached account found, false otherwise. + */ + isCurrentAccountNotFound(): boolean { + return this.isNoCachedAccountFoundError(); + } +} diff --git a/lib/msal-browser/src/custom_auth/get_account/auth_flow/result/GetAccessTokenResult.ts b/lib/msal-browser/src/custom_auth/get_account/auth_flow/result/GetAccessTokenResult.ts new file mode 100644 index 0000000000..b0699df999 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/get_account/auth_flow/result/GetAccessTokenResult.ts @@ -0,0 +1,72 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthenticationResult } from "../../../../response/AuthenticationResult.js"; +import { AuthFlowResultBase } from "../../../core/auth_flow/AuthFlowResultBase.js"; +import { GetCurrentAccountAccessTokenError } from "../error_type/GetAccountError.js"; +import { + GetAccessTokenCompletedState, + GetAccessTokenFailedState, +} from "../state/GetAccessTokenState.js"; + +/* + * Result of getting an access token. + */ +export class GetAccessTokenResult extends AuthFlowResultBase< + GetAccessTokenResultState, + GetCurrentAccountAccessTokenError, + AuthenticationResult +> { + /** + * Creates a new instance of GetAccessTokenResult. + * @param resultData The result data of the access token. + */ + constructor(resultData?: AuthenticationResult) { + super(new GetAccessTokenCompletedState(), resultData); + } + + /** + * Creates a new instance of GetAccessTokenResult with an error. + * @param error The error that occurred. + * @return {GetAccessTokenResult} The result with the error. + */ + static createWithError(error: unknown): GetAccessTokenResult { + const result = new GetAccessTokenResult(); + result.error = new GetCurrentAccountAccessTokenError( + GetAccessTokenResult.createErrorData(error) + ); + result.state = new GetAccessTokenFailedState(); + + return result; + } + + /** + * Checks if the result is completed. + */ + isCompleted(): this is GetAccessTokenResult & { + state: GetAccessTokenCompletedState; + } { + return this.state instanceof GetAccessTokenCompletedState; + } + + /** + * Checks if the result is failed. + */ + isFailed(): this is GetAccessTokenResult & { + state: GetAccessTokenFailedState; + } { + return this.state instanceof GetAccessTokenFailedState; + } +} + +/** + * The possible states for the GetAccessTokenResult. + * This includes: + * - GetAccessTokenCompletedState: The access token was successfully retrieved. + * - GetAccessTokenFailedState: The access token retrieval failed. + */ +export type GetAccessTokenResultState = + | GetAccessTokenCompletedState + | GetAccessTokenFailedState; diff --git a/lib/msal-browser/src/custom_auth/get_account/auth_flow/result/GetAccountResult.ts b/lib/msal-browser/src/custom_auth/get_account/auth_flow/result/GetAccountResult.ts new file mode 100644 index 0000000000..e1950f33b4 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/get_account/auth_flow/result/GetAccountResult.ts @@ -0,0 +1,69 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowResultBase } from "../../../core/auth_flow/AuthFlowResultBase.js"; +import { CustomAuthAccountData } from "../CustomAuthAccountData.js"; +import { GetAccountError } from "../error_type/GetAccountError.js"; +import { + GetAccountCompletedState, + GetAccountFailedState, +} from "../state/GetAccountState.js"; + +/* + * Result of getting an account. + */ +export class GetAccountResult extends AuthFlowResultBase< + GetAccountResultState, + GetAccountError, + CustomAuthAccountData +> { + /** + * Creates a new instance of GetAccountResult. + * @param resultData The result data. + */ + constructor(resultData?: CustomAuthAccountData) { + super(new GetAccountCompletedState(), resultData); + } + + /** + * Creates a new instance of GetAccountResult with an error. + * @param error The error data. + */ + static createWithError(error: unknown): GetAccountResult { + const result = new GetAccountResult(); + result.error = new GetAccountError( + GetAccountResult.createErrorData(error) + ); + result.state = new GetAccountFailedState(); + + return result; + } + + /** + * Checks if the result is in a completed state. + */ + isCompleted(): this is GetAccountResult & { + state: GetAccountCompletedState; + } { + return this.state instanceof GetAccountCompletedState; + } + + /** + * Checks if the result is in a failed state. + */ + isFailed(): this is GetAccountResult & { state: GetAccountFailedState } { + return this.state instanceof GetAccountFailedState; + } +} + +/** + * The possible states for the GetAccountResult. + * This includes: + * - GetAccountCompletedState: The account was successfully retrieved. + * - GetAccountFailedState: The account retrieval failed. + */ +export type GetAccountResultState = + | GetAccountCompletedState + | GetAccountFailedState; diff --git a/lib/msal-browser/src/custom_auth/get_account/auth_flow/result/SignOutResult.ts b/lib/msal-browser/src/custom_auth/get_account/auth_flow/result/SignOutResult.ts new file mode 100644 index 0000000000..001e87da6a --- /dev/null +++ b/lib/msal-browser/src/custom_auth/get_account/auth_flow/result/SignOutResult.ts @@ -0,0 +1,62 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowResultBase } from "../../../core/auth_flow/AuthFlowResultBase.js"; +import { SignOutError } from "../error_type/GetAccountError.js"; +import { + SignOutCompletedState, + SignOutFailedState, +} from "../state/SignOutState.js"; + +/* + * Result of a sign-out operation. + */ +export class SignOutResult extends AuthFlowResultBase< + SignOutResultState, + SignOutError, + void +> { + /** + * Creates a new instance of SignOutResult. + * @param state The state of the result. + */ + constructor() { + super(new SignOutCompletedState()); + } + + /** + * Creates a new instance of SignOutResult with an error. + * @param error The error that occurred during the sign-out operation. + */ + static createWithError(error: unknown): SignOutResult { + const result = new SignOutResult(); + result.error = new SignOutError(SignOutResult.createErrorData(error)); + result.state = new SignOutFailedState(); + + return result; + } + + /** + * Checks if the sign-out operation is completed. + */ + isCompleted(): this is SignOutResult & { state: SignOutCompletedState } { + return this.state instanceof SignOutCompletedState; + } + + /** + * Checks if the sign-out operation failed. + */ + isFailed(): this is SignOutResult & { state: SignOutFailedState } { + return this.state instanceof SignOutFailedState; + } +} + +/** + * The possible states for the SignOutResult. + * This includes: + * - SignOutCompletedState: The sign-out operation was successful. + * - SignOutFailedState: The sign-out operation failed. + */ +export type SignOutResultState = SignOutCompletedState | SignOutFailedState; diff --git a/lib/msal-browser/src/custom_auth/get_account/auth_flow/state/GetAccessTokenState.ts b/lib/msal-browser/src/custom_auth/get_account/auth_flow/state/GetAccessTokenState.ts new file mode 100644 index 0000000000..77a2bc91ce --- /dev/null +++ b/lib/msal-browser/src/custom_auth/get_account/auth_flow/state/GetAccessTokenState.ts @@ -0,0 +1,16 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowStateBase } from "../../../core/auth_flow/AuthFlowState.js"; + +/** + * The completed state of the get access token flow. + */ +export class GetAccessTokenCompletedState extends AuthFlowStateBase {} + +/** + * The failed state of the get access token flow. + */ +export class GetAccessTokenFailedState extends AuthFlowStateBase {} diff --git a/lib/msal-browser/src/custom_auth/get_account/auth_flow/state/GetAccountState.ts b/lib/msal-browser/src/custom_auth/get_account/auth_flow/state/GetAccountState.ts new file mode 100644 index 0000000000..9489a06f51 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/get_account/auth_flow/state/GetAccountState.ts @@ -0,0 +1,16 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowStateBase } from "../../../core/auth_flow/AuthFlowState.js"; + +/** + * The completed state of the get account flow. + */ +export class GetAccountCompletedState extends AuthFlowStateBase {} + +/** + * The failed state of the get account flow. + */ +export class GetAccountFailedState extends AuthFlowStateBase {} diff --git a/lib/msal-browser/src/custom_auth/get_account/auth_flow/state/SignOutState.ts b/lib/msal-browser/src/custom_auth/get_account/auth_flow/state/SignOutState.ts new file mode 100644 index 0000000000..ef679df9d5 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/get_account/auth_flow/state/SignOutState.ts @@ -0,0 +1,16 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowStateBase } from "../../../core/auth_flow/AuthFlowState.js"; + +/** + * The completed state of the sign-out flow. + */ +export class SignOutCompletedState extends AuthFlowStateBase {} + +/** + * The failed state of the sign-out flow. + */ +export class SignOutFailedState extends AuthFlowStateBase {} diff --git a/lib/msal-browser/src/custom_auth/get_account/interaction_client/CustomAuthSilentCacheClient.ts b/lib/msal-browser/src/custom_auth/get_account/interaction_client/CustomAuthSilentCacheClient.ts new file mode 100644 index 0000000000..48ac2599b2 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/get_account/interaction_client/CustomAuthSilentCacheClient.ts @@ -0,0 +1,205 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthAuthority } from "../../core/CustomAuthAuthority.js"; +import { DefaultPackageInfo } from "../../CustomAuthConstants.js"; +import * as PublicApiId from "../../core/telemetry/PublicApiId.js"; +import { CustomAuthInteractionClientBase } from "../../core/interaction_client/CustomAuthInteractionClientBase.js"; +import { + AccountInfo, + ClientAuthError, + ClientAuthErrorCodes, + ClientConfiguration, + CommonSilentFlowRequest, + RefreshTokenClient, + ServerTelemetryManager, + SilentFlowClient, + UrlString, +} from "@azure/msal-common/browser"; +import { AuthenticationResult } from "../../../response/AuthenticationResult.js"; +import { ClearCacheRequest } from "../../../request/ClearCacheRequest.js"; +import { ApiId } from "../../../utils/BrowserConstants.js"; +import { getCurrentUri } from "../../../utils/BrowserUtils.js"; + +export class CustomAuthSilentCacheClient extends CustomAuthInteractionClientBase { + /** + * Acquires a token from the cache if it is not expired. Otherwise, makes a request to renew the token. + * If forceRresh is set to false, then looks up the access token in cache first. + * If access token is expired or not found, then uses refresh token to get a new access token. + * If no refresh token is found or it is expired, then throws error. + * If forceRefresh is set to true, then skips token cache lookup and fetches a new token using refresh token + * If no refresh token is found or it is expired, then throws error. + * @param silentRequest The silent request object. + * @returns {Promise} The promise that resolves to an AuthenticationResult. + */ + override async acquireToken( + silentRequest: CommonSilentFlowRequest + ): Promise { + const telemetryManager = this.initializeServerTelemetryManager( + PublicApiId.ACCOUNT_GET_ACCESS_TOKEN + ); + const clientConfig = this.getCustomAuthClientConfiguration( + telemetryManager, + this.customAuthAuthority + ); + const silentFlowClient = new SilentFlowClient( + clientConfig, + this.performanceClient + ); + + try { + this.logger.verbose( + "Starting silent flow to acquire token from cache", + this.correlationId + ); + + const result = await silentFlowClient.acquireCachedToken( + silentRequest + ); + + this.logger.verbose( + "Silent flow to acquire token from cache is completed and token is found", + this.correlationId + ); + + return result[0] as AuthenticationResult; + } catch (error) { + if ( + error instanceof ClientAuthError && + error.errorCode === ClientAuthErrorCodes.tokenRefreshRequired + ) { + this.logger.verbose( + "Token refresh is required to acquire token silently", + this.correlationId + ); + + const refreshTokenClient = new RefreshTokenClient( + clientConfig, + this.performanceClient + ); + + this.logger.verbose( + "Starting refresh flow to refresh token", + this.correlationId + ); + + const refreshTokenResult = + await refreshTokenClient.acquireTokenByRefreshToken( + silentRequest + ); + + this.logger.verbose( + "Refresh flow to refresh token is completed", + this.correlationId + ); + + return refreshTokenResult as AuthenticationResult; + } + + throw error; + } + } + + override async logout(logoutRequest?: ClearCacheRequest): Promise { + const validLogoutRequest = this.initializeLogoutRequest(logoutRequest); + + // Clear the cache + this.logger.verbose( + "Start to clear the cache", + logoutRequest?.correlationId + ); + await this.clearCacheOnLogout(validLogoutRequest?.account); + this.logger.verbose("Cache cleared", logoutRequest?.correlationId); + + const postLogoutRedirectUri = this.config.auth.postLogoutRedirectUri; + + if (postLogoutRedirectUri) { + const absoluteRedirectUri = UrlString.getAbsoluteUrl( + postLogoutRedirectUri, + getCurrentUri() + ); + + this.logger.verbose( + "Post logout redirect uri is set, redirecting to uri", + logoutRequest?.correlationId + ); + + // Redirect to post logout redirect uri + await this.navigationClient.navigateExternal(absoluteRedirectUri, { + apiId: ApiId.logout, + timeout: this.config.system.redirectNavigationTimeout, + noHistory: false, + }); + } + } + + getCurrentAccount(correlationId: string): AccountInfo | null { + let account: AccountInfo | null = null; + + this.logger.verbose( + "Getting the first account from cache.", + correlationId + ); + + const allAccounts = this.browserStorage.getAllAccounts(); + + if (allAccounts.length > 0) { + if (allAccounts.length !== 1) { + this.logger.warning( + "Multiple accounts found in cache. This is not supported in the Native Auth scenario.", + correlationId + ); + } + + account = allAccounts[0]; + } + + if (account) { + this.logger.verbose("Account data found.", correlationId); + } else { + this.logger.verbose("No account data found.", correlationId); + } + + return account; + } + + private getCustomAuthClientConfiguration( + serverTelemetryManager: ServerTelemetryManager, + customAuthAuthority: CustomAuthAuthority + ): ClientConfiguration { + const logger = this.config.system.loggerOptions; + + return { + authOptions: { + clientId: this.config.auth.clientId, + authority: customAuthAuthority, + clientCapabilities: this.config.auth.clientCapabilities, + redirectUri: this.config.auth.redirectUri, + }, + systemOptions: { + tokenRenewalOffsetSeconds: + this.config.system.tokenRenewalOffsetSeconds, + preventCorsPreflight: true, + }, + loggerOptions: { + loggerCallback: logger.loggerCallback, + piiLoggingEnabled: logger.piiLoggingEnabled, + logLevel: logger.logLevel, + correlationId: this.correlationId, + }, + cryptoInterface: this.browserCrypto, + networkInterface: this.networkClient, + storageInterface: this.browserStorage, + serverTelemetryManager: serverTelemetryManager, + libraryInfo: { + sku: DefaultPackageInfo.SKU, + version: DefaultPackageInfo.VERSION, + cpu: DefaultPackageInfo.CPU, + os: DefaultPackageInfo.OS, + }, + telemetry: this.config.telemetry, + }; + } +} diff --git a/lib/msal-browser/src/custom_auth/index.ts b/lib/msal-browser/src/custom_auth/index.ts new file mode 100644 index 0000000000..6cf025cf81 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/index.ts @@ -0,0 +1,185 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +/** + * @packageDocumentation + * @module @azure/msal-browser/custom-auth + */ + +/** + * This file is the entrypoint when importing with the custom-auth subpath e.g. "import { someExport } from @azure/msal-browser/custom-auth" + * Additional exports should be added to the applicable exports-*.ts files + */ + +// Application and Controller +export { CustomAuthPublicClientApplication } from "./CustomAuthPublicClientApplication.js"; +export { ICustomAuthPublicClientApplication } from "./ICustomAuthPublicClientApplication.js"; + +// Configuration +export { CustomAuthConfiguration } from "./configuration/CustomAuthConfiguration.js"; + +// Account Data +export { CustomAuthAccountData } from "./get_account/auth_flow/CustomAuthAccountData.js"; + +// Operation Inputs +export { + SignInInputs, + SignUpInputs, + ResetPasswordInputs, + AccountRetrievalInputs, + AccessTokenRetrievalInputs, + SignInWithContinuationTokenInputs, +} from "./CustomAuthActionInputs.js"; + +// Operation Base State +export { AuthFlowStateBase } from "./core/auth_flow/AuthFlowState.js"; +export { AuthFlowActionRequiredStateBase } from "./core/auth_flow/AuthFlowState.js"; + +// Sign-in State +export { SignInState } from "./sign_in/auth_flow/state/SignInState.js"; +export { SignInCodeRequiredState } from "./sign_in/auth_flow/state/SignInCodeRequiredState.js"; +export { SignInContinuationState } from "./sign_in/auth_flow/state/SignInContinuationState.js"; +export { SignInPasswordRequiredState } from "./sign_in/auth_flow/state/SignInPasswordRequiredState.js"; +export { SignInCompletedState } from "./sign_in/auth_flow/state/SignInCompletedState.js"; +export { SignInFailedState } from "./sign_in/auth_flow/state/SignInFailedState.js"; + +// Sign-in Results +export { + SignInResult, + SignInResultState, +} from "./sign_in/auth_flow/result/SignInResult.js"; +export { SignInSubmitCodeResult } from "./sign_in/auth_flow/result/SignInSubmitCodeResult.js"; +export { + SignInResendCodeResult, + SignInResendCodeResultState, +} from "./sign_in/auth_flow/result/SignInResendCodeResult.js"; +export { SignInSubmitPasswordResult } from "./sign_in/auth_flow/result/SignInSubmitPasswordResult.js"; +export { SignInSubmitCredentialResultState } from "./sign_in/auth_flow/result/SignInSubmitCredentialResult.js"; + +// Sign-in Errors +export { + SignInError, + SignInSubmitPasswordError, + SignInSubmitCodeError, + SignInResendCodeError, +} from "./sign_in/auth_flow/error_type/SignInError.js"; + +// Sign-up User Account Attributes +export { UserAccountAttributes } from "./UserAccountAttributes.js"; + +// Sign-up State +export { SignUpState } from "./sign_up/auth_flow/state/SignUpState.js"; +export { SignUpAttributesRequiredState } from "./sign_up/auth_flow/state/SignUpAttributesRequiredState.js"; +export { SignUpCodeRequiredState } from "./sign_up/auth_flow/state/SignUpCodeRequiredState.js"; +export { SignUpPasswordRequiredState } from "./sign_up/auth_flow/state/SignUpPasswordRequiredState.js"; +export { SignUpCompletedState } from "./sign_up/auth_flow/state/SignUpCompletedState.js"; +export { SignUpFailedState } from "./sign_up/auth_flow/state/SignUpFailedState.js"; + +// Sign-up Results +export { + SignUpResult, + SignUpResultState, +} from "./sign_up/auth_flow/result/SignUpResult.js"; +export { + SignUpSubmitAttributesResult, + SignUpSubmitAttributesResultState, +} from "./sign_up/auth_flow/result/SignUpSubmitAttributesResult.js"; +export { + SignUpSubmitCodeResult, + SignUpSubmitCodeResultState, +} from "./sign_up/auth_flow/result/SignUpSubmitCodeResult.js"; +export { + SignUpResendCodeResult, + SignUpResendCodeResultState, +} from "./sign_up/auth_flow/result/SignUpResendCodeResult.js"; +export { + SignUpSubmitPasswordResult, + SignUpSubmitPasswordResultState, +} from "./sign_up/auth_flow/result/SignUpSubmitPasswordResult.js"; + +// Sign-up Errors +export { + SignUpError, + SignUpSubmitPasswordError, + SignUpSubmitCodeError, + SignUpSubmitAttributesError, + SignUpResendCodeError, +} from "./sign_up/auth_flow/error_type/SignUpError.js"; + +// Reset-password State +export { ResetPasswordState } from "./reset_password/auth_flow/state/ResetPasswordState.js"; +export { ResetPasswordCodeRequiredState } from "./reset_password/auth_flow/state/ResetPasswordCodeRequiredState.js"; +export { ResetPasswordPasswordRequiredState } from "./reset_password/auth_flow/state/ResetPasswordPasswordRequiredState.js"; +export { ResetPasswordCompletedState } from "./reset_password/auth_flow/state/ResetPasswordCompletedState.js"; +export { ResetPasswordFailedState } from "./reset_password/auth_flow/state/ResetPasswordFailedState.js"; + +// Reset-password Results +export { + ResetPasswordStartResult, + ResetPasswordStartResultState, +} from "./reset_password/auth_flow/result/ResetPasswordStartResult.js"; +export { + ResetPasswordSubmitCodeResult, + ResetPasswordSubmitCodeResultState, +} from "./reset_password/auth_flow/result/ResetPasswordSubmitCodeResult.js"; +export { + ResetPasswordResendCodeResult, + ResetPasswordResendCodeResultState, +} from "./reset_password/auth_flow/result/ResetPasswordResendCodeResult.js"; +export { + ResetPasswordSubmitPasswordResult, + ResetPasswordSubmitPasswordResultState, +} from "./reset_password/auth_flow/result/ResetPasswordSubmitPasswordResult.js"; + +// Reset-password Errors +export { + ResetPasswordError, + ResetPasswordSubmitPasswordError, + ResetPasswordSubmitCodeError, + ResetPasswordResendCodeError, +} from "./reset_password/auth_flow/error_type/ResetPasswordError.js"; + +// Get Access Token Results +export { + GetAccessTokenResult, + GetAccessTokenResultState, +} from "./get_account/auth_flow/result/GetAccessTokenResult.js"; + +// Get Account Results +export { + GetAccountResult, + GetAccountResultState, +} from "./get_account/auth_flow/result/GetAccountResult.js"; + +// Sign Out Results +export { + SignOutResult, + SignOutResultState, +} from "./get_account/auth_flow/result/SignOutResult.js"; + +// Token Management Errors +export { + GetAccountError, + SignOutError, + GetCurrentAccountAccessTokenError, +} from "./get_account/auth_flow/error_type/GetAccountError.js"; + +// Errors +export { CustomAuthApiError } from "./core/error/CustomAuthApiError.js"; +export { CustomAuthError } from "./core/error/CustomAuthError.js"; +export { HttpError } from "./core/error/HttpError.js"; +export { InvalidArgumentError } from "./core/error/InvalidArgumentError.js"; +export { InvalidConfigurationError } from "./core/error/InvalidConfigurationError.js"; +export { MethodNotImplementedError } from "./core/error/MethodNotImplementedError.js"; +export { MsalCustomAuthError } from "./core/error/MsalCustomAuthError.js"; +export { NoCachedAccountFoundError } from "./core/error/NoCachedAccountFoundError.js"; +export { ParsedUrlError } from "./core/error/ParsedUrlError.js"; +export { UnexpectedError } from "./core/error/UnexpectedError.js"; +export { UnsupportedEnvironmentError } from "./core/error/UnsupportedEnvironmentError.js"; +export { UserAccountAttributeError } from "./core/error/UserAccountAttributeError.js"; +export { UserAlreadySignedInError } from "./core/error/UserAlreadySignedInError.js"; + +// Components from msal_browser +export { LogLevel } from "@azure/msal-common/browser"; diff --git a/lib/msal-browser/src/custom_auth/operating_context/CustomAuthOperatingContext.ts b/lib/msal-browser/src/custom_auth/operating_context/CustomAuthOperatingContext.ts new file mode 100644 index 0000000000..8fcde6498b --- /dev/null +++ b/lib/msal-browser/src/custom_auth/operating_context/CustomAuthOperatingContext.ts @@ -0,0 +1,43 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { BaseOperatingContext } from "../../operatingcontext/BaseOperatingContext.js"; +import { + CustomAuthBrowserConfiguration, + CustomAuthConfiguration, + CustomAuthOptions, +} from "../configuration/CustomAuthConfiguration.js"; + +export class CustomAuthOperatingContext extends BaseOperatingContext { + private readonly customAuthOptions: CustomAuthOptions; + private static readonly MODULE_NAME: string = ""; + private static readonly ID: string = "CustomAuthOperatingContext"; + + constructor(configuration: CustomAuthConfiguration) { + super(configuration); + + this.customAuthOptions = configuration.customAuth; + } + + getModuleName(): string { + return CustomAuthOperatingContext.MODULE_NAME; + } + + getId(): string { + return CustomAuthOperatingContext.ID; + } + + getCustomAuthConfig(): CustomAuthBrowserConfiguration { + return { + ...this.getConfig(), + customAuth: this.customAuthOptions, + }; + } + + async initialize(): Promise { + this.available = typeof window !== "undefined"; + return this.available; + } +} diff --git a/lib/msal-browser/src/custom_auth/reset_password/auth_flow/error_type/ResetPasswordError.ts b/lib/msal-browser/src/custom_auth/reset_password/auth_flow/error_type/ResetPasswordError.ts new file mode 100644 index 0000000000..9b9e9ed2c1 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/reset_password/auth_flow/error_type/ResetPasswordError.ts @@ -0,0 +1,96 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthActionErrorBase } from "../../../core/auth_flow/AuthFlowErrorBase.js"; +import { CustomAuthApiError } from "../../../core/error/CustomAuthApiError.js"; +import * as CustomAuthApiErrorCode from "../../../core/network_client/custom_auth_api/types/ApiErrorCodes.js"; + +export class ResetPasswordError extends AuthActionErrorBase { + /** + * Checks if the error is due to the user not being found. + * @returns true if the error is due to the user not being found, false otherwise. + */ + isUserNotFound(): boolean { + return this.isUserNotFoundError(); + } + + /** + * Checks if the error is due to the username being invalid. + * @returns true if the error is due to the username being invalid, false otherwise. + */ + isInvalidUsername(): boolean { + return this.isUserInvalidError(); + } + + /** + * Checks if the error is due to the provided challenge type is not supported. + * @returns {boolean} True if the error is due to the provided challenge type is not supported, false otherwise. + */ + isUnsupportedChallengeType(): boolean { + return this.isUnsupportedChallengeTypeError(); + } + + /** + * Check if client app supports the challenge type configured in Entra. + * @returns {boolean} True if client app doesn't support the challenge type configured in Entra, "loginPopup" function is required to continue the operation. + */ + isRedirectRequired(): boolean { + return this.isRedirectError(); + } +} + +export class ResetPasswordSubmitPasswordError extends AuthActionErrorBase { + /** + * Checks if the new password is invalid or incorrect. + * @returns {boolean} True if the new password is invalid, false otherwise. + */ + isInvalidPassword(): boolean { + return ( + this.isInvalidNewPasswordError() || this.isPasswordIncorrectError() + ); + } + + /** + * Checks if the password reset failed due to reset timeout or password change failed. + * @returns {boolean} True if the password reset failed, false otherwise. + */ + isPasswordResetFailed(): boolean { + return ( + this.errorData instanceof CustomAuthApiError && + (this.errorData.error === + CustomAuthApiErrorCode.PASSWORD_RESET_TIMEOUT || + this.errorData.error === + CustomAuthApiErrorCode.PASSWORD_CHANGE_FAILED) + ); + } +} + +export class ResetPasswordSubmitCodeError extends AuthActionErrorBase { + /** + * Checks if the provided code is invalid. + * @returns {boolean} True if the provided code is invalid, false otherwise. + */ + isInvalidCode(): boolean { + return this.isInvalidCodeError(); + } + + /** + * Check if client app supports the challenge type configured in Entra. + * @returns {boolean} True if client app doesn't support the challenge type configured in Entra, "loginPopup" function is required to continue the operation. + */ + isRedirectRequired(): boolean { + return this.isRedirectError(); + } +} + +export class ResetPasswordResendCodeError extends AuthActionErrorBase { + /** + * Check if client app supports the challenge type configured in Entra. + * @returns {boolean} True if client app doesn't support the challenge type configured in Entra, "loginPopup" function is required to continue the operation. + */ + isRedirectRequired(): boolean { + return this.isRedirectError(); + } +} diff --git a/lib/msal-browser/src/custom_auth/reset_password/auth_flow/result/ResetPasswordResendCodeResult.ts b/lib/msal-browser/src/custom_auth/reset_password/auth_flow/result/ResetPasswordResendCodeResult.ts new file mode 100644 index 0000000000..b880b479eb --- /dev/null +++ b/lib/msal-browser/src/custom_auth/reset_password/auth_flow/result/ResetPasswordResendCodeResult.ts @@ -0,0 +1,76 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowResultBase } from "../../../core/auth_flow/AuthFlowResultBase.js"; +import { ResetPasswordResendCodeError } from "../error_type/ResetPasswordError.js"; +import type { ResetPasswordCodeRequiredState } from "../state/ResetPasswordCodeRequiredState.js"; +import { ResetPasswordFailedState } from "../state/ResetPasswordFailedState.js"; + +/* + * Result of resending code in a reset password operation. + */ +export class ResetPasswordResendCodeResult extends AuthFlowResultBase< + ResetPasswordResendCodeResultState, + ResetPasswordResendCodeError, + void +> { + /** + * Creates a new instance of ResetPasswordResendCodeResult. + * @param state The state of the result. + */ + constructor(state: ResetPasswordResendCodeResultState) { + super(state); + } + + /** + * Creates a new instance of ResetPasswordResendCodeResult with an error. + * @param error The error that occurred. + * @returns {ResetPasswordResendCodeResult} A new instance of ResetPasswordResendCodeResult with the error set. + */ + static createWithError(error: unknown): ResetPasswordResendCodeResult { + const result = new ResetPasswordResendCodeResult( + new ResetPasswordFailedState() + ); + result.error = new ResetPasswordResendCodeError( + ResetPasswordResendCodeResult.createErrorData(error) + ); + + return result; + } + + /** + * Checks if the result is in a failed state. + */ + isFailed(): this is ResetPasswordResendCodeResult & { + state: ResetPasswordFailedState; + } { + return this.state instanceof ResetPasswordFailedState; + } + + /** + * Checks if the result is in a code required state. + */ + isCodeRequired(): this is ResetPasswordResendCodeResult & { + state: ResetPasswordCodeRequiredState; + } { + /* + * The instanceof operator couldn't be used here to check the state type since the circular dependency issue. + * So we are using the constructor name to check the state type. + */ + return ( + this.state.constructor?.name === "ResetPasswordCodeRequiredState" + ); + } +} + +/** + * The possible states for the ResetPasswordResendCodeResult. + * This includes: + * - ResetPasswordCodeRequiredState: The reset password process requires a code. + * - ResetPasswordFailedState: The reset password process has failed. + */ +export type ResetPasswordResendCodeResultState = + | ResetPasswordCodeRequiredState + | ResetPasswordFailedState; diff --git a/lib/msal-browser/src/custom_auth/reset_password/auth_flow/result/ResetPasswordStartResult.ts b/lib/msal-browser/src/custom_auth/reset_password/auth_flow/result/ResetPasswordStartResult.ts new file mode 100644 index 0000000000..dc5a11d568 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/reset_password/auth_flow/result/ResetPasswordStartResult.ts @@ -0,0 +1,70 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowResultBase } from "../../../core/auth_flow/AuthFlowResultBase.js"; +import { ResetPasswordError } from "../error_type/ResetPasswordError.js"; +import { ResetPasswordCodeRequiredState } from "../state/ResetPasswordCodeRequiredState.js"; +import { ResetPasswordFailedState } from "../state/ResetPasswordFailedState.js"; + +/* + * Result of a reset password operation. + */ +export class ResetPasswordStartResult extends AuthFlowResultBase< + ResetPasswordStartResultState, + ResetPasswordError, + void +> { + /** + * Creates a new instance of ResetPasswordStartResult. + * @param state The state of the result. + */ + constructor(state: ResetPasswordStartResultState) { + super(state); + } + + /** + * Creates a new instance of ResetPasswordStartResult with an error. + * @param error The error that occurred. + * @returns {ResetPasswordStartResult} A new instance of ResetPasswordStartResult with the error set. + */ + static createWithError(error: unknown): ResetPasswordStartResult { + const result = new ResetPasswordStartResult( + new ResetPasswordFailedState() + ); + result.error = new ResetPasswordError( + ResetPasswordStartResult.createErrorData(error) + ); + + return result; + } + + /** + * Checks if the result is in a failed state. + */ + isFailed(): this is ResetPasswordStartResult & { + state: ResetPasswordFailedState; + } { + return this.state instanceof ResetPasswordFailedState; + } + + /** + * Checks if the result is in a code required state. + */ + isCodeRequired(): this is ResetPasswordStartResult & { + state: ResetPasswordCodeRequiredState; + } { + return this.state instanceof ResetPasswordCodeRequiredState; + } +} + +/** + * The possible states for the ResetPasswordStartResult. + * This includes: + * - ResetPasswordCodeRequiredState: The reset password process requires a code. + * - ResetPasswordFailedState: The reset password process has failed. + */ +export type ResetPasswordStartResultState = + | ResetPasswordCodeRequiredState + | ResetPasswordFailedState; diff --git a/lib/msal-browser/src/custom_auth/reset_password/auth_flow/result/ResetPasswordSubmitCodeResult.ts b/lib/msal-browser/src/custom_auth/reset_password/auth_flow/result/ResetPasswordSubmitCodeResult.ts new file mode 100644 index 0000000000..6e31209203 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/reset_password/auth_flow/result/ResetPasswordSubmitCodeResult.ts @@ -0,0 +1,70 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowResultBase } from "../../../core/auth_flow/AuthFlowResultBase.js"; +import { ResetPasswordSubmitCodeError } from "../error_type/ResetPasswordError.js"; +import { ResetPasswordFailedState } from "../state/ResetPasswordFailedState.js"; +import { ResetPasswordPasswordRequiredState } from "../state/ResetPasswordPasswordRequiredState.js"; + +/* + * Result of a reset password operation that requires a code. + */ +export class ResetPasswordSubmitCodeResult extends AuthFlowResultBase< + ResetPasswordSubmitCodeResultState, + ResetPasswordSubmitCodeError, + void +> { + /** + * Creates a new instance of ResetPasswordSubmitCodeResult. + * @param state The state of the result. + */ + constructor(state: ResetPasswordSubmitCodeResultState) { + super(state); + } + + /** + * Creates a new instance of ResetPasswordSubmitCodeResult with an error. + * @param error The error that occurred. + * @returns {ResetPasswordSubmitCodeResult} A new instance of ResetPasswordSubmitCodeResult with the error set. + */ + static createWithError(error: unknown): ResetPasswordSubmitCodeResult { + const result = new ResetPasswordSubmitCodeResult( + new ResetPasswordFailedState() + ); + result.error = new ResetPasswordSubmitCodeError( + ResetPasswordSubmitCodeResult.createErrorData(error) + ); + + return result; + } + + /** + * Checks if the result is in a failed state. + */ + isFailed(): this is ResetPasswordSubmitCodeResult & { + state: ResetPasswordFailedState; + } { + return this.state instanceof ResetPasswordFailedState; + } + + /** + * Checks if the result is in a password required state. + */ + isPasswordRequired(): this is ResetPasswordSubmitCodeResult & { + state: ResetPasswordPasswordRequiredState; + } { + return this.state instanceof ResetPasswordPasswordRequiredState; + } +} + +/** + * The possible states for the ResetPasswordSubmitCodeResult. + * This includes: + * - ResetPasswordPasswordRequiredState: The reset password process requires a password. + * - ResetPasswordFailedState: The reset password process has failed. + */ +export type ResetPasswordSubmitCodeResultState = + | ResetPasswordPasswordRequiredState + | ResetPasswordFailedState; diff --git a/lib/msal-browser/src/custom_auth/reset_password/auth_flow/result/ResetPasswordSubmitPasswordResult.ts b/lib/msal-browser/src/custom_auth/reset_password/auth_flow/result/ResetPasswordSubmitPasswordResult.ts new file mode 100644 index 0000000000..66c8649002 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/reset_password/auth_flow/result/ResetPasswordSubmitPasswordResult.ts @@ -0,0 +1,65 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowResultBase } from "../../../core/auth_flow/AuthFlowResultBase.js"; +import { ResetPasswordSubmitPasswordError } from "../error_type/ResetPasswordError.js"; +import { ResetPasswordCompletedState } from "../state/ResetPasswordCompletedState.js"; +import { ResetPasswordFailedState } from "../state/ResetPasswordFailedState.js"; + +/* + * Result of a reset password operation that requires a password. + */ +export class ResetPasswordSubmitPasswordResult extends AuthFlowResultBase< + ResetPasswordSubmitPasswordResultState, + ResetPasswordSubmitPasswordError, + void +> { + /** + * Creates a new instance of ResetPasswordSubmitPasswordResult. + * @param state The state of the result. + */ + constructor(state: ResetPasswordSubmitPasswordResultState) { + super(state); + } + + static createWithError(error: unknown): ResetPasswordSubmitPasswordResult { + const result = new ResetPasswordSubmitPasswordResult( + new ResetPasswordFailedState() + ); + result.error = new ResetPasswordSubmitPasswordError( + ResetPasswordSubmitPasswordResult.createErrorData(error) + ); + + return result; + } + + /** + * Checks if the result is in a failed state. + */ + isFailed(): this is ResetPasswordSubmitPasswordResult & { + state: ResetPasswordFailedState; + } { + return this.state instanceof ResetPasswordFailedState; + } + + /** + * Checks if the result is in a completed state. + */ + isCompleted(): this is ResetPasswordSubmitPasswordResult & { + state: ResetPasswordCompletedState; + } { + return this.state instanceof ResetPasswordCompletedState; + } +} + +/** + * The possible states for the ResetPasswordSubmitPasswordResult. + * This includes: + * - ResetPasswordCompletedState: The reset password process has completed successfully. + * - ResetPasswordFailedState: The reset password process has failed. + */ +export type ResetPasswordSubmitPasswordResultState = + | ResetPasswordCompletedState + | ResetPasswordFailedState; diff --git a/lib/msal-browser/src/custom_auth/reset_password/auth_flow/state/ResetPasswordCodeRequiredState.ts b/lib/msal-browser/src/custom_auth/reset_password/auth_flow/state/ResetPasswordCodeRequiredState.ts new file mode 100644 index 0000000000..fb2d9ff27c --- /dev/null +++ b/lib/msal-browser/src/custom_auth/reset_password/auth_flow/state/ResetPasswordCodeRequiredState.ts @@ -0,0 +1,130 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { ResetPasswordResendCodeResult } from "../result/ResetPasswordResendCodeResult.js"; +import { ResetPasswordSubmitCodeResult } from "../result/ResetPasswordSubmitCodeResult.js"; +import { ResetPasswordCodeRequiredStateParameters } from "./ResetPasswordStateParameters.js"; +import { ResetPasswordState } from "./ResetPasswordState.js"; +import { ResetPasswordPasswordRequiredState } from "./ResetPasswordPasswordRequiredState.js"; + +/* + * Reset password code required state. + */ +export class ResetPasswordCodeRequiredState extends ResetPasswordState { + /** + * Submits a one-time passcode that the customer user received in their email in order to continue password reset flow. + * @param {string} code - The code to submit. + * @returns {Promise} The result of the operation. + */ + async submitCode(code: string): Promise { + try { + this.ensureCodeIsValid(code, this.stateParameters.codeLength); + + this.stateParameters.logger.verbose( + "Submitting code for password reset.", + this.stateParameters.correlationId + ); + + const result = + await this.stateParameters.resetPasswordClient.submitCode({ + clientId: this.stateParameters.config.auth.clientId, + correlationId: this.stateParameters.correlationId, + challengeType: + this.stateParameters.config.customAuth.challengeTypes ?? + [], + continuationToken: + this.stateParameters.continuationToken ?? "", + code: code, + username: this.stateParameters.username, + }); + + this.stateParameters.logger.verbose( + "Code is submitted for password reset.", + this.stateParameters.correlationId + ); + + return new ResetPasswordSubmitCodeResult( + new ResetPasswordPasswordRequiredState({ + correlationId: result.correlationId, + continuationToken: result.continuationToken, + logger: this.stateParameters.logger, + config: this.stateParameters.config, + resetPasswordClient: + this.stateParameters.resetPasswordClient, + signInClient: this.stateParameters.signInClient, + cacheClient: this.stateParameters.cacheClient, + username: this.stateParameters.username, + }) + ); + } catch (error) { + this.stateParameters.logger.errorPii( + `Failed to submit code for password reset. Error: ${error}.`, + this.stateParameters.correlationId + ); + + return ResetPasswordSubmitCodeResult.createWithError(error); + } + } + + /** + * Resends another one-time passcode if the previous one hasn't been verified + * @returns {Promise} The result of the operation. + */ + async resendCode(): Promise { + try { + this.stateParameters.logger.verbose( + "Resending code for password reset.", + this.stateParameters.correlationId + ); + + const result = + await this.stateParameters.resetPasswordClient.resendCode({ + clientId: this.stateParameters.config.auth.clientId, + challengeType: + this.stateParameters.config.customAuth.challengeTypes ?? + [], + username: this.stateParameters.username, + correlationId: this.stateParameters.correlationId, + continuationToken: + this.stateParameters.continuationToken ?? "", + }); + + this.stateParameters.logger.verbose( + "Code is resent for password reset.", + this.stateParameters.correlationId + ); + + return new ResetPasswordResendCodeResult( + new ResetPasswordCodeRequiredState({ + correlationId: result.correlationId, + continuationToken: result.continuationToken, + logger: this.stateParameters.logger, + config: this.stateParameters.config, + resetPasswordClient: + this.stateParameters.resetPasswordClient, + signInClient: this.stateParameters.signInClient, + cacheClient: this.stateParameters.cacheClient, + username: this.stateParameters.username, + codeLength: result.codeLength, + }) + ); + } catch (error) { + this.stateParameters.logger.errorPii( + `Failed to resend code for password reset. Error: ${error}.`, + this.stateParameters.correlationId + ); + + return ResetPasswordResendCodeResult.createWithError(error); + } + } + + /** + * Gets the sent code length. + * @returns {number} The length of the code. + */ + getCodeLength(): number { + return this.stateParameters.codeLength; + } +} diff --git a/lib/msal-browser/src/custom_auth/reset_password/auth_flow/state/ResetPasswordCompletedState.ts b/lib/msal-browser/src/custom_auth/reset_password/auth_flow/state/ResetPasswordCompletedState.ts new file mode 100644 index 0000000000..a1533df316 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/reset_password/auth_flow/state/ResetPasswordCompletedState.ts @@ -0,0 +1,11 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { SignInContinuationState } from "../../../sign_in/auth_flow/state/SignInContinuationState.js"; + +/** + * Represents the state that indicates the successful completion of a password reset operation. + */ +export class ResetPasswordCompletedState extends SignInContinuationState {} diff --git a/lib/msal-browser/src/custom_auth/reset_password/auth_flow/state/ResetPasswordFailedState.ts b/lib/msal-browser/src/custom_auth/reset_password/auth_flow/state/ResetPasswordFailedState.ts new file mode 100644 index 0000000000..a920970fbe --- /dev/null +++ b/lib/msal-browser/src/custom_auth/reset_password/auth_flow/state/ResetPasswordFailedState.ts @@ -0,0 +1,11 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowStateBase } from "../../../core/auth_flow/AuthFlowState.js"; + +/** + * State of a reset password operation that has failed. + */ +export class ResetPasswordFailedState extends AuthFlowStateBase {} diff --git a/lib/msal-browser/src/custom_auth/reset_password/auth_flow/state/ResetPasswordPasswordRequiredState.ts b/lib/msal-browser/src/custom_auth/reset_password/auth_flow/state/ResetPasswordPasswordRequiredState.ts new file mode 100644 index 0000000000..ffb230ec93 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/reset_password/auth_flow/state/ResetPasswordPasswordRequiredState.ts @@ -0,0 +1,73 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { ResetPasswordSubmitPasswordResult } from "../result/ResetPasswordSubmitPasswordResult.js"; +import { ResetPasswordState } from "./ResetPasswordState.js"; +import { ResetPasswordPasswordRequiredStateParameters } from "./ResetPasswordStateParameters.js"; +import { ResetPasswordCompletedState } from "./ResetPasswordCompletedState.js"; +import { SignInScenario } from "../../../sign_in/auth_flow/SignInScenario.js"; + +/* + * Reset password password required state. + */ +export class ResetPasswordPasswordRequiredState extends ResetPasswordState { + /** + * Submits a new password for reset password flow. + * @param {string} password - The password to submit. + * @returns {Promise} The result of the operation. + */ + async submitNewPassword( + password: string + ): Promise { + try { + this.ensurePasswordIsNotEmpty(password); + + this.stateParameters.logger.verbose( + "Submitting new password for password reset.", + this.stateParameters.correlationId + ); + + const result = + await this.stateParameters.resetPasswordClient.submitNewPassword( + { + clientId: this.stateParameters.config.auth.clientId, + correlationId: this.stateParameters.correlationId, + challengeType: + this.stateParameters.config.customAuth + .challengeTypes ?? [], + continuationToken: + this.stateParameters.continuationToken ?? "", + newPassword: password, + username: this.stateParameters.username, + } + ); + + this.stateParameters.logger.verbose( + "New password is submitted for sign-up.", + this.stateParameters.correlationId + ); + + return new ResetPasswordSubmitPasswordResult( + new ResetPasswordCompletedState({ + correlationId: result.correlationId, + continuationToken: result.continuationToken, + logger: this.stateParameters.logger, + config: this.stateParameters.config, + username: this.stateParameters.username, + signInClient: this.stateParameters.signInClient, + cacheClient: this.stateParameters.cacheClient, + signInScenario: SignInScenario.SignInAfterPasswordReset, + }) + ); + } catch (error) { + this.stateParameters.logger.errorPii( + `Failed to submit password for password reset. Error: ${error}.`, + this.stateParameters.correlationId + ); + + return ResetPasswordSubmitPasswordResult.createWithError(error); + } + } +} diff --git a/lib/msal-browser/src/custom_auth/reset_password/auth_flow/state/ResetPasswordState.ts b/lib/msal-browser/src/custom_auth/reset_password/auth_flow/state/ResetPasswordState.ts new file mode 100644 index 0000000000..e9c81480ac --- /dev/null +++ b/lib/msal-browser/src/custom_auth/reset_password/auth_flow/state/ResetPasswordState.ts @@ -0,0 +1,29 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowActionRequiredStateBase } from "../../../core/auth_flow/AuthFlowState.js"; +import { ensureArgumentIsNotEmptyString } from "../../../core/utils/ArgumentValidator.js"; +import { ResetPasswordStateParameters } from "./ResetPasswordStateParameters.js"; + +/* + * Base state handler for reset password operation. + */ +export abstract class ResetPasswordState< + TParameters extends ResetPasswordStateParameters +> extends AuthFlowActionRequiredStateBase { + /* + * Creates a new state for reset password operation. + * @param stateParameters - The state parameters for reset-password. + */ + constructor(stateParameters: TParameters) { + super(stateParameters); + + ensureArgumentIsNotEmptyString( + "username", + this.stateParameters.username, + this.stateParameters.correlationId + ); + } +} diff --git a/lib/msal-browser/src/custom_auth/reset_password/auth_flow/state/ResetPasswordStateParameters.ts b/lib/msal-browser/src/custom_auth/reset_password/auth_flow/state/ResetPasswordStateParameters.ts new file mode 100644 index 0000000000..f8f16a01b5 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/reset_password/auth_flow/state/ResetPasswordStateParameters.ts @@ -0,0 +1,25 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { ResetPasswordClient } from "../../interaction_client/ResetPasswordClient.js"; +import { SignInClient } from "../../../sign_in/interaction_client/SignInClient.js"; +import { CustomAuthSilentCacheClient } from "../../../get_account/interaction_client/CustomAuthSilentCacheClient.js"; +import { AuthFlowActionRequiredStateParameters } from "../../../core/auth_flow/AuthFlowState.js"; + +export interface ResetPasswordStateParameters + extends AuthFlowActionRequiredStateParameters { + username: string; + resetPasswordClient: ResetPasswordClient; + signInClient: SignInClient; + cacheClient: CustomAuthSilentCacheClient; +} + +export type ResetPasswordPasswordRequiredStateParameters = + ResetPasswordStateParameters; + +export interface ResetPasswordCodeRequiredStateParameters + extends ResetPasswordStateParameters { + codeLength: number; +} diff --git a/lib/msal-browser/src/custom_auth/reset_password/interaction_client/ResetPasswordClient.ts b/lib/msal-browser/src/custom_auth/reset_password/interaction_client/ResetPasswordClient.ts new file mode 100644 index 0000000000..bab66b22db --- /dev/null +++ b/lib/msal-browser/src/custom_auth/reset_password/interaction_client/ResetPasswordClient.ts @@ -0,0 +1,311 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { ServerTelemetryManager } from "@azure/msal-common/browser"; +import { CustomAuthApiError } from "../../core/error/CustomAuthApiError.js"; +import { CustomAuthInteractionClientBase } from "../../core/interaction_client/CustomAuthInteractionClientBase.js"; +import * as CustomAuthApiErrorCode from "../../core/network_client/custom_auth_api/types/ApiErrorCodes.js"; +import { + ResetPasswordChallengeRequest, + ResetPasswordContinueRequest, + ResetPasswordPollCompletionRequest, + ResetPasswordStartRequest, + ResetPasswordSubmitRequest, +} from "../../core/network_client/custom_auth_api/types/ApiRequestTypes.js"; +import * as PublicApiId from "../../core/telemetry/PublicApiId.js"; +import { + ChallengeType, + DefaultCustomAuthApiCodeLength, + PasswordResetPollingTimeoutInMs, + ResetPasswordPollStatus, +} from "../../CustomAuthConstants.js"; +import { + ResetPasswordResendCodeParams, + ResetPasswordStartParams, + ResetPasswordSubmitCodeParams, + ResetPasswordSubmitNewPasswordParams, +} from "./parameter/ResetPasswordParams.js"; +import { + ResetPasswordCodeRequiredResult, + ResetPasswordCompletedResult, + ResetPasswordPasswordRequiredResult, +} from "./result/ResetPasswordActionResult.js"; +import { ensureArgumentIsNotEmptyString } from "../../core/utils/ArgumentValidator.js"; + +export class ResetPasswordClient extends CustomAuthInteractionClientBase { + /** + * Starts the password reset flow. + * @param parameters The parameters for starting the password reset flow. + * @returns The result of password reset start operation. + */ + async start( + parameters: ResetPasswordStartParams + ): Promise { + const correlationId = parameters.correlationId; + const apiId = PublicApiId.PASSWORD_RESET_START; + const telemetryManager = this.initializeServerTelemetryManager(apiId); + + const startRequest: ResetPasswordStartRequest = { + challenge_type: this.getChallengeTypes(parameters.challengeType), + username: parameters.username, + correlationId: correlationId, + telemetryManager: telemetryManager, + }; + + this.logger.verbose( + "Calling start endpoint for password reset flow.", + correlationId + ); + + const startResponse = + await this.customAuthApiClient.resetPasswordApi.start(startRequest); + + this.logger.verbose( + "Start endpoint for password reset returned successfully.", + correlationId + ); + + const challengeRequest: ResetPasswordChallengeRequest = { + continuation_token: startResponse.continuation_token ?? "", + challenge_type: this.getChallengeTypes(parameters.challengeType), + correlationId: correlationId, + telemetryManager: telemetryManager, + }; + + return this.performChallengeRequest(challengeRequest); + } + + /** + * Submits the code for password reset. + * @param parameters The parameters for submitting the code for password reset. + * @returns The result of submitting the code for password reset. + */ + async submitCode( + parameters: ResetPasswordSubmitCodeParams + ): Promise { + const correlationId = parameters.correlationId; + ensureArgumentIsNotEmptyString( + "parameters.code", + parameters.code, + correlationId + ); + + const apiId = PublicApiId.PASSWORD_RESET_SUBMIT_CODE; + const telemetryManager = this.initializeServerTelemetryManager(apiId); + + const continueRequest: ResetPasswordContinueRequest = { + continuation_token: parameters.continuationToken, + oob: parameters.code, + correlationId: correlationId, + telemetryManager: telemetryManager, + }; + + this.logger.verbose( + "Calling continue endpoint with code for password reset.", + correlationId + ); + + const response = + await this.customAuthApiClient.resetPasswordApi.continueWithCode( + continueRequest + ); + + this.logger.verbose( + "Continue endpoint called successfully with code for password reset.", + response.correlation_id + ); + + return { + correlationId: response.correlation_id, + continuationToken: response.continuation_token ?? "", + }; + } + + /** + * Resends the another one-time passcode if the previous one hasn't been verified + * @param parameters The parameters for resending the code for password reset. + * @returns The result of resending the code for password reset. + */ + async resendCode( + parameters: ResetPasswordResendCodeParams + ): Promise { + const apiId = PublicApiId.PASSWORD_RESET_RESEND_CODE; + const telemetryManager = this.initializeServerTelemetryManager(apiId); + + const challengeRequest: ResetPasswordChallengeRequest = { + continuation_token: parameters.continuationToken, + challenge_type: this.getChallengeTypes(parameters.challengeType), + correlationId: parameters.correlationId, + telemetryManager: telemetryManager, + }; + + return this.performChallengeRequest(challengeRequest); + } + + /** + * Submits the new password for password reset. + * @param parameters The parameters for submitting the new password for password reset. + * @returns The result of submitting the new password for password reset. + */ + async submitNewPassword( + parameters: ResetPasswordSubmitNewPasswordParams + ): Promise { + const correlationId = parameters.correlationId; + + ensureArgumentIsNotEmptyString( + "parameters.newPassword", + parameters.newPassword, + correlationId + ); + + const apiId = PublicApiId.PASSWORD_RESET_SUBMIT_PASSWORD; + const telemetryManager = this.initializeServerTelemetryManager(apiId); + + const submitRequest: ResetPasswordSubmitRequest = { + continuation_token: parameters.continuationToken, + new_password: parameters.newPassword, + correlationId: correlationId, + telemetryManager: telemetryManager, + }; + + this.logger.verbose( + "Calling submit endpoint with new password for password reset.", + correlationId + ); + + const submitResponse = + await this.customAuthApiClient.resetPasswordApi.submitNewPassword( + submitRequest + ); + + this.logger.verbose( + "Submit endpoint called successfully with new password for password reset.", + correlationId + ); + + return this.performPollCompletionRequest( + submitResponse.continuation_token ?? "", + submitResponse.poll_interval, + correlationId, + telemetryManager + ); + } + + private async performChallengeRequest( + request: ResetPasswordChallengeRequest + ): Promise { + const correlationId = request.correlationId; + this.logger.verbose( + "Calling challenge endpoint for password reset flow.", + correlationId + ); + + const response = + await this.customAuthApiClient.resetPasswordApi.requestChallenge( + request + ); + + this.logger.verbose( + "Challenge endpoint for password reset returned successfully.", + correlationId + ); + + if (response.challenge_type === ChallengeType.OOB) { + // Code is required + this.logger.verbose( + "Code is required for password reset flow.", + correlationId + ); + + return { + correlationId: response.correlation_id, + continuationToken: response.continuation_token ?? "", + challengeChannel: response.challenge_channel ?? "", + challengeTargetLabel: response.challenge_target_label ?? "", + codeLength: + response.code_length ?? DefaultCustomAuthApiCodeLength, + bindingMethod: response.binding_method ?? "", + }; + } + + this.logger.error( + `Unsupported challenge type '${response.challenge_type}' returned from challenge endpoint for password reset.`, + correlationId + ); + + throw new CustomAuthApiError( + CustomAuthApiErrorCode.UNSUPPORTED_CHALLENGE_TYPE, + `Unsupported challenge type '${response.challenge_type}'.`, + correlationId + ); + } + + private async performPollCompletionRequest( + continuationToken: string, + pollInterval: number, + correlationId: string, + telemetryManager: ServerTelemetryManager + ): Promise { + const startTime = performance.now(); + + while ( + performance.now() - startTime < + PasswordResetPollingTimeoutInMs + ) { + const pollRequest: ResetPasswordPollCompletionRequest = { + continuation_token: continuationToken, + correlationId: correlationId, + telemetryManager: telemetryManager, + }; + + this.logger.verbose( + "Calling the poll completion endpoint for password reset flow.", + correlationId + ); + + const pollResponse = + await this.customAuthApiClient.resetPasswordApi.pollCompletion( + pollRequest + ); + + this.logger.verbose( + "Poll completion endpoint for password reset returned successfully.", + correlationId + ); + + if (pollResponse.status === ResetPasswordPollStatus.SUCCEEDED) { + return { + correlationId: pollResponse.correlation_id, + continuationToken: pollResponse.continuation_token ?? "", + }; + } else if (pollResponse.status === ResetPasswordPollStatus.FAILED) { + throw new CustomAuthApiError( + CustomAuthApiErrorCode.PASSWORD_CHANGE_FAILED, + "Password is failed to be reset.", + pollResponse.correlation_id + ); + } + + this.logger.verbose( + `Poll completion endpoint for password reset is not started or in progress, waiting ${pollInterval} seconds for next check.`, + correlationId + ); + + await this.delay(pollInterval * 1000); + } + + this.logger.error("Password reset flow has timed out.", correlationId); + + throw new CustomAuthApiError( + CustomAuthApiErrorCode.PASSWORD_RESET_TIMEOUT, + "Password reset flow has timed out.", + correlationId + ); + } + + private async delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/lib/msal-browser/src/custom_auth/reset_password/interaction_client/parameter/ResetPasswordParams.ts b/lib/msal-browser/src/custom_auth/reset_password/interaction_client/parameter/ResetPasswordParams.ts new file mode 100644 index 0000000000..fdf38e8bf1 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/reset_password/interaction_client/parameter/ResetPasswordParams.ts @@ -0,0 +1,28 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +export interface ResetPasswordParamsBase { + clientId: string; + challengeType: Array; + username: string; + correlationId: string; +} + +export type ResetPasswordStartParams = ResetPasswordParamsBase; + +export interface ResetPasswordResendCodeParams extends ResetPasswordParamsBase { + continuationToken: string; +} + +export interface ResetPasswordSubmitCodeParams extends ResetPasswordParamsBase { + continuationToken: string; + code: string; +} + +export interface ResetPasswordSubmitNewPasswordParams + extends ResetPasswordParamsBase { + continuationToken: string; + newPassword: string; +} diff --git a/lib/msal-browser/src/custom_auth/reset_password/interaction_client/result/ResetPasswordActionResult.ts b/lib/msal-browser/src/custom_auth/reset_password/interaction_client/result/ResetPasswordActionResult.ts new file mode 100644 index 0000000000..c6b9b844bb --- /dev/null +++ b/lib/msal-browser/src/custom_auth/reset_password/interaction_client/result/ResetPasswordActionResult.ts @@ -0,0 +1,21 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +interface ResetPasswordActionResult { + correlationId: string; + continuationToken: string; +} + +export interface ResetPasswordCodeRequiredResult + extends ResetPasswordActionResult { + challengeChannel: string; + challengeTargetLabel: string; + codeLength: number; + bindingMethod: string; +} + +export type ResetPasswordPasswordRequiredResult = ResetPasswordActionResult; + +export type ResetPasswordCompletedResult = ResetPasswordActionResult; diff --git a/lib/msal-browser/src/custom_auth/sign_in/auth_flow/SignInScenario.ts b/lib/msal-browser/src/custom_auth/sign_in/auth_flow/SignInScenario.ts new file mode 100644 index 0000000000..6c2c591b2c --- /dev/null +++ b/lib/msal-browser/src/custom_auth/sign_in/auth_flow/SignInScenario.ts @@ -0,0 +1,12 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +export const SignInScenario = { + SignInAfterSignUp: "SignInAfterSignUp", + SignInAfterPasswordReset: "SignInAfterPasswordReset", +} as const; + +export type SignInScenarioType = + (typeof SignInScenario)[keyof typeof SignInScenario]; diff --git a/lib/msal-browser/src/custom_auth/sign_in/auth_flow/error_type/SignInError.ts b/lib/msal-browser/src/custom_auth/sign_in/auth_flow/error_type/SignInError.ts new file mode 100644 index 0000000000..2adc8c5320 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/sign_in/auth_flow/error_type/SignInError.ts @@ -0,0 +1,79 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthActionErrorBase } from "../../../core/auth_flow/AuthFlowErrorBase.js"; +import * as CustomAuthApiErrorCode from "../../../core/network_client/custom_auth_api/types/ApiErrorCodes.js"; + +export class SignInError extends AuthActionErrorBase { + /** + * Checks if the error is due to the user not being found. + * @returns true if the error is due to the user not being found, false otherwise. + */ + isUserNotFound(): boolean { + return this.errorData.error === CustomAuthApiErrorCode.USER_NOT_FOUND; + } + + /** + * Checks if the error is due to the username being invalid. + * @returns true if the error is due to the username being invalid, false otherwise. + */ + isInvalidUsername(): boolean { + return this.isUserInvalidError(); + } + + /** + * Checks if the error is due to the provided password being incorrect. + * @returns true if the error is due to the provided password being incorrect, false otherwise. + */ + isPasswordIncorrect(): boolean { + return this.isPasswordIncorrectError(); + } + + /** + * Checks if the error is due to the provided challenge type is not supported. + * @returns {boolean} True if the error is due to the provided challenge type is not supported, false otherwise. + */ + isUnsupportedChallengeType(): boolean { + return this.isUnsupportedChallengeTypeError(); + } + + /** + * Check if client app supports the challenge type configured in Entra. + * @returns {boolean} True if "loginPopup" function is required to continue sthe operation. + */ + isRedirectRequired(): boolean { + return this.isRedirectError(); + } +} + +export class SignInSubmitPasswordError extends AuthActionErrorBase { + /** + * Checks if the password submitted during sign-in is incorrect. + * @returns {boolean} True if the error is due to the password being invalid, false otherwise. + */ + isInvalidPassword(): boolean { + return this.isPasswordIncorrectError(); + } +} + +export class SignInSubmitCodeError extends AuthActionErrorBase { + /** + * Checks if the code submitted during sign-in is invalid. + * @returns {boolean} True if the error is due to the code being invalid, false otherwise. + */ + isInvalidCode(): boolean { + return this.isInvalidCodeError(); + } +} + +export class SignInResendCodeError extends AuthActionErrorBase { + /** + * Check if client app supports the challenge type configured in Entra. + * @returns {boolean} True if "loginPopup" function is required to continue sthe operation. + */ + isRedirectRequired(): boolean { + return this.isRedirectError(); + } +} diff --git a/lib/msal-browser/src/custom_auth/sign_in/auth_flow/result/SignInResendCodeResult.ts b/lib/msal-browser/src/custom_auth/sign_in/auth_flow/result/SignInResendCodeResult.ts new file mode 100644 index 0000000000..f9c598745b --- /dev/null +++ b/lib/msal-browser/src/custom_auth/sign_in/auth_flow/result/SignInResendCodeResult.ts @@ -0,0 +1,67 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowResultBase } from "../../../core/auth_flow/AuthFlowResultBase.js"; +import { SignInResendCodeError } from "../error_type/SignInError.js"; +import type { SignInCodeRequiredState } from "../state/SignInCodeRequiredState.js"; +import { SignInFailedState } from "../state/SignInFailedState.js"; + +export class SignInResendCodeResult extends AuthFlowResultBase< + SignInResendCodeResultState, + SignInResendCodeError, + void +> { + /** + * Creates a new instance of SignInResendCodeResult. + * @param state The state of the result. + */ + constructor(state: SignInResendCodeResultState) { + super(state); + } + + /** + * Creates a new instance of SignInResendCodeResult with an error. + * @param error The error that occurred. + * @returns {SignInResendCodeResult} A new instance of SignInResendCodeResult with the error set. + */ + static createWithError(error: unknown): SignInResendCodeResult { + const result = new SignInResendCodeResult(new SignInFailedState()); + result.error = new SignInResendCodeError( + SignInResendCodeResult.createErrorData(error) + ); + + return result; + } + + /** + * Checks if the result is in a failed state. + */ + isFailed(): this is SignInResendCodeResult & { state: SignInFailedState } { + return this.state instanceof SignInFailedState; + } + + /** + * Checks if the result is in a code required state. + */ + isCodeRequired(): this is SignInResendCodeResult & { + state: SignInCodeRequiredState; + } { + /* + * The instanceof operator couldn't be used here to check the state type since the circular dependency issue. + * So we are using the constructor name to check the state type. + */ + return this.state.constructor?.name === "SignInCodeRequiredState"; + } +} + +/** + * The possible states for the SignInResendCodeResult. + * This includes: + * - SignInCodeRequiredState: The sign-in process requires a code. + * - SignInFailedState: The sign-in process has failed. + */ +export type SignInResendCodeResultState = + | SignInCodeRequiredState + | SignInFailedState; diff --git a/lib/msal-browser/src/custom_auth/sign_in/auth_flow/result/SignInResult.ts b/lib/msal-browser/src/custom_auth/sign_in/auth_flow/result/SignInResult.ts new file mode 100644 index 0000000000..59d101d496 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/sign_in/auth_flow/result/SignInResult.ts @@ -0,0 +1,87 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthAccountData } from "../../../get_account/auth_flow/CustomAuthAccountData.js"; +import { AuthFlowResultBase } from "../../../core/auth_flow/AuthFlowResultBase.js"; +import { SignInError } from "../error_type/SignInError.js"; +import { SignInCodeRequiredState } from "../state/SignInCodeRequiredState.js"; +import { SignInPasswordRequiredState } from "../state/SignInPasswordRequiredState.js"; +import { SignInFailedState } from "../state/SignInFailedState.js"; +import { SignInCompletedState } from "../state/SignInCompletedState.js"; + +/* + * Result of a sign-in operation. + */ +export class SignInResult extends AuthFlowResultBase< + SignInResultState, + SignInError, + CustomAuthAccountData +> { + /** + * Creates a new instance of SignInResultState. + * @param state The state of the result. + */ + constructor(state: SignInResultState, resultData?: CustomAuthAccountData) { + super(state, resultData); + } + + /** + * Creates a new instance of SignInResult with an error. + * @param error The error that occurred. + * @returns {SignInResult} A new instance of SignInResult with the error set. + */ + static createWithError(error: unknown): SignInResult { + const result = new SignInResult(new SignInFailedState()); + result.error = new SignInError(SignInResult.createErrorData(error)); + + return result; + } + + /** + * Checks if the result is in a failed state. + */ + isFailed(): this is SignInResult & { state: SignInFailedState } { + return this.state instanceof SignInFailedState; + } + + /** + * Checks if the result is in a code required state. + */ + isCodeRequired(): this is SignInResult & { + state: SignInCodeRequiredState; + } { + return this.state instanceof SignInCodeRequiredState; + } + + /** + * Checks if the result is in a password required state. + */ + isPasswordRequired(): this is SignInResult & { + state: SignInPasswordRequiredState; + } { + return this.state instanceof SignInPasswordRequiredState; + } + + /** + * Checks if the result is in a completed state. + */ + isCompleted(): this is SignInResult & { state: SignInCompletedState } { + return this.state instanceof SignInCompletedState; + } +} + +/** + * The possible states for the SignInResult. + * This includes: + * - SignInCodeRequiredState: The sign-in process requires a code. + * - SignInPasswordRequiredState: The sign-in process requires a password. + * - SignInFailedState: The sign-in process has failed. + * - SignInCompletedState: The sign-in process is completed. + */ +export type SignInResultState = + | SignInCodeRequiredState + | SignInPasswordRequiredState + | SignInFailedState + | SignInCompletedState; diff --git a/lib/msal-browser/src/custom_auth/sign_in/auth_flow/result/SignInSubmitCodeResult.ts b/lib/msal-browser/src/custom_auth/sign_in/auth_flow/result/SignInSubmitCodeResult.ts new file mode 100644 index 0000000000..356178ffd4 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/sign_in/auth_flow/result/SignInSubmitCodeResult.ts @@ -0,0 +1,44 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { SignInSubmitCodeError } from "../error_type/SignInError.js"; +import { SignInCompletedState } from "../state/SignInCompletedState.js"; +import { SignInFailedState } from "../state/SignInFailedState.js"; +import { SignInSubmitCredentialResult } from "./SignInSubmitCredentialResult.js"; + +/* + * Result of a sign-in submit code operation. + */ +export class SignInSubmitCodeResult extends SignInSubmitCredentialResult { + /** + * Creates a new instance of SignInSubmitCodeResult with error data. + * @param error The error that occurred. + * @returns {SignInSubmitCodeResult} A new instance of SignInSubmitCodeResult with the error set. + */ + static createWithError(error: unknown): SignInSubmitCodeResult { + const result = new SignInSubmitCodeResult(new SignInFailedState()); + result.error = new SignInSubmitCodeError( + SignInSubmitCodeResult.createErrorData(error) + ); + + return result; + } + + /** + * Checks if the result is in a failed state. + */ + isFailed(): this is SignInSubmitCodeResult & { state: SignInFailedState } { + return this.state instanceof SignInFailedState; + } + + /** + * Checks if the result is in a completed state. + */ + isCompleted(): this is SignInSubmitCodeResult & { + state: SignInCompletedState; + } { + return this.state instanceof SignInCompletedState; + } +} diff --git a/lib/msal-browser/src/custom_auth/sign_in/auth_flow/result/SignInSubmitCredentialResult.ts b/lib/msal-browser/src/custom_auth/sign_in/auth_flow/result/SignInSubmitCredentialResult.ts new file mode 100644 index 0000000000..53df78770d --- /dev/null +++ b/lib/msal-browser/src/custom_auth/sign_in/auth_flow/result/SignInSubmitCredentialResult.ts @@ -0,0 +1,43 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthAccountData } from "../../../get_account/auth_flow/CustomAuthAccountData.js"; +import { AuthFlowErrorBase } from "../../../core/auth_flow/AuthFlowErrorBase.js"; +import { AuthFlowResultBase } from "../../../core/auth_flow/AuthFlowResultBase.js"; +import { SignInFailedState } from "../state/SignInFailedState.js"; +import { SignInCompletedState } from "../state/SignInCompletedState.js"; + +/* + * Result of a sign-in submit credential operation. + */ +export abstract class SignInSubmitCredentialResult< + TError extends AuthFlowErrorBase +> extends AuthFlowResultBase< + SignInSubmitCredentialResultState, + TError, + CustomAuthAccountData +> { + /** + * Creates a new instance of SignInSubmitCredentialResult. + * @param state The state of the result. + * @param resultData The result data. + */ + constructor( + state: SignInSubmitCredentialResultState, + resultData?: CustomAuthAccountData + ) { + super(state, resultData); + } +} + +/** + * The possible states of the SignInSubmitCredentialResult. + * This includes: + * - SignInCompletedState: The sign-in process has completed successfully. + * - SignInFailedState: The sign-in process has failed. + */ +export type SignInSubmitCredentialResultState = + | SignInCompletedState + | SignInFailedState; diff --git a/lib/msal-browser/src/custom_auth/sign_in/auth_flow/result/SignInSubmitPasswordResult.ts b/lib/msal-browser/src/custom_auth/sign_in/auth_flow/result/SignInSubmitPasswordResult.ts new file mode 100644 index 0000000000..b653697aa0 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/sign_in/auth_flow/result/SignInSubmitPasswordResult.ts @@ -0,0 +1,41 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { SignInSubmitPasswordError } from "../error_type/SignInError.js"; +import { SignInCompletedState } from "../state/SignInCompletedState.js"; +import { SignInFailedState } from "../state/SignInFailedState.js"; +import { SignInSubmitCredentialResult } from "./SignInSubmitCredentialResult.js"; + +/* + * Result of a sign-in submit password operation. + */ +export class SignInSubmitPasswordResult extends SignInSubmitCredentialResult { + static createWithError(error: unknown): SignInSubmitPasswordResult { + const result = new SignInSubmitPasswordResult(new SignInFailedState()); + result.error = new SignInSubmitPasswordError( + SignInSubmitPasswordResult.createErrorData(error) + ); + + return result; + } + + /** + * Checks if the result is in a failed state. + */ + isFailed(): this is SignInSubmitPasswordResult & { + state: SignInFailedState; + } { + return this.state instanceof SignInFailedState; + } + + /** + * Checks if the result is in a completed state. + */ + isCompleted(): this is SignInSubmitPasswordResult & { + state: SignInCompletedState; + } { + return this.state instanceof SignInCompletedState; + } +} diff --git a/lib/msal-browser/src/custom_auth/sign_in/auth_flow/state/SignInCodeRequiredState.ts b/lib/msal-browser/src/custom_auth/sign_in/auth_flow/state/SignInCodeRequiredState.ts new file mode 100644 index 0000000000..8094b4f70b --- /dev/null +++ b/lib/msal-browser/src/custom_auth/sign_in/auth_flow/state/SignInCodeRequiredState.ts @@ -0,0 +1,141 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthAccountData } from "../../../get_account/auth_flow/CustomAuthAccountData.js"; +import { + SignInResendCodeParams, + SignInSubmitCodeParams, +} from "../../interaction_client/parameter/SignInParams.js"; +import { SignInResendCodeResult } from "../result/SignInResendCodeResult.js"; +import { SignInSubmitCodeResult } from "../result/SignInSubmitCodeResult.js"; +import { SignInCodeRequiredStateParameters } from "./SignInStateParameters.js"; +import { SignInState } from "./SignInState.js"; +import { SignInCompletedState } from "./SignInCompletedState.js"; + +/* + * Sign-in code required state. + */ +export class SignInCodeRequiredState extends SignInState { + /** + * Once user configures email one-time passcode as a authentication method in Microsoft Entra, a one-time passcode will be sent to the user’s email. + * Submit this one-time passcode to continue sign-in flow. + * @param {string} code - The code to submit. + * @returns {Promise} The result of the operation. + */ + async submitCode(code: string): Promise { + try { + this.ensureCodeIsValid(code, this.stateParameters.codeLength); + + const submitCodeParams: SignInSubmitCodeParams = { + clientId: this.stateParameters.config.auth.clientId, + correlationId: this.stateParameters.correlationId, + challengeType: + this.stateParameters.config.customAuth.challengeTypes ?? [], + scopes: this.stateParameters.scopes ?? [], + continuationToken: this.stateParameters.continuationToken ?? "", + code: code, + username: this.stateParameters.username, + }; + + this.stateParameters.logger.verbose( + "Submitting code for sign-in.", + this.stateParameters.correlationId + ); + + const completedResult = + await this.stateParameters.signInClient.submitCode( + submitCodeParams + ); + + this.stateParameters.logger.verbose( + "Code submitted for sign-in.", + this.stateParameters.correlationId + ); + + const accountInfo = new CustomAuthAccountData( + completedResult.authenticationResult.account, + this.stateParameters.config, + this.stateParameters.cacheClient, + this.stateParameters.logger, + this.stateParameters.correlationId + ); + + return new SignInSubmitCodeResult( + new SignInCompletedState(), + accountInfo + ); + } catch (error) { + this.stateParameters.logger.errorPii( + `Failed to submit code for sign-in. Error: ${error}.`, + this.stateParameters.correlationId + ); + + return SignInSubmitCodeResult.createWithError(error); + } + } + + /** + * Resends the another one-time passcode for sign-in flow if the previous one hasn't been verified. + * @returns {Promise} The result of the operation. + */ + async resendCode(): Promise { + try { + const submitCodeParams: SignInResendCodeParams = { + clientId: this.stateParameters.config.auth.clientId, + correlationId: this.stateParameters.correlationId, + challengeType: + this.stateParameters.config.customAuth.challengeTypes ?? [], + continuationToken: this.stateParameters.continuationToken ?? "", + username: this.stateParameters.username, + }; + + this.stateParameters.logger.verbose( + "Resending code for sign-in.", + this.stateParameters.correlationId + ); + + const result = await this.stateParameters.signInClient.resendCode( + submitCodeParams + ); + + this.stateParameters.logger.verbose( + "Code resent for sign-in.", + this.stateParameters.correlationId + ); + + return new SignInResendCodeResult( + new SignInCodeRequiredState({ + correlationId: result.correlationId, + continuationToken: result.continuationToken, + logger: this.stateParameters.logger, + config: this.stateParameters.config, + signInClient: this.stateParameters.signInClient, + cacheClient: this.stateParameters.cacheClient, + username: this.stateParameters.username, + codeLength: result.codeLength, + scopes: this.stateParameters.scopes, + }) + ); + } catch (error) { + return SignInResendCodeResult.createWithError(error); + } + } + + /** + * Gets the sent code length. + * @returns {number} The length of the code. + */ + getCodeLength(): number { + return this.stateParameters.codeLength; + } + + /** + * Gets the scopes to request. + * @returns {string[] | undefined} The scopes to request. + */ + getScopes(): string[] | undefined { + return this.stateParameters.scopes; + } +} diff --git a/lib/msal-browser/src/custom_auth/sign_in/auth_flow/state/SignInCompletedState.ts b/lib/msal-browser/src/custom_auth/sign_in/auth_flow/state/SignInCompletedState.ts new file mode 100644 index 0000000000..313a6e4030 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/sign_in/auth_flow/state/SignInCompletedState.ts @@ -0,0 +1,12 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowStateBase } from "../../../core/auth_flow/AuthFlowState.js"; + +/** + * Represents the completed state of the sign-in operation. + * This state indicates that the sign-in process has finished successfully. + */ +export class SignInCompletedState extends AuthFlowStateBase {} diff --git a/lib/msal-browser/src/custom_auth/sign_in/auth_flow/state/SignInContinuationState.ts b/lib/msal-browser/src/custom_auth/sign_in/auth_flow/state/SignInContinuationState.ts new file mode 100644 index 0000000000..f7c069fe4e --- /dev/null +++ b/lib/msal-browser/src/custom_auth/sign_in/auth_flow/state/SignInContinuationState.ts @@ -0,0 +1,71 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthAccountData } from "../../../get_account/auth_flow/CustomAuthAccountData.js"; +import { SignInContinuationTokenParams } from "../../interaction_client/parameter/SignInParams.js"; +import { SignInResult } from "../result/SignInResult.js"; +import { SignInWithContinuationTokenInputs } from "../../../CustomAuthActionInputs.js"; +import { SignInContinuationStateParameters } from "./SignInStateParameters.js"; +import { SignInState } from "./SignInState.js"; +import { SignInCompletedState } from "./SignInCompletedState.js"; + +/* + * Sign-in continuation state. + */ +export class SignInContinuationState extends SignInState { + /** + * Initiates the sign-in flow with continuation token. + * @param {SignInWithContinuationTokenInputs} signInWithContinuationTokenInputs - The result of the operation. + * @returns {Promise} The result of the operation. + */ + async signIn( + signInWithContinuationTokenInputs?: SignInWithContinuationTokenInputs + ): Promise { + try { + const continuationTokenParams: SignInContinuationTokenParams = { + clientId: this.stateParameters.config.auth.clientId, + correlationId: this.stateParameters.correlationId, + challengeType: + this.stateParameters.config.customAuth.challengeTypes ?? [], + scopes: signInWithContinuationTokenInputs?.scopes ?? [], + continuationToken: this.stateParameters.continuationToken ?? "", + username: this.stateParameters.username, + signInScenario: this.stateParameters.signInScenario, + }; + + this.stateParameters.logger.verbose( + "Signing in with continuation token.", + this.stateParameters.correlationId + ); + + const completedResult = + await this.stateParameters.signInClient.signInWithContinuationToken( + continuationTokenParams + ); + + this.stateParameters.logger.verbose( + "Signed in with continuation token.", + this.stateParameters.correlationId + ); + + const accountInfo = new CustomAuthAccountData( + completedResult.authenticationResult.account, + this.stateParameters.config, + this.stateParameters.cacheClient, + this.stateParameters.logger, + this.stateParameters.correlationId + ); + + return new SignInResult(new SignInCompletedState(), accountInfo); + } catch (error) { + this.stateParameters.logger.errorPii( + `Failed to sign in with continuation token. Error: ${error}.`, + this.stateParameters.correlationId + ); + + return SignInResult.createWithError(error); + } + } +} diff --git a/lib/msal-browser/src/custom_auth/sign_in/auth_flow/state/SignInFailedState.ts b/lib/msal-browser/src/custom_auth/sign_in/auth_flow/state/SignInFailedState.ts new file mode 100644 index 0000000000..e80641e575 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/sign_in/auth_flow/state/SignInFailedState.ts @@ -0,0 +1,11 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowStateBase } from "../../../core/auth_flow/AuthFlowState.js"; + +/** + * Represents the state of a sign-in operation that has been failed. + */ +export class SignInFailedState extends AuthFlowStateBase {} diff --git a/lib/msal-browser/src/custom_auth/sign_in/auth_flow/state/SignInPasswordRequiredState.ts b/lib/msal-browser/src/custom_auth/sign_in/auth_flow/state/SignInPasswordRequiredState.ts new file mode 100644 index 0000000000..025e13174e --- /dev/null +++ b/lib/msal-browser/src/custom_auth/sign_in/auth_flow/state/SignInPasswordRequiredState.ts @@ -0,0 +1,83 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthAccountData } from "../../../get_account/auth_flow/CustomAuthAccountData.js"; +import { SignInSubmitPasswordParams } from "../../interaction_client/parameter/SignInParams.js"; +import { SignInSubmitPasswordResult } from "../result/SignInSubmitPasswordResult.js"; +import { SignInCompletedState } from "./SignInCompletedState.js"; +import { SignInState } from "./SignInState.js"; +import { SignInPasswordRequiredStateParameters } from "./SignInStateParameters.js"; + +/* + * Sign-in password required state. + */ +export class SignInPasswordRequiredState extends SignInState { + /** + * Once user configures email with password as a authentication method in Microsoft Entra, user submits a password to continue sign-in flow. + * @param {string} password - The password to submit. + * @returns {Promise} The result of the operation. + */ + async submitPassword( + password: string + ): Promise { + try { + this.ensurePasswordIsNotEmpty(password); + + const submitPasswordParams: SignInSubmitPasswordParams = { + clientId: this.stateParameters.config.auth.clientId, + correlationId: this.stateParameters.correlationId, + challengeType: + this.stateParameters.config.customAuth.challengeTypes ?? [], + scopes: this.stateParameters.scopes ?? [], + continuationToken: this.stateParameters.continuationToken ?? "", + password: password, + username: this.stateParameters.username, + }; + + this.stateParameters.logger.verbose( + "Submitting password for sign-in.", + this.stateParameters.correlationId + ); + + const completedResult = + await this.stateParameters.signInClient.submitPassword( + submitPasswordParams + ); + + this.stateParameters.logger.verbose( + "Password submitted for sign-in.", + this.stateParameters.correlationId + ); + + const accountInfo = new CustomAuthAccountData( + completedResult.authenticationResult.account, + this.stateParameters.config, + this.stateParameters.cacheClient, + this.stateParameters.logger, + this.stateParameters.correlationId + ); + + return new SignInSubmitPasswordResult( + new SignInCompletedState(), + accountInfo + ); + } catch (error) { + this.stateParameters.logger.errorPii( + `Failed to sign in after submitting password. Error: ${error}.`, + this.stateParameters.correlationId + ); + + return SignInSubmitPasswordResult.createWithError(error); + } + } + + /** + * Gets the scopes to request. + * @returns {string[] | undefined} The scopes to request. + */ + getScopes(): string[] | undefined { + return this.stateParameters.scopes; + } +} diff --git a/lib/msal-browser/src/custom_auth/sign_in/auth_flow/state/SignInState.ts b/lib/msal-browser/src/custom_auth/sign_in/auth_flow/state/SignInState.ts new file mode 100644 index 0000000000..4e7f01cb53 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/sign_in/auth_flow/state/SignInState.ts @@ -0,0 +1,34 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowActionRequiredStateBase } from "../../../core/auth_flow/AuthFlowState.js"; +import { ensureArgumentIsNotEmptyString } from "../../../core/utils/ArgumentValidator.js"; +import { SignInStateParameters } from "./SignInStateParameters.js"; + +/* + * Base state handler for sign-in flow. + */ +export abstract class SignInState< + TParameters extends SignInStateParameters +> extends AuthFlowActionRequiredStateBase { + /* + * Creates a new SignInState. + * @param stateParameters - The state parameters for sign-in. + */ + constructor(stateParameters: TParameters) { + super(stateParameters); + + ensureArgumentIsNotEmptyString( + "username", + stateParameters.username, + stateParameters.correlationId + ); + ensureArgumentIsNotEmptyString( + "continuationToken", + stateParameters.continuationToken, + stateParameters.correlationId + ); + } +} diff --git a/lib/msal-browser/src/custom_auth/sign_in/auth_flow/state/SignInStateParameters.ts b/lib/msal-browser/src/custom_auth/sign_in/auth_flow/state/SignInStateParameters.ts new file mode 100644 index 0000000000..61f802a9b2 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/sign_in/auth_flow/state/SignInStateParameters.ts @@ -0,0 +1,32 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowActionRequiredStateParameters } from "../../../core/auth_flow/AuthFlowState.js"; +import { CustomAuthSilentCacheClient } from "../../../get_account/interaction_client/CustomAuthSilentCacheClient.js"; +import { SignInClient } from "../../interaction_client/SignInClient.js"; +import { SignInScenarioType } from "../SignInScenario.js"; + +export interface SignInStateParameters + extends AuthFlowActionRequiredStateParameters { + username: string; + signInClient: SignInClient; + cacheClient: CustomAuthSilentCacheClient; +} + +export interface SignInPasswordRequiredStateParameters + extends SignInStateParameters { + scopes?: string[]; +} + +export interface SignInCodeRequiredStateParameters + extends SignInStateParameters { + codeLength: number; + scopes?: string[]; +} + +export interface SignInContinuationStateParameters + extends SignInStateParameters { + signInScenario: SignInScenarioType; +} diff --git a/lib/msal-browser/src/custom_auth/sign_in/interaction_client/SignInClient.ts b/lib/msal-browser/src/custom_auth/sign_in/interaction_client/SignInClient.ts new file mode 100644 index 0000000000..d5351850fb --- /dev/null +++ b/lib/msal-browser/src/custom_auth/sign_in/interaction_client/SignInClient.ts @@ -0,0 +1,396 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { + ChallengeType, + DefaultCustomAuthApiCodeLength, +} from "../../CustomAuthConstants.js"; +import { CustomAuthApiError } from "../../core/error/CustomAuthApiError.js"; +import * as CustomAuthApiErrorCode from "../../core/network_client/custom_auth_api/types/ApiErrorCodes.js"; + +import { CustomAuthInteractionClientBase } from "../../core/interaction_client/CustomAuthInteractionClientBase.js"; +import { + SignInStartParams, + SignInResendCodeParams, + SignInSubmitCodeParams, + SignInSubmitPasswordParams, + SignInContinuationTokenParams, +} from "./parameter/SignInParams.js"; +import { + createSignInCodeSendResult, + createSignInCompleteResult, + createSignInPasswordRequiredResult, + SIGN_IN_PASSWORD_REQUIRED_RESULT_TYPE, + SignInCodeSendResult, + SignInCompletedResult, + SignInPasswordRequiredResult, +} from "./result/SignInActionResult.js"; +import * as PublicApiId from "../../core/telemetry/PublicApiId.js"; +import { + SignInChallengeRequest, + SignInContinuationTokenRequest, + SignInInitiateRequest, + SignInOobTokenRequest, + SignInPasswordTokenRequest, +} from "../../core/network_client/custom_auth_api/types/ApiRequestTypes.js"; +import { SignInTokenResponse } from "../../core/network_client/custom_auth_api/types/ApiResponseTypes.js"; +import { + SignInScenario, + SignInScenarioType, +} from "../auth_flow/SignInScenario.js"; +import { UnexpectedError } from "../../core/error/UnexpectedError.js"; +import { ICustomAuthApiClient } from "../../core/network_client/custom_auth_api/ICustomAuthApiClient.js"; +import { CustomAuthAuthority } from "../../core/CustomAuthAuthority.js"; +import { + ICrypto, + IPerformanceClient, + Logger, + ResponseHandler, +} from "@azure/msal-common/browser"; +import { BrowserConfiguration } from "../../../config/Configuration.js"; +import { BrowserCacheManager } from "../../../cache/BrowserCacheManager.js"; +import { EventHandler } from "../../../event/EventHandler.js"; +import { INavigationClient } from "../../../navigation/INavigationClient.js"; +import { AuthenticationResult } from "../../../response/AuthenticationResult.js"; +import { ensureArgumentIsNotEmptyString } from "../../core/utils/ArgumentValidator.js"; + +export class SignInClient extends CustomAuthInteractionClientBase { + private readonly tokenResponseHandler: ResponseHandler; + + constructor( + config: BrowserConfiguration, + storageImpl: BrowserCacheManager, + browserCrypto: ICrypto, + logger: Logger, + eventHandler: EventHandler, + navigationClient: INavigationClient, + performanceClient: IPerformanceClient, + customAuthApiClient: ICustomAuthApiClient, + customAuthAuthority: CustomAuthAuthority + ) { + super( + config, + storageImpl, + browserCrypto, + logger, + eventHandler, + navigationClient, + performanceClient, + customAuthApiClient, + customAuthAuthority + ); + + this.tokenResponseHandler = new ResponseHandler( + this.config.auth.clientId, + this.browserStorage, + this.browserCrypto, + this.logger, + null, + null + ); + } + + /** + * Starts the signin flow. + * @param parameters The parameters required to start the sign-in flow. + * @returns The result of the sign-in start operation. + */ + async start( + parameters: SignInStartParams + ): Promise { + const apiId = !parameters.password + ? PublicApiId.SIGN_IN_WITH_CODE_START + : PublicApiId.SIGN_IN_WITH_PASSWORD_START; + const telemetryManager = this.initializeServerTelemetryManager(apiId); + + this.logger.verbose( + "Calling initiate endpoint for sign in.", + parameters.correlationId + ); + + const initReq: SignInInitiateRequest = { + challenge_type: this.getChallengeTypes(parameters.challengeType), + username: parameters.username, + correlationId: parameters.correlationId, + telemetryManager: telemetryManager, + }; + + const initiateResponse = + await this.customAuthApiClient.signInApi.initiate(initReq); + + this.logger.verbose( + "Initiate endpoint called for sign in.", + parameters.correlationId + ); + + const challengeReq: SignInChallengeRequest = { + challenge_type: this.getChallengeTypes(parameters.challengeType), + continuation_token: initiateResponse.continuation_token ?? "", + correlationId: initiateResponse.correlation_id, + telemetryManager: telemetryManager, + }; + + return this.performChallengeRequest(challengeReq); + } + + /** + * Resends the code for sign-in flow. + * @param parameters The parameters required to resend the code. + * @returns The result of the sign-in resend code action. + */ + async resendCode( + parameters: SignInResendCodeParams + ): Promise { + const apiId = PublicApiId.SIGN_IN_RESEND_CODE; + const telemetryManager = this.initializeServerTelemetryManager(apiId); + + const challengeReq: SignInChallengeRequest = { + challenge_type: this.getChallengeTypes(parameters.challengeType), + continuation_token: parameters.continuationToken ?? "", + correlationId: parameters.correlationId, + telemetryManager: telemetryManager, + }; + + const result = await this.performChallengeRequest(challengeReq); + + if (result.type === SIGN_IN_PASSWORD_REQUIRED_RESULT_TYPE) { + this.logger.error( + "Resend code operation failed due to the challenge type 'password' is not supported.", + parameters.correlationId + ); + + throw new CustomAuthApiError( + CustomAuthApiErrorCode.UNSUPPORTED_CHALLENGE_TYPE, + "Unsupported challenge type 'password'.", + result.correlationId + ); + } + + return result; + } + + /** + * Submits the code for sign-in flow. + * @param parameters The parameters required to submit the code. + * @returns The result of the sign-in submit code action. + */ + async submitCode( + parameters: SignInSubmitCodeParams + ): Promise { + ensureArgumentIsNotEmptyString( + "parameters.code", + parameters.code, + parameters.correlationId + ); + + const apiId = PublicApiId.SIGN_IN_SUBMIT_CODE; + const telemetryManager = this.initializeServerTelemetryManager(apiId); + const scopes = this.getScopes(parameters.scopes); + + const request: SignInOobTokenRequest = { + continuation_token: parameters.continuationToken, + oob: parameters.code, + scope: scopes.join(" "), + correlationId: parameters.correlationId, + telemetryManager: telemetryManager, + }; + + return this.performTokenRequest( + () => + this.customAuthApiClient.signInApi.requestTokensWithOob( + request + ), + scopes + ); + } + + /** + * Submits the password for sign-in flow. + * @param parameters The parameters required to submit the password. + * @returns The result of the sign-in submit password action. + */ + async submitPassword( + parameters: SignInSubmitPasswordParams + ): Promise { + ensureArgumentIsNotEmptyString( + "parameters.password", + parameters.password, + parameters.correlationId + ); + + const apiId = PublicApiId.SIGN_IN_SUBMIT_PASSWORD; + const telemetryManager = this.initializeServerTelemetryManager(apiId); + const scopes = this.getScopes(parameters.scopes); + + const request: SignInPasswordTokenRequest = { + continuation_token: parameters.continuationToken, + password: parameters.password, + scope: scopes.join(" "), + correlationId: parameters.correlationId, + telemetryManager: telemetryManager, + }; + + return this.performTokenRequest( + () => + this.customAuthApiClient.signInApi.requestTokensWithPassword( + request + ), + scopes + ); + } + + /** + * Signs in with continuation token. + * @param parameters The parameters required to sign in with continuation token. + * @returns The result of the sign-in complete action. + */ + async signInWithContinuationToken( + parameters: SignInContinuationTokenParams + ): Promise { + const apiId = this.getPublicApiIdBySignInScenario( + parameters.signInScenario, + parameters.correlationId + ); + const telemetryManager = this.initializeServerTelemetryManager(apiId); + const scopes = this.getScopes(parameters.scopes); + + // Create token request. + const request: SignInContinuationTokenRequest = { + continuation_token: parameters.continuationToken, + username: parameters.username, + correlationId: parameters.correlationId, + telemetryManager: telemetryManager, + scope: scopes.join(" "), + }; + + // Call token endpoint. + return this.performTokenRequest( + () => + this.customAuthApiClient.signInApi.requestTokenWithContinuationToken( + request + ), + scopes + ); + } + + private async performTokenRequest( + tokenEndpointCaller: () => Promise, + requestScopes: string[] + ): Promise { + this.logger.verbose( + "Calling token endpoint for sign in.", + this.correlationId + ); + + const requestTimestamp = Math.round(new Date().getTime() / 1000.0); + const tokenResponse = await tokenEndpointCaller(); + + this.logger.verbose( + "Token endpoint called for sign in.", + this.correlationId + ); + + // Save tokens and create authentication result. + const result = + await this.tokenResponseHandler.handleServerTokenResponse( + tokenResponse, + this.customAuthAuthority, + requestTimestamp, + { + authority: this.customAuthAuthority.canonicalAuthority, + correlationId: tokenResponse.correlation_id ?? "", + scopes: requestScopes, + storeInCache: { + idToken: true, + accessToken: true, + refreshToken: true, + }, + } + ); + + return createSignInCompleteResult({ + correlationId: tokenResponse.correlation_id ?? "", + authenticationResult: result as AuthenticationResult, + }); + } + + private async performChallengeRequest( + request: SignInChallengeRequest + ): Promise { + this.logger.verbose( + "Calling challenge endpoint for sign in.", + request.correlationId + ); + + const challengeResponse = + await this.customAuthApiClient.signInApi.requestChallenge(request); + + this.logger.verbose( + "Challenge endpoint called for sign in.", + request.correlationId + ); + + if (challengeResponse.challenge_type === ChallengeType.OOB) { + // Code is required + this.logger.verbose( + "Challenge type is oob for sign in.", + request.correlationId + ); + + return createSignInCodeSendResult({ + correlationId: challengeResponse.correlation_id, + continuationToken: challengeResponse.continuation_token ?? "", + challengeChannel: challengeResponse.challenge_channel ?? "", + challengeTargetLabel: + challengeResponse.challenge_target_label ?? "", + codeLength: + challengeResponse.code_length ?? + DefaultCustomAuthApiCodeLength, + bindingMethod: challengeResponse.binding_method ?? "", + }); + } + + if (challengeResponse.challenge_type === ChallengeType.PASSWORD) { + // Password is required + this.logger.verbose( + "Challenge type is password for sign in.", + request.correlationId + ); + + return createSignInPasswordRequiredResult({ + correlationId: challengeResponse.correlation_id, + continuationToken: challengeResponse.continuation_token ?? "", + }); + } + + this.logger.error( + `Unsupported challenge type '${challengeResponse.challenge_type}' for sign in.`, + request.correlationId + ); + + throw new CustomAuthApiError( + CustomAuthApiErrorCode.UNSUPPORTED_CHALLENGE_TYPE, + `Unsupported challenge type '${challengeResponse.challenge_type}'.`, + challengeResponse.correlation_id + ); + } + + private getPublicApiIdBySignInScenario( + scenario: SignInScenarioType, + correlationId: string + ): number { + switch (scenario) { + case SignInScenario.SignInAfterSignUp: + return PublicApiId.SIGN_IN_AFTER_SIGN_UP; + case SignInScenario.SignInAfterPasswordReset: + return PublicApiId.SIGN_IN_AFTER_PASSWORD_RESET; + default: + throw new UnexpectedError( + `Unsupported sign-in scenario '${scenario}'.`, + correlationId + ); + } + } +} diff --git a/lib/msal-browser/src/custom_auth/sign_in/interaction_client/parameter/SignInParams.ts b/lib/msal-browser/src/custom_auth/sign_in/interaction_client/parameter/SignInParams.ts new file mode 100644 index 0000000000..9d83fee076 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/sign_in/interaction_client/parameter/SignInParams.ts @@ -0,0 +1,39 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { SignInScenarioType } from "../../auth_flow/SignInScenario.js"; + +export interface SignInParamsBase { + clientId: string; + correlationId: string; + challengeType: Array; + username: string; +} + +export interface SignInResendCodeParams extends SignInParamsBase { + continuationToken: string; +} + +export interface SignInStartParams extends SignInParamsBase { + password?: string; +} + +export interface SignInSubmitCodeParams extends SignInParamsBase { + continuationToken: string; + code: string; + scopes: Array; +} + +export interface SignInSubmitPasswordParams extends SignInParamsBase { + continuationToken: string; + password: string; + scopes: Array; +} + +export interface SignInContinuationTokenParams extends SignInParamsBase { + continuationToken: string; + signInScenario: SignInScenarioType; + scopes: Array; +} diff --git a/lib/msal-browser/src/custom_auth/sign_in/interaction_client/result/SignInActionResult.ts b/lib/msal-browser/src/custom_auth/sign_in/interaction_client/result/SignInActionResult.ts new file mode 100644 index 0000000000..0446f6e85c --- /dev/null +++ b/lib/msal-browser/src/custom_auth/sign_in/interaction_client/result/SignInActionResult.ts @@ -0,0 +1,65 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthenticationResult } from "../../../../response/AuthenticationResult.js"; + +interface SignInActionResult { + type: string; + correlationId: string; +} + +interface SignInContinuationTokenResult extends SignInActionResult { + continuationToken: string; +} + +export interface SignInCompletedResult extends SignInActionResult { + type: typeof SIGN_IN_COMPLETED_RESULT_TYPE; + authenticationResult: AuthenticationResult; +} + +export interface SignInPasswordRequiredResult + extends SignInContinuationTokenResult { + type: typeof SIGN_IN_PASSWORD_REQUIRED_RESULT_TYPE; +} + +export interface SignInCodeSendResult extends SignInContinuationTokenResult { + type: typeof SIGN_IN_CODE_SEND_RESULT_TYPE; + challengeChannel: string; + challengeTargetLabel: string; + codeLength: number; + bindingMethod: string; +} + +export const SIGN_IN_CODE_SEND_RESULT_TYPE = "SignInCodeSendResult"; +export const SIGN_IN_PASSWORD_REQUIRED_RESULT_TYPE = + "SignInPasswordRequiredResult"; +export const SIGN_IN_COMPLETED_RESULT_TYPE = "SignInCompletedResult"; + +export function createSignInCompleteResult( + input: Omit +): SignInCompletedResult { + return { + type: SIGN_IN_COMPLETED_RESULT_TYPE, + ...input, + }; +} + +export function createSignInPasswordRequiredResult( + input: Omit +): SignInPasswordRequiredResult { + return { + type: SIGN_IN_PASSWORD_REQUIRED_RESULT_TYPE, + ...input, + }; +} + +export function createSignInCodeSendResult( + input: Omit +): SignInCodeSendResult { + return { + type: SIGN_IN_CODE_SEND_RESULT_TYPE, + ...input, + }; +} diff --git a/lib/msal-browser/src/custom_auth/sign_up/auth_flow/error_type/SignUpError.ts b/lib/msal-browser/src/custom_auth/sign_up/auth_flow/error_type/SignUpError.ts new file mode 100644 index 0000000000..01edb064fa --- /dev/null +++ b/lib/msal-browser/src/custom_auth/sign_up/auth_flow/error_type/SignUpError.ts @@ -0,0 +1,138 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthActionErrorBase } from "../../../core/auth_flow/AuthFlowErrorBase.js"; + +export class SignUpError extends AuthActionErrorBase { + /** + * Checks if the error is due to the user already exists. + * @returns {boolean} True if the error is due to the user already exists, false otherwise. + */ + isUserAlreadyExists(): boolean { + return this.isUserAlreadyExistsError(); + } + + /** + * Checks if the error is due to the username is invalid. + * @returns {boolean} True if the error is due to the user is invalid, false otherwise. + */ + isInvalidUsername(): boolean { + return this.isUserInvalidError(); + } + + /** + * Checks if the error is due to the password being invalid or incorrect. + * @returns {boolean} True if the error is due to the password being invalid, false otherwise. + */ + isInvalidPassword(): boolean { + return this.isInvalidNewPasswordError(); + } + + /** + * Checks if the error is due to the required attributes are missing. + * @returns {boolean} True if the error is due to the required attributes are missing, false otherwise. + */ + isMissingRequiredAttributes(): boolean { + return this.isAttributeRequiredError(); + } + + /** + * Checks if the error is due to the attributes validation failed. + * @returns {boolean} True if the error is due to the attributes validation failed, false otherwise. + */ + isAttributesValidationFailed(): boolean { + return this.isAttributeValidationFailedError(); + } + + /** + * Checks if the error is due to the provided challenge type is not supported. + * @returns {boolean} True if the error is due to the provided challenge type is not supported, false otherwise. + */ + isUnsupportedChallengeType(): boolean { + return this.isUnsupportedChallengeTypeError(); + } + + /** + * Check if client app supports the challenge type configured in Entra. + * @returns {boolean} True if "loginPopup" function is required to continue sthe operation. + */ + isRedirectRequired(): boolean { + return this.isRedirectError(); + } +} + +export class SignUpSubmitPasswordError extends AuthActionErrorBase { + /** + * Checks if the error is due to the password being invalid or incorrect. + * @returns {boolean} True if the error is due to the password being invalid, false otherwise. + */ + isInvalidPassword(): boolean { + return ( + this.isPasswordIncorrectError() || this.isInvalidNewPasswordError() + ); + } + + /** + * Check if client app supports the challenge type configured in Entra. + * @returns {boolean} True if "loginPopup" function is required to continue sthe operation. + */ + isRedirectRequired(): boolean { + return this.isRedirectError(); + } +} + +export class SignUpSubmitCodeError extends AuthActionErrorBase { + /** + * Checks if the provided code is invalid. + * @returns {boolean} True if the provided code is invalid, false otherwise. + */ + isInvalidCode(): boolean { + return this.isInvalidCodeError(); + } + + /** + * Check if client app supports the challenge type configured in Entra. + * @returns {boolean} True if "loginPopup" function is required to continue sthe operation. + */ + isRedirectRequired(): boolean { + return this.isRedirectError(); + } +} + +export class SignUpSubmitAttributesError extends AuthActionErrorBase { + /** + * Checks if the error is due to the required attributes are missing. + * @returns {boolean} True if the error is due to the required attributes are missing, false otherwise. + */ + isMissingRequiredAttributes(): boolean { + return this.isAttributeRequiredError(); + } + + /** + * Checks if the error is due to the attributes validation failed. + * @returns {boolean} True if the error is due to the attributes validation failed, false otherwise. + */ + isAttributesValidationFailed(): boolean { + return this.isAttributeValidationFailedError(); + } + + /** + * Check if client app supports the challenge type configured in Entra. + * @returns {boolean} True if "loginPopup" function is required to continue sthe operation. + */ + isRedirectRequired(): boolean { + return this.isRedirectError(); + } +} + +export class SignUpResendCodeError extends AuthActionErrorBase { + /** + * Check if client app supports the challenge type configured in Entra. + * @returns {boolean} True if "loginPopup" function is required to continue sthe operation. + */ + isRedirectRequired(): boolean { + return this.isRedirectError(); + } +} diff --git a/lib/msal-browser/src/custom_auth/sign_up/auth_flow/result/SignUpResendCodeResult.ts b/lib/msal-browser/src/custom_auth/sign_up/auth_flow/result/SignUpResendCodeResult.ts new file mode 100644 index 0000000000..4864403dba --- /dev/null +++ b/lib/msal-browser/src/custom_auth/sign_up/auth_flow/result/SignUpResendCodeResult.ts @@ -0,0 +1,70 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowResultBase } from "../../../core/auth_flow/AuthFlowResultBase.js"; +import { SignUpResendCodeError } from "../error_type/SignUpError.js"; +import type { SignUpCodeRequiredState } from "../state/SignUpCodeRequiredState.js"; +import { SignUpFailedState } from "../state/SignUpFailedState.js"; + +/* + * Result of resending code in a sign-up operation. + */ +export class SignUpResendCodeResult extends AuthFlowResultBase< + SignUpResendCodeResultState, + SignUpResendCodeError, + void +> { + /** + * Creates a new instance of SignUpResendCodeResult. + * @param state The state of the result. + */ + constructor(state: SignUpResendCodeResultState) { + super(state); + } + + /** + * Creates a new instance of SignUpResendCodeResult with an error. + * @param error The error that occurred. + * @returns {SignUpResendCodeResult} A new instance of SignUpResendCodeResult with the error set. + */ + static createWithError(error: unknown): SignUpResendCodeResult { + const result = new SignUpResendCodeResult(new SignUpFailedState()); + result.error = new SignUpResendCodeError( + SignUpResendCodeResult.createErrorData(error) + ); + + return result; + } + + /** + * Checks if the result is in a failed state. + */ + isFailed(): this is SignUpResendCodeResult & { state: SignUpFailedState } { + return this.state instanceof SignUpFailedState; + } + + /** + * Checks if the result is in a code required state. + */ + isCodeRequired(): this is SignUpResendCodeResult & { + state: SignUpCodeRequiredState; + } { + /* + * The instanceof operator couldn't be used here to check the state type since the circular dependency issue. + * So we are using the constructor name to check the state type. + */ + return this.state.constructor?.name === "SignUpCodeRequiredState"; + } +} + +/** + * The possible states for the SignUpResendCodeResult. + * This includes: + * - SignUpCodeRequiredState: The sign-up process requires a code. + * - SignUpFailedState: The sign-up process has failed. + */ +export type SignUpResendCodeResultState = + | SignUpCodeRequiredState + | SignUpFailedState; diff --git a/lib/msal-browser/src/custom_auth/sign_up/auth_flow/result/SignUpResult.ts b/lib/msal-browser/src/custom_auth/sign_up/auth_flow/result/SignUpResult.ts new file mode 100644 index 0000000000..98ba06cb0b --- /dev/null +++ b/lib/msal-browser/src/custom_auth/sign_up/auth_flow/result/SignUpResult.ts @@ -0,0 +1,88 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowResultBase } from "../../../core/auth_flow/AuthFlowResultBase.js"; +import { SignUpError } from "../error_type/SignUpError.js"; +import { SignUpAttributesRequiredState } from "../state/SignUpAttributesRequiredState.js"; +import { SignUpCodeRequiredState } from "../state/SignUpCodeRequiredState.js"; +import { SignUpFailedState } from "../state/SignUpFailedState.js"; +import { SignUpPasswordRequiredState } from "../state/SignUpPasswordRequiredState.js"; + +/* + * Result of a sign-up operation. + */ +export class SignUpResult extends AuthFlowResultBase< + SignUpResultState, + SignUpError, + void +> { + /** + * Creates a new instance of SignUpResult. + * @param state The state of the result. + */ + constructor(state: SignUpResultState) { + super(state); + } + + /** + * Creates a new instance of SignUpResult with an error. + * @param error The error that occurred. + * @returns {SignUpResult} A new instance of SignUpResult with the error set. + */ + static createWithError(error: unknown): SignUpResult { + const result = new SignUpResult(new SignUpFailedState()); + result.error = new SignUpError(SignUpResult.createErrorData(error)); + + return result; + } + + /** + * Checks if the result is in a failed state. + */ + isFailed(): this is SignUpResult & { state: SignUpFailedState } { + return this.state instanceof SignUpFailedState; + } + + /** + * Checks if the result is in a code required state. + */ + isCodeRequired(): this is SignUpResult & { + state: SignUpCodeRequiredState; + } { + return this.state instanceof SignUpCodeRequiredState; + } + + /** + * Checks if the result is in a password required state. + */ + isPasswordRequired(): this is SignUpResult & { + state: SignUpPasswordRequiredState; + } { + return this.state instanceof SignUpPasswordRequiredState; + } + + /** + * Checks if the result is in an attributes required state. + */ + isAttributesRequired(): this is SignUpResult & { + state: SignUpAttributesRequiredState; + } { + return this.state instanceof SignUpAttributesRequiredState; + } +} + +/** + * The possible states for the SignUpResult. + * This includes: + * - SignUpCodeRequiredState: The sign-up process requires a code. + * - SignUpPasswordRequiredState: The sign-up process requires a password. + * - SignUpAttributesRequiredState: The sign-up process requires additional attributes. + * - SignUpFailedState: The sign-up process has failed. + */ +export type SignUpResultState = + | SignUpCodeRequiredState + | SignUpPasswordRequiredState + | SignUpAttributesRequiredState + | SignUpFailedState; diff --git a/lib/msal-browser/src/custom_auth/sign_up/auth_flow/result/SignUpSubmitAttributesResult.ts b/lib/msal-browser/src/custom_auth/sign_up/auth_flow/result/SignUpSubmitAttributesResult.ts new file mode 100644 index 0000000000..b40322ac3a --- /dev/null +++ b/lib/msal-browser/src/custom_auth/sign_up/auth_flow/result/SignUpSubmitAttributesResult.ts @@ -0,0 +1,70 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowResultBase } from "../../../core/auth_flow/AuthFlowResultBase.js"; +import { SignUpSubmitAttributesError } from "../error_type/SignUpError.js"; +import { SignUpCompletedState } from "../state/SignUpCompletedState.js"; +import { SignUpFailedState } from "../state/SignUpFailedState.js"; + +/* + * Result of a sign-up operation that requires attributes. + */ +export class SignUpSubmitAttributesResult extends AuthFlowResultBase< + SignUpSubmitAttributesResultState, + SignUpSubmitAttributesError, + void +> { + /** + * Creates a new instance of SignUpSubmitAttributesResult. + * @param state The state of the result. + */ + constructor(state: SignUpSubmitAttributesResultState) { + super(state); + } + + /** + * Creates a new instance of SignUpSubmitAttributesResult with an error. + * @param error The error that occurred. + * @returns {SignUpSubmitAttributesResult} A new instance of SignUpSubmitAttributesResult with the error set. + */ + static createWithError(error: unknown): SignUpSubmitAttributesResult { + const result = new SignUpSubmitAttributesResult( + new SignUpFailedState() + ); + result.error = new SignUpSubmitAttributesError( + SignUpSubmitAttributesResult.createErrorData(error) + ); + + return result; + } + + /** + * Checks if the result is in a failed state. + */ + isFailed(): this is SignUpSubmitAttributesResult & { + state: SignUpFailedState; + } { + return this.state instanceof SignUpFailedState; + } + + /** + * Checks if the result is in a completed state. + */ + isCompleted(): this is SignUpSubmitAttributesResult & { + state: SignUpCompletedState; + } { + return this.state instanceof SignUpCompletedState; + } +} + +/** + * The possible states for the SignUpSubmitAttributesResult. + * This includes: + * - SignUpCompletedState: The sign-up process has completed successfully. + * - SignUpFailedState: The sign-up process has failed. + */ +export type SignUpSubmitAttributesResultState = + | SignUpCompletedState + | SignUpFailedState; diff --git a/lib/msal-browser/src/custom_auth/sign_up/auth_flow/result/SignUpSubmitCodeResult.ts b/lib/msal-browser/src/custom_auth/sign_up/auth_flow/result/SignUpSubmitCodeResult.ts new file mode 100644 index 0000000000..048cb84cbd --- /dev/null +++ b/lib/msal-browser/src/custom_auth/sign_up/auth_flow/result/SignUpSubmitCodeResult.ts @@ -0,0 +1,90 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowResultBase } from "../../../core/auth_flow/AuthFlowResultBase.js"; +import { SignUpSubmitCodeError } from "../error_type/SignUpError.js"; +import { SignUpAttributesRequiredState } from "../state/SignUpAttributesRequiredState.js"; +import { SignUpPasswordRequiredState } from "../state/SignUpPasswordRequiredState.js"; +import { SignUpCompletedState } from "../state/SignUpCompletedState.js"; +import { SignUpFailedState } from "../state/SignUpFailedState.js"; + +/* + * Result of a sign-up operation that requires a code. + */ +export class SignUpSubmitCodeResult extends AuthFlowResultBase< + SignUpSubmitCodeResultState, + SignUpSubmitCodeError, + void +> { + /** + * Creates a new instance of SignUpSubmitCodeResult. + * @param state The state of the result. + */ + constructor(state: SignUpSubmitCodeResultState) { + super(state); + } + + /** + * Creates a new instance of SignUpSubmitCodeResult with an error. + * @param error The error that occurred. + * @returns {SignUpSubmitCodeResult} A new instance of SignUpSubmitCodeResult with the error set. + */ + static createWithError(error: unknown): SignUpSubmitCodeResult { + const result = new SignUpSubmitCodeResult(new SignUpFailedState()); + result.error = new SignUpSubmitCodeError( + SignUpSubmitCodeResult.createErrorData(error) + ); + + return result; + } + + /** + * Checks if the result is in a failed state. + */ + isFailed(): this is SignUpSubmitCodeResult & { state: SignUpFailedState } { + return this.state instanceof SignUpFailedState; + } + + /** + * Checks if the result is in a password required state. + */ + isPasswordRequired(): this is SignUpSubmitCodeResult & { + state: SignUpPasswordRequiredState; + } { + return this.state instanceof SignUpPasswordRequiredState; + } + + /** + * Checks if the result is in an attributes required state. + */ + isAttributesRequired(): this is SignUpSubmitCodeResult & { + state: SignUpAttributesRequiredState; + } { + return this.state instanceof SignUpAttributesRequiredState; + } + + /** + * Checks if the result is in a completed state. + */ + isCompleted(): this is SignUpSubmitCodeResult & { + state: SignUpCompletedState; + } { + return this.state instanceof SignUpCompletedState; + } +} + +/** + * The possible states for the SignUpSubmitCodeResult. + * This includes: + * - SignUpPasswordRequiredState: The sign-up process requires a password. + * - SignUpAttributesRequiredState: The sign-up process requires additional attributes. + * - SignUpCompletedState: The sign-up process has completed successfully. + * - SignUpFailedState: The sign-up process has failed. + */ +export type SignUpSubmitCodeResultState = + | SignUpPasswordRequiredState + | SignUpAttributesRequiredState + | SignUpCompletedState + | SignUpFailedState; diff --git a/lib/msal-browser/src/custom_auth/sign_up/auth_flow/result/SignUpSubmitPasswordResult.ts b/lib/msal-browser/src/custom_auth/sign_up/auth_flow/result/SignUpSubmitPasswordResult.ts new file mode 100644 index 0000000000..eed94e482f --- /dev/null +++ b/lib/msal-browser/src/custom_auth/sign_up/auth_flow/result/SignUpSubmitPasswordResult.ts @@ -0,0 +1,80 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowResultBase } from "../../../core/auth_flow/AuthFlowResultBase.js"; +import { SignUpSubmitPasswordError } from "../error_type/SignUpError.js"; +import { SignUpAttributesRequiredState } from "../state/SignUpAttributesRequiredState.js"; +import { SignUpCompletedState } from "../state/SignUpCompletedState.js"; +import { SignUpFailedState } from "../state/SignUpFailedState.js"; + +/* + * Result of a sign-up operation that requires a password. + */ +export class SignUpSubmitPasswordResult extends AuthFlowResultBase< + SignUpSubmitPasswordResultState, + SignUpSubmitPasswordError, + void +> { + /** + * Creates a new instance of SignUpSubmitPasswordResult. + * @param state The state of the result. + */ + constructor(state: SignUpSubmitPasswordResultState) { + super(state); + } + + /** + * Creates a new instance of SignUpSubmitPasswordResult with an error. + * @param error The error that occurred. + * @returns {SignUpSubmitPasswordResult} A new instance of SignUpSubmitPasswordResult with the error set. + */ + static createWithError(error: unknown): SignUpSubmitPasswordResult { + const result = new SignUpSubmitPasswordResult(new SignUpFailedState()); + result.error = new SignUpSubmitPasswordError( + SignUpSubmitPasswordResult.createErrorData(error) + ); + + return result; + } + + /** + * Checks if the result is in a failed state. + */ + isFailed(): this is SignUpSubmitPasswordResult & { + state: SignUpFailedState; + } { + return this.state instanceof SignUpFailedState; + } + + /** + * Checks if the result is in an attributes required state. + */ + isAttributesRequired(): this is SignUpSubmitPasswordResult & { + state: SignUpAttributesRequiredState; + } { + return this.state instanceof SignUpAttributesRequiredState; + } + + /** + * Checks if the result is in a completed state. + */ + isCompleted(): this is SignUpSubmitPasswordResult & { + state: SignUpCompletedState; + } { + return this.state instanceof SignUpCompletedState; + } +} + +/** + * The possible states for the SignUpSubmitPasswordResult. + * This includes: + * - SignUpAttributesRequiredState: The sign-up process requires additional attributes. + * - SignUpCompletedState: The sign-up process has completed successfully. + * - SignUpFailedState: The sign-up process has failed. + */ +export type SignUpSubmitPasswordResultState = + | SignUpAttributesRequiredState + | SignUpCompletedState + | SignUpFailedState; diff --git a/lib/msal-browser/src/custom_auth/sign_up/auth_flow/state/SignUpAttributesRequiredState.ts b/lib/msal-browser/src/custom_auth/sign_up/auth_flow/state/SignUpAttributesRequiredState.ts new file mode 100644 index 0000000000..a084cf6c0e --- /dev/null +++ b/lib/msal-browser/src/custom_auth/sign_up/auth_flow/state/SignUpAttributesRequiredState.ts @@ -0,0 +1,115 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { InvalidArgumentError } from "../../../core/error/InvalidArgumentError.js"; +import { UnexpectedError } from "../../../core/error/UnexpectedError.js"; +import { UserAccountAttributes } from "../../../UserAccountAttributes.js"; +import { SIGN_UP_COMPLETED_RESULT_TYPE } from "../../interaction_client/result/SignUpActionResult.js"; +import { SignUpSubmitAttributesResult } from "../result/SignUpSubmitAttributesResult.js"; +import { SignUpState } from "./SignUpState.js"; +import { SignUpAttributesRequiredStateParameters } from "./SignUpStateParameters.js"; +import { UserAttribute } from "../../../core/network_client/custom_auth_api/types/ApiErrorResponseTypes.js"; +import { SignUpCompletedState } from "./SignUpCompletedState.js"; +import { SignInScenario } from "../../../sign_in/auth_flow/SignInScenario.js"; + +/* + * Sign-up attributes required state. + */ +export class SignUpAttributesRequiredState extends SignUpState { + /** + * Submits attributes to continue sign-up flow. + * This methods is used to submit required attributes. + * These attributes, built in or custom, were configured in the Microsoft Entra admin center by the tenant administrator. + * @param {UserAccountAttributes} attributes - The attributes to submit. + * @returns {Promise} The result of the operation. + */ + async submitAttributes( + attributes: UserAccountAttributes + ): Promise { + if (!attributes || Object.keys(attributes).length === 0) { + this.stateParameters.logger.error( + "Attributes are required for sign-up.", + this.stateParameters.correlationId + ); + + return Promise.resolve( + SignUpSubmitAttributesResult.createWithError( + new InvalidArgumentError( + "attributes", + this.stateParameters.correlationId + ) + ) + ); + } + + try { + this.stateParameters.logger.verbose( + "Submitting attributes for sign-up.", + this.stateParameters.correlationId + ); + + const result = + await this.stateParameters.signUpClient.submitAttributes({ + clientId: this.stateParameters.config.auth.clientId, + correlationId: this.stateParameters.correlationId, + challengeType: + this.stateParameters.config.customAuth.challengeTypes ?? + [], + continuationToken: + this.stateParameters.continuationToken ?? "", + attributes: attributes, + username: this.stateParameters.username, + }); + + this.stateParameters.logger.verbose( + "Attributes submitted for sign-up.", + this.stateParameters.correlationId + ); + + if (result.type === SIGN_UP_COMPLETED_RESULT_TYPE) { + // Sign-up completed + this.stateParameters.logger.verbose( + "Sign-up completed.", + this.stateParameters.correlationId + ); + + return new SignUpSubmitAttributesResult( + new SignUpCompletedState({ + correlationId: result.correlationId, + continuationToken: result.continuationToken, + logger: this.stateParameters.logger, + config: this.stateParameters.config, + signInClient: this.stateParameters.signInClient, + cacheClient: this.stateParameters.cacheClient, + username: this.stateParameters.username, + signInScenario: SignInScenario.SignInAfterSignUp, + }) + ); + } + + return SignUpSubmitAttributesResult.createWithError( + new UnexpectedError( + "Unknown sign-up result type.", + this.stateParameters.correlationId + ) + ); + } catch (error) { + this.stateParameters.logger.errorPii( + `Failed to submit attributes for sign up. Error: ${error}.`, + this.stateParameters.correlationId + ); + + return SignUpSubmitAttributesResult.createWithError(error); + } + } + + /** + * Gets the required attributes for sign-up. + * @returns {UserAttribute[]} The required attributes for sign-up. + */ + getRequiredAttributes(): UserAttribute[] { + return this.stateParameters.requiredAttributes; + } +} diff --git a/lib/msal-browser/src/custom_auth/sign_up/auth_flow/state/SignUpCodeRequiredState.ts b/lib/msal-browser/src/custom_auth/sign_up/auth_flow/state/SignUpCodeRequiredState.ts new file mode 100644 index 0000000000..a20e04a798 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/sign_up/auth_flow/state/SignUpCodeRequiredState.ts @@ -0,0 +1,196 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { UnexpectedError } from "../../../core/error/UnexpectedError.js"; +import { + SIGN_UP_ATTRIBUTES_REQUIRED_RESULT_TYPE, + SIGN_UP_COMPLETED_RESULT_TYPE, + SIGN_UP_PASSWORD_REQUIRED_RESULT_TYPE, +} from "../../interaction_client/result/SignUpActionResult.js"; +import { SignUpResendCodeResult } from "../result/SignUpResendCodeResult.js"; +import { SignUpSubmitCodeResult } from "../result/SignUpSubmitCodeResult.js"; +import { SignUpState } from "./SignUpState.js"; +import { SignUpCodeRequiredStateParameters } from "./SignUpStateParameters.js"; +import { SignUpPasswordRequiredState } from "./SignUpPasswordRequiredState.js"; +import { SignUpAttributesRequiredState } from "./SignUpAttributesRequiredState.js"; +import { SignUpCompletedState } from "./SignUpCompletedState.js"; +import { SignInScenario } from "../../../sign_in/auth_flow/SignInScenario.js"; + +/* + * Sign-up code required state. + */ +export class SignUpCodeRequiredState extends SignUpState { + /** + * Submit one-time passcode to continue sign-up flow. + * @param {string} code - The code to submit. + * @returns {Promise} The result of the operation. + */ + async submitCode(code: string): Promise { + try { + this.ensureCodeIsValid(code, this.stateParameters.codeLength); + + this.stateParameters.logger.verbose( + "Submitting code for sign-up.", + this.stateParameters.correlationId + ); + + const result = await this.stateParameters.signUpClient.submitCode({ + clientId: this.stateParameters.config.auth.clientId, + correlationId: this.stateParameters.correlationId, + challengeType: + this.stateParameters.config.customAuth.challengeTypes ?? [], + continuationToken: this.stateParameters.continuationToken ?? "", + code: code, + username: this.stateParameters.username, + }); + + this.stateParameters.logger.verbose( + "Code submitted for sign-up.", + this.stateParameters.correlationId + ); + + if (result.type === SIGN_UP_PASSWORD_REQUIRED_RESULT_TYPE) { + // Password required + this.stateParameters.logger.verbose( + "Password required for sign-up.", + this.stateParameters.correlationId + ); + + return new SignUpSubmitCodeResult( + new SignUpPasswordRequiredState({ + correlationId: result.correlationId, + continuationToken: result.continuationToken, + logger: this.stateParameters.logger, + config: this.stateParameters.config, + signInClient: this.stateParameters.signInClient, + signUpClient: this.stateParameters.signUpClient, + cacheClient: this.stateParameters.cacheClient, + username: this.stateParameters.username, + }) + ); + } else if ( + result.type === SIGN_UP_ATTRIBUTES_REQUIRED_RESULT_TYPE + ) { + // Attributes required + this.stateParameters.logger.verbose( + "Attributes required for sign-up.", + this.stateParameters.correlationId + ); + + return new SignUpSubmitCodeResult( + new SignUpAttributesRequiredState({ + correlationId: result.correlationId, + continuationToken: result.continuationToken, + logger: this.stateParameters.logger, + config: this.stateParameters.config, + signInClient: this.stateParameters.signInClient, + signUpClient: this.stateParameters.signUpClient, + cacheClient: this.stateParameters.cacheClient, + username: this.stateParameters.username, + requiredAttributes: result.requiredAttributes, + }) + ); + } else if (result.type === SIGN_UP_COMPLETED_RESULT_TYPE) { + // Sign-up completed + this.stateParameters.logger.verbose( + "Sign-up completed.", + this.stateParameters.correlationId + ); + + return new SignUpSubmitCodeResult( + new SignUpCompletedState({ + correlationId: result.correlationId, + continuationToken: result.continuationToken, + logger: this.stateParameters.logger, + config: this.stateParameters.config, + signInClient: this.stateParameters.signInClient, + cacheClient: this.stateParameters.cacheClient, + username: this.stateParameters.username, + signInScenario: SignInScenario.SignInAfterSignUp, + }) + ); + } + + return SignUpSubmitCodeResult.createWithError( + new UnexpectedError( + "Unknown sign-up result type.", + this.stateParameters.correlationId + ) + ); + } catch (error) { + this.stateParameters.logger.errorPii( + `Failed to submit code for sign up. Error: ${error}.`, + this.stateParameters.correlationId + ); + + return SignUpSubmitCodeResult.createWithError(error); + } + } + + /** + * Resends the another one-time passcode for sign-up flow if the previous one hasn't been verified. + * @returns {Promise} The result of the operation. + */ + async resendCode(): Promise { + try { + this.stateParameters.logger.verbose( + "Resending code for sign-up.", + this.stateParameters.correlationId + ); + + const result = await this.stateParameters.signUpClient.resendCode({ + clientId: this.stateParameters.config.auth.clientId, + challengeType: + this.stateParameters.config.customAuth.challengeTypes ?? [], + username: this.stateParameters.username, + correlationId: this.stateParameters.correlationId, + continuationToken: this.stateParameters.continuationToken ?? "", + }); + + this.stateParameters.logger.verbose( + "Code resent for sign-up.", + this.stateParameters.correlationId + ); + + return new SignUpResendCodeResult( + new SignUpCodeRequiredState({ + correlationId: result.correlationId, + continuationToken: result.continuationToken, + logger: this.stateParameters.logger, + config: this.stateParameters.config, + signInClient: this.stateParameters.signInClient, + signUpClient: this.stateParameters.signUpClient, + cacheClient: this.stateParameters.cacheClient, + username: this.stateParameters.username, + codeLength: result.codeLength, + codeResendInterval: result.interval, + }) + ); + } catch (error) { + this.stateParameters.logger.errorPii( + `Failed to resend code for sign up. Error: ${error}.`, + this.stateParameters.correlationId + ); + + return SignUpResendCodeResult.createWithError(error); + } + } + + /** + * Gets the sent code length. + * @returns {number} The length of the code. + */ + getCodeLength(): number { + return this.stateParameters.codeLength; + } + + /** + * Gets the interval in seconds for the code to be resent. + * @returns {number} The interval in seconds for the code to be resent. + */ + getCodeResendInterval(): number { + return this.stateParameters.codeResendInterval; + } +} diff --git a/lib/msal-browser/src/custom_auth/sign_up/auth_flow/state/SignUpCompletedState.ts b/lib/msal-browser/src/custom_auth/sign_up/auth_flow/state/SignUpCompletedState.ts new file mode 100644 index 0000000000..4526ae5724 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/sign_up/auth_flow/state/SignUpCompletedState.ts @@ -0,0 +1,11 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { SignInContinuationState } from "../../../sign_in/auth_flow/state/SignInContinuationState.js"; + +/** + * Represents the state of a sign-up operation that has been completed scuccessfully. + */ +export class SignUpCompletedState extends SignInContinuationState {} diff --git a/lib/msal-browser/src/custom_auth/sign_up/auth_flow/state/SignUpFailedState.ts b/lib/msal-browser/src/custom_auth/sign_up/auth_flow/state/SignUpFailedState.ts new file mode 100644 index 0000000000..c3b631308a --- /dev/null +++ b/lib/msal-browser/src/custom_auth/sign_up/auth_flow/state/SignUpFailedState.ts @@ -0,0 +1,11 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowStateBase } from "../../../core/auth_flow/AuthFlowState.js"; + +/** + * Represents the state of a sign-up operation that has failed. + */ +export class SignUpFailedState extends AuthFlowStateBase {} diff --git a/lib/msal-browser/src/custom_auth/sign_up/auth_flow/state/SignUpPasswordRequiredState.ts b/lib/msal-browser/src/custom_auth/sign_up/auth_flow/state/SignUpPasswordRequiredState.ts new file mode 100644 index 0000000000..290d3c86e0 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/sign_up/auth_flow/state/SignUpPasswordRequiredState.ts @@ -0,0 +1,112 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { UnexpectedError } from "../../../core/error/UnexpectedError.js"; +import { SignInScenario } from "../../../sign_in/auth_flow/SignInScenario.js"; +import { + SIGN_UP_ATTRIBUTES_REQUIRED_RESULT_TYPE, + SIGN_UP_COMPLETED_RESULT_TYPE, +} from "../../interaction_client/result/SignUpActionResult.js"; +import { SignUpSubmitPasswordResult } from "../result/SignUpSubmitPasswordResult.js"; +import { SignUpAttributesRequiredState } from "./SignUpAttributesRequiredState.js"; +import { SignUpCompletedState } from "./SignUpCompletedState.js"; +import { SignUpState } from "./SignUpState.js"; +import { SignUpPasswordRequiredStateParameters } from "./SignUpStateParameters.js"; + +/* + * Sign-up password required state. + */ +export class SignUpPasswordRequiredState extends SignUpState { + /** + * Submits a password for sign-up. + * @param {string} password - The password to submit. + * @returns {Promise} The result of the operation. + */ + async submitPassword( + password: string + ): Promise { + try { + this.ensurePasswordIsNotEmpty(password); + + this.stateParameters.logger.verbose( + "Submitting password for sign-up.", + this.stateParameters.correlationId + ); + + const result = + await this.stateParameters.signUpClient.submitPassword({ + clientId: this.stateParameters.config.auth.clientId, + correlationId: this.stateParameters.correlationId, + challengeType: + this.stateParameters.config.customAuth.challengeTypes ?? + [], + continuationToken: + this.stateParameters.continuationToken ?? "", + password: password, + username: this.stateParameters.username, + }); + + this.stateParameters.logger.verbose( + "Password submitted for sign-up.", + this.stateParameters.correlationId + ); + + if (result.type === SIGN_UP_ATTRIBUTES_REQUIRED_RESULT_TYPE) { + // Attributes required + this.stateParameters.logger.verbose( + "Attributes required for sign-up.", + this.stateParameters.correlationId + ); + + return new SignUpSubmitPasswordResult( + new SignUpAttributesRequiredState({ + correlationId: result.correlationId, + continuationToken: result.continuationToken, + logger: this.stateParameters.logger, + config: this.stateParameters.config, + signInClient: this.stateParameters.signInClient, + signUpClient: this.stateParameters.signUpClient, + cacheClient: this.stateParameters.cacheClient, + username: this.stateParameters.username, + requiredAttributes: result.requiredAttributes, + }) + ); + } else if (result.type === SIGN_UP_COMPLETED_RESULT_TYPE) { + // Sign-up completed + this.stateParameters.logger.verbose( + "Sign-up completed.", + this.stateParameters.correlationId + ); + + return new SignUpSubmitPasswordResult( + new SignUpCompletedState({ + correlationId: result.correlationId, + continuationToken: result.continuationToken, + logger: this.stateParameters.logger, + config: this.stateParameters.config, + signInClient: this.stateParameters.signInClient, + cacheClient: this.stateParameters.cacheClient, + username: this.stateParameters.username, + signInScenario: SignInScenario.SignInAfterSignUp, + }) + ); + } + + return SignUpSubmitPasswordResult.createWithError( + new UnexpectedError( + "Unknown sign-up result type.", + this.stateParameters.correlationId + ) + ); + } catch (error) { + this.stateParameters.logger.errorPii( + `Failed to submit password for sign up. Error: ${error}.`, + this.stateParameters.correlationId + ); + + return SignUpSubmitPasswordResult.createWithError(error); + } + } +} diff --git a/lib/msal-browser/src/custom_auth/sign_up/auth_flow/state/SignUpState.ts b/lib/msal-browser/src/custom_auth/sign_up/auth_flow/state/SignUpState.ts new file mode 100644 index 0000000000..c130fbb585 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/sign_up/auth_flow/state/SignUpState.ts @@ -0,0 +1,34 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { AuthFlowActionRequiredStateBase } from "../../../core/auth_flow/AuthFlowState.js"; +import { ensureArgumentIsNotEmptyString } from "../../../core/utils/ArgumentValidator.js"; +import { SignUpStateParameters } from "./SignUpStateParameters.js"; + +/* + * Base state handler for sign-up flow. + */ +export abstract class SignUpState< + TParameters extends SignUpStateParameters +> extends AuthFlowActionRequiredStateBase { + /* + * Creates a new SignUpState. + * @param stateParameters - The state parameters for sign-up. + */ + constructor(stateParameters: TParameters) { + super(stateParameters); + + ensureArgumentIsNotEmptyString( + "username", + stateParameters.username, + stateParameters.correlationId + ); + ensureArgumentIsNotEmptyString( + "continuationToken", + stateParameters.continuationToken, + stateParameters.correlationId + ); + } +} diff --git a/lib/msal-browser/src/custom_auth/sign_up/auth_flow/state/SignUpStateParameters.ts b/lib/msal-browser/src/custom_auth/sign_up/auth_flow/state/SignUpStateParameters.ts new file mode 100644 index 0000000000..c1df011282 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/sign_up/auth_flow/state/SignUpStateParameters.ts @@ -0,0 +1,31 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { SignUpClient } from "../../interaction_client/SignUpClient.js"; +import { SignInClient } from "../../../sign_in/interaction_client/SignInClient.js"; +import { CustomAuthSilentCacheClient } from "../../../get_account/interaction_client/CustomAuthSilentCacheClient.js"; +import { AuthFlowActionRequiredStateParameters } from "../../../core/auth_flow/AuthFlowState.js"; +import { UserAttribute } from "../../../core/network_client/custom_auth_api/types/ApiErrorResponseTypes.js"; + +export interface SignUpStateParameters + extends AuthFlowActionRequiredStateParameters { + username: string; + signUpClient: SignUpClient; + signInClient: SignInClient; + cacheClient: CustomAuthSilentCacheClient; +} + +export type SignUpPasswordRequiredStateParameters = SignUpStateParameters; + +export interface SignUpCodeRequiredStateParameters + extends SignUpStateParameters { + codeLength: number; + codeResendInterval: number; +} + +export interface SignUpAttributesRequiredStateParameters + extends SignUpStateParameters { + requiredAttributes: Array; +} diff --git a/lib/msal-browser/src/custom_auth/sign_up/interaction_client/SignUpClient.ts b/lib/msal-browser/src/custom_auth/sign_up/interaction_client/SignUpClient.ts new file mode 100644 index 0000000000..4ab31d039c --- /dev/null +++ b/lib/msal-browser/src/custom_auth/sign_up/interaction_client/SignUpClient.ts @@ -0,0 +1,496 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthApiError } from "../../core/error/CustomAuthApiError.js"; +import * as CustomAuthApiErrorCode from "../../core/network_client/custom_auth_api/types/ApiErrorCodes.js"; +import { UnexpectedError } from "../../core/error/UnexpectedError.js"; +import { CustomAuthInteractionClientBase } from "../../core/interaction_client/CustomAuthInteractionClientBase.js"; +import * as PublicApiId from "../../core/telemetry/PublicApiId.js"; +import { + ChallengeType, + DefaultCustomAuthApiCodeLength, + DefaultCustomAuthApiCodeResendIntervalInSec, +} from "../../CustomAuthConstants.js"; +import { + SignUpParamsBase, + SignUpResendCodeParams, + SignUpStartParams, + SignUpSubmitCodeParams, + SignUpSubmitPasswordParams, + SignUpSubmitUserAttributesParams, +} from "./parameter/SignUpParams.js"; +import { + createSignUpAttributesRequiredResult, + createSignUpCodeRequiredResult, + createSignUpCompletedResult, + createSignUpPasswordRequiredResult, + SIGN_UP_ATTRIBUTES_REQUIRED_RESULT_TYPE, + SIGN_UP_CODE_REQUIRED_RESULT_TYPE, + SIGN_UP_PASSWORD_REQUIRED_RESULT_TYPE, + SignUpAttributesRequiredResult, + SignUpCodeRequiredResult, + SignUpCompletedResult, + SignUpPasswordRequiredResult, +} from "./result/SignUpActionResult.js"; +import { + SignUpChallengeRequest, + SignUpContinueWithAttributesRequest, + SignUpContinueWithOobRequest, + SignUpContinueWithPasswordRequest, + SignUpStartRequest, +} from "../../core/network_client/custom_auth_api/types/ApiRequestTypes.js"; +import { SignUpContinueResponse } from "../../core/network_client/custom_auth_api/types/ApiResponseTypes.js"; +import { ServerTelemetryManager } from "@azure/msal-common/browser"; + +export class SignUpClient extends CustomAuthInteractionClientBase { + /** + * Starts the sign up flow. + * @param parameters The parameters for the sign up start action. + * @returns The result of the sign up start action. + */ + async start( + parameters: SignUpStartParams + ): Promise { + const apiId = !parameters.password + ? PublicApiId.SIGN_UP_START + : PublicApiId.SIGN_UP_WITH_PASSWORD_START; + const telemetryManager = this.initializeServerTelemetryManager(apiId); + + const startRequest: SignUpStartRequest = { + username: parameters.username, + password: parameters.password, + attributes: parameters.attributes, + challenge_type: this.getChallengeTypes(parameters.challengeType), + telemetryManager, + correlationId: parameters.correlationId, + }; + + this.logger.verbose( + "Calling start endpoint for sign up.", + parameters.correlationId + ); + + const startResponse = await this.customAuthApiClient.signUpApi.start( + startRequest + ); + + this.logger.verbose( + "Start endpoint called for sign up.", + parameters.correlationId + ); + + const challengeRequest: SignUpChallengeRequest = { + continuation_token: startResponse.continuation_token ?? "", + challenge_type: this.getChallengeTypes(parameters.challengeType), + telemetryManager, + correlationId: startResponse.correlation_id, + }; + + return this.performChallengeRequest(challengeRequest); + } + + /** + * Submits the code for the sign up flow. + * @param parameters The parameters for the sign up submit code action. + * @returns The result of the sign up submit code action. + */ + async submitCode( + parameters: SignUpSubmitCodeParams + ): Promise< + | SignUpCompletedResult + | SignUpPasswordRequiredResult + | SignUpAttributesRequiredResult + > { + const apiId = PublicApiId.SIGN_UP_SUBMIT_CODE; + const telemetryManager = this.initializeServerTelemetryManager(apiId); + + const requestSubmitCode: SignUpContinueWithOobRequest = { + continuation_token: parameters.continuationToken, + oob: parameters.code, + telemetryManager, + correlationId: parameters.correlationId, + }; + + const result = await this.performContinueRequest( + "SignUpClient.submitCode", + parameters, + telemetryManager, + () => + this.customAuthApiClient.signUpApi.continueWithCode( + requestSubmitCode + ), + parameters.correlationId + ); + + if (result.type === SIGN_UP_CODE_REQUIRED_RESULT_TYPE) { + throw new CustomAuthApiError( + CustomAuthApiErrorCode.UNSUPPORTED_CHALLENGE_TYPE, + "The challenge type 'oob' is invalid after submtting code for sign up.", + parameters.correlationId + ); + } + + return result; + } + + /** + * Submits the password for the sign up flow. + * @param parameter The parameters for the sign up submit password action. + * @returns The result of the sign up submit password action. + */ + async submitPassword( + parameter: SignUpSubmitPasswordParams + ): Promise< + | SignUpCompletedResult + | SignUpCodeRequiredResult + | SignUpAttributesRequiredResult + > { + const apiId = PublicApiId.SIGN_UP_SUBMIT_PASSWORD; + const telemetryManager = this.initializeServerTelemetryManager(apiId); + + const requestSubmitPwd: SignUpContinueWithPasswordRequest = { + continuation_token: parameter.continuationToken, + password: parameter.password, + telemetryManager, + correlationId: parameter.correlationId, + }; + + const result = await this.performContinueRequest( + "SignUpClient.submitPassword", + parameter, + telemetryManager, + () => + this.customAuthApiClient.signUpApi.continueWithPassword( + requestSubmitPwd + ), + parameter.correlationId + ); + + if (result.type === SIGN_UP_PASSWORD_REQUIRED_RESULT_TYPE) { + throw new CustomAuthApiError( + CustomAuthApiErrorCode.UNSUPPORTED_CHALLENGE_TYPE, + "The challenge type 'password' is invalid after submtting password for sign up.", + parameter.correlationId + ); + } + + return result; + } + + /** + * Submits the attributes for the sign up flow. + * @param parameter The parameters for the sign up submit attributes action. + * @returns The result of the sign up submit attributes action. + */ + async submitAttributes( + parameter: SignUpSubmitUserAttributesParams + ): Promise< + | SignUpCompletedResult + | SignUpPasswordRequiredResult + | SignUpCodeRequiredResult + > { + const apiId = PublicApiId.SIGN_UP_SUBMIT_ATTRIBUTES; + const telemetryManager = this.initializeServerTelemetryManager(apiId); + const reqWithAttr: SignUpContinueWithAttributesRequest = { + continuation_token: parameter.continuationToken, + attributes: parameter.attributes, + telemetryManager, + correlationId: parameter.correlationId, + }; + + const result = await this.performContinueRequest( + "SignUpClient.submitAttributes", + parameter, + telemetryManager, + () => + this.customAuthApiClient.signUpApi.continueWithAttributes( + reqWithAttr + ), + parameter.correlationId + ); + + if (result.type === SIGN_UP_ATTRIBUTES_REQUIRED_RESULT_TYPE) { + throw new CustomAuthApiError( + CustomAuthApiErrorCode.ATTRIBUTES_REQUIRED, + "User attributes required", + parameter.correlationId, + [], + "", + result.requiredAttributes, + result.continuationToken + ); + } + + return result; + } + + /** + * Resends the code for the sign up flow. + * @param parameters The parameters for the sign up resend code action. + * @returns The result of the sign up resend code action. + */ + async resendCode( + parameters: SignUpResendCodeParams + ): Promise { + const apiId = PublicApiId.SIGN_UP_RESEND_CODE; + const telemetryManager = this.initializeServerTelemetryManager(apiId); + + const challengeRequest: SignUpChallengeRequest = { + continuation_token: parameters.continuationToken ?? "", + challenge_type: this.getChallengeTypes(parameters.challengeType), + telemetryManager, + correlationId: parameters.correlationId, + }; + + const result = await this.performChallengeRequest(challengeRequest); + + if (result.type === SIGN_UP_PASSWORD_REQUIRED_RESULT_TYPE) { + throw new CustomAuthApiError( + CustomAuthApiErrorCode.UNSUPPORTED_CHALLENGE_TYPE, + "The challenge type 'password' is invalid after resending code for sign up.", + parameters.correlationId + ); + } + + return result; + } + + private async performChallengeRequest( + request: SignUpChallengeRequest + ): Promise { + this.logger.verbose( + "Calling challenge endpoint for sign up.", + request.correlationId + ); + + const challengeResponse = + await this.customAuthApiClient.signUpApi.requestChallenge(request); + + this.logger.verbose( + "Challenge endpoint called for sign up.", + request.correlationId + ); + + if (challengeResponse.challenge_type === ChallengeType.OOB) { + // Code is required + this.logger.verbose( + "Challenge type is oob for sign up.", + request.correlationId + ); + + return createSignUpCodeRequiredResult({ + correlationId: challengeResponse.correlation_id, + continuationToken: challengeResponse.continuation_token ?? "", + challengeChannel: challengeResponse.challenge_channel ?? "", + challengeTargetLabel: + challengeResponse.challenge_target_label ?? "", + codeLength: + challengeResponse.code_length ?? + DefaultCustomAuthApiCodeLength, + interval: + challengeResponse.interval ?? + DefaultCustomAuthApiCodeResendIntervalInSec, + bindingMethod: challengeResponse.binding_method ?? "", + }); + } + + if (challengeResponse.challenge_type === ChallengeType.PASSWORD) { + // Password is required + this.logger.verbose( + "Challenge type is password for sign up.", + request.correlationId + ); + + return createSignUpPasswordRequiredResult({ + correlationId: challengeResponse.correlation_id, + continuationToken: challengeResponse.continuation_token ?? "", + }); + } + + this.logger.error( + `Unsupported challenge type '${challengeResponse.challenge_type}' for sign up.`, + request.correlationId + ); + + throw new CustomAuthApiError( + CustomAuthApiErrorCode.UNSUPPORTED_CHALLENGE_TYPE, + `Unsupported challenge type '${challengeResponse.challenge_type}'.`, + request.correlationId + ); + } + + private async performContinueRequest( + callerName: string, + requestParams: SignUpParamsBase, + telemetryManager: ServerTelemetryManager, + responseGetter: () => Promise, + requestCorrelationId: string + ): Promise< + | SignUpCompletedResult + | SignUpPasswordRequiredResult + | SignUpCodeRequiredResult + | SignUpAttributesRequiredResult + > { + this.logger.verbose( + `${callerName} is calling continue endpoint for sign up.`, + requestCorrelationId + ); + + try { + const response = await responseGetter(); + + this.logger.verbose( + `Continue endpoint called by ${callerName} for sign up.`, + requestCorrelationId + ); + + return createSignUpCompletedResult({ + correlationId: requestCorrelationId, + continuationToken: response.continuation_token ?? "", + }); + } catch (error) { + if (error instanceof CustomAuthApiError) { + return this.handleContinueResponseError( + error, + error.correlationId ?? requestCorrelationId, + requestParams, + telemetryManager + ); + } else { + this.logger.errorPii( + `${callerName} is failed to call continue endpoint for sign up. Error: ${error}`, + requestCorrelationId + ); + + throw new UnexpectedError(error, requestCorrelationId); + } + } + } + + private async handleContinueResponseError( + responseError: CustomAuthApiError, + correlationId: string, + requestParams: SignUpParamsBase, + telemetryManager: ServerTelemetryManager + ): Promise< + | SignUpPasswordRequiredResult + | SignUpCodeRequiredResult + | SignUpAttributesRequiredResult + > { + if ( + responseError.error === + CustomAuthApiErrorCode.CREDENTIAL_REQUIRED && + !!responseError.errorCodes && + responseError.errorCodes.includes(55103) + ) { + // Credential is required + this.logger.verbose( + "The credential is required in the sign up flow.", + correlationId + ); + + const continuationToken = + this.readContinuationTokenFromResponeError(responseError); + + // Call the challenge endpoint to ensure the password challenge type is supported. + const challengeRequest: SignUpChallengeRequest = { + continuation_token: continuationToken, + challenge_type: this.getChallengeTypes( + requestParams.challengeType + ), + telemetryManager, + correlationId, + }; + + const challengeResult = await this.performChallengeRequest( + challengeRequest + ); + + if ( + challengeResult.type === SIGN_UP_PASSWORD_REQUIRED_RESULT_TYPE + ) { + return createSignUpPasswordRequiredResult({ + correlationId: correlationId, + continuationToken: challengeResult.continuationToken, + }); + } + + if (challengeResult.type === SIGN_UP_CODE_REQUIRED_RESULT_TYPE) { + return createSignUpCodeRequiredResult({ + correlationId: challengeResult.correlationId, + continuationToken: challengeResult.continuationToken, + challengeChannel: challengeResult.challengeChannel, + challengeTargetLabel: challengeResult.challengeTargetLabel, + codeLength: challengeResult.codeLength, + interval: challengeResult.interval, + bindingMethod: challengeResult.bindingMethod, + }); + } + + throw new CustomAuthApiError( + CustomAuthApiErrorCode.UNSUPPORTED_CHALLENGE_TYPE, + "The challenge type is not supported.", + correlationId + ); + } + + if (this.isAttributesRequiredError(responseError, correlationId)) { + // Attributes are required + this.logger.verbose( + "Attributes are required in the sign up flow.", + correlationId + ); + + const continuationToken = + this.readContinuationTokenFromResponeError(responseError); + + return createSignUpAttributesRequiredResult({ + correlationId: correlationId, + continuationToken: continuationToken, + requiredAttributes: responseError.attributes ?? [], + }); + } + + throw responseError; + } + + private isAttributesRequiredError( + responseError: CustomAuthApiError, + correlationId: string + ): boolean { + if ( + responseError.error === CustomAuthApiErrorCode.ATTRIBUTES_REQUIRED + ) { + if ( + !responseError.attributes || + responseError.attributes.length === 0 + ) { + throw new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_RESPONSE_BODY, + "Attributes are required but required_attributes field is missing in the response body.", + correlationId + ); + } + + return true; + } + + return false; + } + + private readContinuationTokenFromResponeError( + responseError: CustomAuthApiError + ): string { + if (!responseError.continuationToken) { + throw new CustomAuthApiError( + CustomAuthApiErrorCode.CONTINUATION_TOKEN_MISSING, + "Continuation token is missing in the response body", + responseError.correlationId + ); + } + + return responseError.continuationToken; + } +} diff --git a/lib/msal-browser/src/custom_auth/sign_up/interaction_client/parameter/SignUpParams.ts b/lib/msal-browser/src/custom_auth/sign_up/interaction_client/parameter/SignUpParams.ts new file mode 100644 index 0000000000..e34643f0a9 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/sign_up/interaction_client/parameter/SignUpParams.ts @@ -0,0 +1,36 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +export interface SignUpParamsBase { + clientId: string; + challengeType: Array; + username: string; + correlationId: string; +} + +export interface SignUpStartParams extends SignUpParamsBase { + password?: string; + attributes?: Record; +} + +export interface SignUpResendCodeParams extends SignUpParamsBase { + continuationToken: string; +} + +export interface SignUpContinueParams extends SignUpParamsBase { + continuationToken: string; +} + +export interface SignUpSubmitCodeParams extends SignUpContinueParams { + code: string; +} + +export interface SignUpSubmitPasswordParams extends SignUpContinueParams { + password: string; +} + +export interface SignUpSubmitUserAttributesParams extends SignUpContinueParams { + attributes: Record; +} diff --git a/lib/msal-browser/src/custom_auth/sign_up/interaction_client/result/SignUpActionResult.ts b/lib/msal-browser/src/custom_auth/sign_up/interaction_client/result/SignUpActionResult.ts new file mode 100644 index 0000000000..536537f192 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/sign_up/interaction_client/result/SignUpActionResult.ts @@ -0,0 +1,77 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { UserAttribute } from "../../../core/network_client/custom_auth_api/types/ApiErrorResponseTypes.js"; + +interface SignUpActionResult { + type: string; + correlationId: string; + continuationToken: string; +} + +export interface SignUpCompletedResult extends SignUpActionResult { + type: typeof SIGN_UP_COMPLETED_RESULT_TYPE; +} + +export interface SignUpPasswordRequiredResult extends SignUpActionResult { + type: typeof SIGN_UP_PASSWORD_REQUIRED_RESULT_TYPE; +} + +export interface SignUpCodeRequiredResult extends SignUpActionResult { + type: typeof SIGN_UP_CODE_REQUIRED_RESULT_TYPE; + challengeChannel: string; + challengeTargetLabel: string; + codeLength: number; + interval: number; + bindingMethod: string; +} + +export interface SignUpAttributesRequiredResult extends SignUpActionResult { + type: typeof SIGN_UP_ATTRIBUTES_REQUIRED_RESULT_TYPE; + requiredAttributes: Array; +} + +export const SIGN_UP_COMPLETED_RESULT_TYPE = "SignUpCompletedResult"; +export const SIGN_UP_PASSWORD_REQUIRED_RESULT_TYPE = + "SignUpPasswordRequiredResult"; +export const SIGN_UP_CODE_REQUIRED_RESULT_TYPE = "SignUpCodeRequiredResult"; +export const SIGN_UP_ATTRIBUTES_REQUIRED_RESULT_TYPE = + "SignUpAttributesRequiredResult"; + +export function createSignUpCompletedResult( + input: Omit +): SignUpCompletedResult { + return { + type: SIGN_UP_COMPLETED_RESULT_TYPE, + ...input, + }; +} + +export function createSignUpPasswordRequiredResult( + input: Omit +): SignUpPasswordRequiredResult { + return { + type: SIGN_UP_PASSWORD_REQUIRED_RESULT_TYPE, + ...input, + }; +} + +export function createSignUpCodeRequiredResult( + input: Omit +): SignUpCodeRequiredResult { + return { + type: SIGN_UP_CODE_REQUIRED_RESULT_TYPE, + ...input, + }; +} + +export function createSignUpAttributesRequiredResult( + input: Omit +): SignUpAttributesRequiredResult { + return { + type: SIGN_UP_ATTRIBUTES_REQUIRED_RESULT_TYPE, + ...input, + }; +} diff --git a/lib/msal-browser/test/custom_auth/CustomAuthPublicClientApplication.spec.ts b/lib/msal-browser/test/custom_auth/CustomAuthPublicClientApplication.spec.ts new file mode 100644 index 0000000000..31382b0559 --- /dev/null +++ b/lib/msal-browser/test/custom_auth/CustomAuthPublicClientApplication.spec.ts @@ -0,0 +1,184 @@ +import { ICustomAuthStandardController } from "../../src/custom_auth/controller/ICustomAuthStandardController.js"; +import { InvalidConfigurationError } from "../../src/custom_auth/core/error/InvalidConfigurationError.js"; +import { CustomAuthPublicClientApplication } from "../../src/custom_auth/CustomAuthPublicClientApplication.js"; +import { customAuthConfig } from "./test_resources/CustomAuthConfig.js"; +import { SignUpResult } from "../../src/custom_auth/sign_up/auth_flow/result/SignUpResult.js"; +import { CustomAuthError } from "../../src/custom_auth/core/error/CustomAuthError.js"; +import { ResetPasswordStartResult } from "../../src/custom_auth/reset_password/auth_flow/result/ResetPasswordStartResult.js"; +import { GetAccountResult } from "../../src/custom_auth/get_account/auth_flow/result/GetAccountResult.js"; +import { CustomAuthStandardController } from "../../src/custom_auth/controller/CustomAuthStandardController.js"; + +describe("CustomAuthPublicClientApplication", () => { + let mockController: jest.Mocked; + + beforeEach(() => { + mockController = { + signIn: jest.fn(), + signUp: jest.fn(), + resetPassword: jest.fn(), + getCurrentAccount: jest.fn(), + } as unknown as jest.Mocked; + }); + + describe("constructor and config validation", () => { + it("should throw an error if the config is null", async () => { + await expect( + CustomAuthPublicClientApplication.create(null as any) + ).rejects.toThrow(InvalidConfigurationError); + }); + + it("should throw an error if the authority is missing", async () => { + const invalidConfig = { auth: {}, customAuth: {} } as any; + + await expect( + CustomAuthPublicClientApplication.create(invalidConfig) + ).rejects.toThrow(InvalidConfigurationError); + }); + + it("should throw an error if challenge type is invalid", async () => { + const invalidConfig = { + auth: { authority: customAuthConfig.auth.authority }, + customAuth: { + challengeTypes: ["invalid-challenge-type", "oob"], + }, + }; + + await expect( + CustomAuthPublicClientApplication.create(invalidConfig as any) + ).rejects.toThrow(InvalidConfigurationError); + }); + + it("should create an instance if the config is valid", async () => { + const app = await CustomAuthPublicClientApplication.create( + customAuthConfig + ); + + expect(app).toBeInstanceOf(CustomAuthPublicClientApplication); + + const controller = (app as CustomAuthPublicClientApplication)[ + "customAuthController" + ] as CustomAuthStandardController; + controller["eventHandler"]["broadcastChannel"]?.close(); + }); + }); + + describe("signIn", () => { + it("should call the customAuthController signIn method with correct inputs", async () => { + const mockSignInInputs = { + username: "testuser", + password: "testpassword", + }; + + const mockSignInResult = { accessToken: "test-token" }; + + mockController.signIn.mockResolvedValueOnce( + mockSignInResult as any + ); + + const app = await CustomAuthPublicClientApplication.create( + customAuthConfig + ); + + (app as any)["customAuthController"] = mockController; + + const result = await app.signIn(mockSignInInputs); + + expect(mockController.signIn).toHaveBeenCalledWith( + mockSignInInputs + ); + expect(result).toEqual(mockSignInResult); + }); + }); + + describe("signUp", () => { + it("should call the customAuthController signUp method with correct inputs", async () => { + const mockSignUpInputs = { + username: "testuser", + password: "testpassword", + }; + + const mockSignUpResult = SignUpResult.createWithError( + new CustomAuthError("test-error") + ); + + mockController.signUp.mockResolvedValueOnce( + mockSignUpResult as any + ); + + const app = await CustomAuthPublicClientApplication.create( + customAuthConfig + ); + + (app as any)["customAuthController"] = mockController; + + const result = await app.signUp(mockSignUpInputs); + + expect(mockController.signUp).toHaveBeenCalledWith( + mockSignUpInputs + ); + expect(result).toEqual(mockSignUpResult); + }); + }); + + describe("resetPassword", () => { + it("should call the customAuthController resetPassword method with correct inputs", async () => { + const mockResetPasswordInputs = { + username: "testuser", + }; + + const mockResetPasswordResult = + ResetPasswordStartResult.createWithError( + new CustomAuthError("test-error") + ); + + mockController.resetPassword.mockResolvedValueOnce( + mockResetPasswordResult as any + ); + + const app = await CustomAuthPublicClientApplication.create( + customAuthConfig + ); + + (app as any)["customAuthController"] = mockController; + + const result = await app.resetPassword(mockResetPasswordInputs); + + expect(mockController.resetPassword).toHaveBeenCalledWith( + mockResetPasswordInputs + ); + expect(result).toEqual(mockResetPasswordResult); + }); + }); + + describe("getCurrentAccount", () => { + it("should call the customAuthController getCurrentAccount method with correct inputs", async () => { + const mockGetCurrentAccountInputs = { + correlationId: "test-id", + }; + + const mockGetCurrentAccountResult = + GetAccountResult.createWithError( + new CustomAuthError("test-error") + ); + + mockController.getCurrentAccount.mockReturnValue( + mockGetCurrentAccountResult as any + ); + + const app = await CustomAuthPublicClientApplication.create( + customAuthConfig + ); + + (app as any)["customAuthController"] = mockController; + + const result = await app.getCurrentAccount( + mockGetCurrentAccountInputs + ); + + expect(mockController.getCurrentAccount).toHaveBeenCalledWith( + mockGetCurrentAccountInputs + ); + expect(result).toEqual(mockGetCurrentAccountResult); + }); + }); +}); diff --git a/lib/msal-browser/test/custom_auth/controller/CustomAuthStandardController.spec.ts b/lib/msal-browser/test/custom_auth/controller/CustomAuthStandardController.spec.ts new file mode 100644 index 0000000000..d5e063a3de --- /dev/null +++ b/lib/msal-browser/test/custom_auth/controller/CustomAuthStandardController.spec.ts @@ -0,0 +1,442 @@ +import { CustomAuthStandardController } from "../../../src/custom_auth/controller/CustomAuthStandardController.js"; +import { + ResetPasswordInputs, + SignInInputs, + SignUpInputs, +} from "../../../src/custom_auth/CustomAuthActionInputs.js"; +import { CustomAuthOperatingContext } from "../../../src/custom_auth/operating_context/CustomAuthOperatingContext.js"; +import { customAuthConfig } from "../test_resources/CustomAuthConfig.js"; +import { SignInError } from "../../../src/custom_auth/sign_in/auth_flow/error_type/SignInError.js"; +import { SignInResult } from "../../../src/custom_auth/sign_in/auth_flow/result/SignInResult.js"; +import { CustomAuthAccountData } from "../../../src/custom_auth/get_account/auth_flow/CustomAuthAccountData.js"; +import { SignUpError } from "../../../src/custom_auth/sign_up/auth_flow/error_type/SignUpError.js"; +import { ChallengeType } from "../../../src/custom_auth/CustomAuthConstants.js"; +import { + CustomAuthApiError, + RedirectError, +} from "../../../src/custom_auth/core/error/CustomAuthApiError.js"; +import { SignUpResult } from "../../../src/custom_auth/sign_up/auth_flow/result/SignUpResult.js"; +import * as CustomAuthApiErrorCode from "../../../src/custom_auth/core/network_client/custom_auth_api/types/ApiErrorCodes.js"; +import * as CustomAuthApiSuberror from "../../../src/custom_auth/core/network_client/custom_auth_api/types/ApiSuberrors.js"; +import { ResetPasswordError } from "../../../src/custom_auth/reset_password/auth_flow/error_type/ResetPasswordError.js"; +import { ResetPasswordCodeRequiredState } from "../../../src/custom_auth/reset_password/auth_flow/state/ResetPasswordCodeRequiredState.js"; +import { ResetPasswordStartResult } from "../../../src/custom_auth/reset_password/auth_flow/result/ResetPasswordStartResult.js"; + +jest.mock( + "../../../src/custom_auth/core/network_client/custom_auth_api/CustomAuthApiClient.js", + () => { + let signInApiClient = { + initiate: jest.fn(), + requestChallenge: jest.fn(), + requestTokensWithPassword: jest.fn(), + requestTokensWithOTP: jest.fn(), + }; + let signUpApiClient = { + start: jest.fn(), + requestChallenge: jest.fn(), + continue: jest.fn(), + continueWithPassword: jest.fn(), + continueWithAttributes: jest.fn(), + }; + let resetPasswordApiClient = { + start: jest.fn(), + requestChallenge: jest.fn(), + continueWithCode: jest.fn(), + submitNewPassword: jest.fn(), + pollCompletion: jest.fn(), + }; + + // Set up the prototype or instance methods/properties + const CustomAuthApiClient = jest.fn().mockImplementation(() => ({ + signInApi: signInApiClient, + signUpApi: signUpApiClient, + resetPasswordApi: resetPasswordApiClient, + })); + + return { + CustomAuthApiClient, + signInApiClient, + signUpApiClient, + resetPasswordApiClient, + }; + } +); + +jest.mock("@azure/msal-common/browser", () => { + const actualModule = jest.requireActual("@azure/msal-common/browser"); + return { + ...actualModule, + ResponseHandler: jest.fn().mockImplementation(() => ({ + handleServerTokenResponse: jest.fn().mockResolvedValue({ + uniqueId: "test-unique-id", + tenantId: "test-tenant-id", + scopes: ["test-scope"], + account: { + homeAccountId: "test-home-account-id", + environment: "test-environment", + tenantId: "test-tenant-id", + username: "test-username", + }, + idToken: "test-id-token", + idTokenClaims: {}, + accessToken: "test-access-token", + refreshToken: "test-refresh-token", + expiresOn: new Date(), + extExpiresOn: new Date(), + }), + })), + }; +}); + +describe("CustomAuthStandardController", () => { + let controller: CustomAuthStandardController; + const { signInApiClient, signUpApiClient, resetPasswordApiClient } = + jest.requireMock( + "../../../src/custom_auth/core/network_client/custom_auth_api/CustomAuthApiClient.js" + ); + + beforeEach(() => { + const context = new CustomAuthOperatingContext(customAuthConfig); + controller = new CustomAuthStandardController(context); + + global.fetch = jest.fn(); // Mock the fetch API + }); + + afterEach(() => { + // controller.closeEventChannel(); + jest.clearAllMocks(); // Clear mocks between tests + if ( + controller && + controller["eventHandler"] && + controller["eventHandler"]["broadcastChannel"] + ) { + controller["eventHandler"]["broadcastChannel"].close(); + } + }); + + test("Check if BroadcastChannel exists in JSDOM", () => { + expect(typeof BroadcastChannel).toBe("function"); + }); + + describe("signIn", () => { + it("should return error result if provided username is invalid", async () => { + const signInInputs: SignInInputs = { + correlationId: "correlation-id", + username: "", + }; + + const result = await controller.signIn(signInInputs); + + expect(result.error).toBeDefined(); + expect(result.error).toBeInstanceOf(SignInError); + + expect(result.error?.isInvalidUsername()).toBe(true); + }); + + it("should return code required result if the challenge type is oob", async () => { + signInApiClient.initiate.mockResolvedValue({ + continuation_token: "continuation_token_1", + }); + signInApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.OOB, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + code_length: 6, + challenge_channel: "email", + target_challenge_label: "email", + }); + + const signInInputs: SignInInputs = { + correlationId: "correlation-id", + username: "test@test.com", + }; + + const result = await controller.signIn(signInInputs); + + expect(result).toBeInstanceOf(SignInResult); + expect(result.error).toBeUndefined(); + expect(result.isCodeRequired()).toBe(true); + }); + + it("should return password required result if the challenge type is password", async () => { + signInApiClient.initiate.mockResolvedValue({ + continuation_token: "continuation_token_1", + }); + signInApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.PASSWORD, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + }); + + const signInInputs: SignInInputs = { + correlationId: "correlation-id", + username: "test@test.com", + }; + + const result = await controller.signIn(signInInputs); + + expect(result).toBeInstanceOf(SignInResult); + expect(result.error).toBeUndefined(); + expect(result.isPasswordRequired()).toBe(true); + }); + + it("should return correct completed result if the challenge type is password and password is provided", async () => { + signInApiClient.initiate.mockResolvedValue({ + continuation_token: "continuation_token_1", + }); + signInApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.PASSWORD, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + }); + signInApiClient.requestTokensWithPassword.mockResolvedValue({ + correlation_id: "test-correlation-id", + access_token: "test-access-token", + refresh_token: "test-refresh-token", + id_token: "test-id-token", + expires_in: 3600, + token_type: "Bearer", + }); + + const signInInputs: SignInInputs = { + correlationId: "correlation-id", + username: "test@test.com", + password: "test-password", + }; + + const result = await controller.signIn(signInInputs); + + expect(result).toBeInstanceOf(SignInResult); + expect(result.error).toBeUndefined(); + expect(result.isCompleted()).toBe(true); + expect(result.data).toBeDefined(); + expect(result.data).toBeInstanceOf(CustomAuthAccountData); + }); + + it("should return failed result if the challenge type is redirect", async () => { + signInApiClient.initiate.mockRejectedValue(new RedirectError()); + + const signInInputs: SignInInputs = { + correlationId: "correlation-id", + username: "test@test.com", + password: "test-password", + }; + + const result = await controller.signIn(signInInputs); + + expect(result).toBeInstanceOf(SignInResult); + expect(result.error).toBeDefined(); + expect(result.error?.errorData).toBeDefined(); + expect(result.error?.isRedirectRequired()).toEqual(true); + expect(result.isFailed()).toBe(true); + }); + }); + + describe("signUp", () => { + it("should return error result if provided username is empty", async () => { + const signUpInputs: SignUpInputs = { + correlationId: "correlation-id", + username: "", + }; + + const result = await controller.signUp(signUpInputs); + + expect(result.error).toBeDefined(); + expect(result.error).toBeInstanceOf(SignUpError); + + expect(result.error?.isInvalidUsername()).toBe(true); + }); + + it("should return result with code required state if the challenge type is oob", async () => { + signUpApiClient.start.mockResolvedValue({ + continuation_token: "continuation_token_1", + }); + signUpApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.OOB, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + code_length: 6, + challenge_channel: "email", + challenge_target_label: "email", + }); + + const signUpInputs: SignUpInputs = { + correlationId: "correlation-id", + username: "test@test.com", + }; + + const result = await controller.signUp(signUpInputs); + + expect(result).toBeInstanceOf(SignUpResult); + expect(result.error).toBeUndefined(); + expect(result.isCodeRequired()).toBe(true); + }); + + it("should return result with password required state if the challenge type is password", async () => { + signUpApiClient.start.mockResolvedValue({ + continuation_token: "continuation_token_1", + }); + signUpApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.PASSWORD, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + }); + + const signUpInputs: SignUpInputs = { + correlationId: "correlation-id", + username: "test@test.com", + }; + + const result = await controller.signUp(signUpInputs); + + expect(result).toBeInstanceOf(SignUpResult); + expect(result.error).toBeUndefined(); + expect(result.isPasswordRequired()).toBe(true); + }); + + it("should return failed result if the start endpoint returns redirect challenge type", async () => { + signUpApiClient.start.mockRejectedValue(new RedirectError()); + + const signUpInputs: SignUpInputs = { + correlationId: "correlation-id", + username: "test@test.com", + }; + + const result = await controller.signUp(signUpInputs); + + expect(result).toBeInstanceOf(SignUpResult); + expect(result.error).toBeDefined(); + expect(result.error?.errorData).toBeDefined(); + expect(result.error?.isRedirectRequired()).toEqual(true); + expect(result.isFailed()).toBe(true); + }); + + it("should return failed result if the challenge endpoint returns redirect challenge type", async () => { + signUpApiClient.start.mockResolvedValue({ + continuation_token: "continuation_token_1", + }); + signUpApiClient.requestChallenge.mockRejectedValue( + new RedirectError() + ); + + const signUpInputs: SignUpInputs = { + correlationId: "correlation-id", + username: "test@test.com", + }; + + const result = await controller.signUp(signUpInputs); + + expect(result).toBeInstanceOf(SignUpResult); + expect(result.error).toBeDefined(); + expect(result.error?.errorData).toBeDefined(); + expect(result.error?.isRedirectRequired()).toEqual(true); + expect(result.isFailed()).toBe(true); + }); + + it("should return failed result if the password is too weak", async () => { + signUpApiClient.start.mockRejectedValue( + new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_GRANT, + "Password is too weak", + "correlation-id", + [], + CustomAuthApiSuberror.PASSWORD_TOO_WEAK + ) + ); + + const signUpInputs: SignUpInputs = { + correlationId: "correlation-id", + username: "test@test.com", + }; + + const result = await controller.signUp(signUpInputs); + + expect(result).toBeInstanceOf(SignUpResult); + expect(result.error).toBeDefined(); + expect(result.error?.errorData).toBeDefined(); + expect(result.error?.isInvalidPassword()).toEqual(true); + expect(result.isFailed()).toBe(true); + }); + }); + + describe("resetPassword", () => { + it("should return error result if provided username is invalid", async () => { + // Empty username + let inputs: ResetPasswordInputs = { + correlationId: "correlation-id", + username: "", + }; + + let result = await controller.resetPassword(inputs); + + expect(result.error).toBeDefined(); + expect(result.error).toBeInstanceOf(ResetPasswordError); + + expect(result.error?.isInvalidUsername()).toBe(true); + }); + + it("should return code required result successfully", async () => { + resetPasswordApiClient.start.mockResolvedValue({ + continuation_token: "continuation_token_1", + }); + resetPasswordApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.OOB, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + code_length: 8, + challenge_channel: "email", + target_challenge_label: "email", + }); + + const inputs: ResetPasswordInputs = { + correlationId: "correlation-id", + username: "test@test.com", + }; + + const result = await controller.resetPassword(inputs); + + expect(result.error).toBeUndefined(); + expect(result.state).toBeInstanceOf(ResetPasswordCodeRequiredState); + expect(result.isCodeRequired()).toBe(true); + }); + + it("should return redirect error if the return challenge is redirect", async () => { + resetPasswordApiClient.start.mockRejectedValue(new RedirectError()); + + const inputs: ResetPasswordInputs = { + correlationId: "correlation-id", + username: "test@test.com", + }; + + const result = await controller.resetPassword(inputs); + + expect(result).toBeInstanceOf(ResetPasswordStartResult); + expect(result.error).toBeDefined(); + expect(result.error?.errorData).toBeDefined(); + expect(result.error?.isRedirectRequired()).toEqual(true); + expect(result.isFailed()).toBe(true); + }); + + it("should return failed result if the user is not found", async () => { + resetPasswordApiClient.start.mockRejectedValue( + new CustomAuthApiError( + CustomAuthApiErrorCode.USER_NOT_FOUND, + "User not found" + ) + ); + + const inputs: ResetPasswordInputs = { + correlationId: "correlation-id", + username: "test@test.com", + }; + + const result = await controller.resetPassword(inputs); + + expect(result).toBeInstanceOf(ResetPasswordStartResult); + expect(result.error).toBeDefined(); + expect(result.error?.errorData).toBeDefined(); + expect(result.error?.isUserNotFound()).toEqual(true); + expect(result.isFailed()).toBe(true); + }); + }); +}); diff --git a/lib/msal-browser/test/custom_auth/core/CustomAuthAuthority.spec.ts b/lib/msal-browser/test/custom_auth/core/CustomAuthAuthority.spec.ts new file mode 100644 index 0000000000..4f9c80ea99 --- /dev/null +++ b/lib/msal-browser/test/custom_auth/core/CustomAuthAuthority.spec.ts @@ -0,0 +1,184 @@ +import { INetworkModule, Logger } from "@azure/msal-common/browser"; +import { BrowserCacheManager } from "../../../src/cache/BrowserCacheManager.js"; +import { BrowserConfiguration } from "../../../src/config/Configuration.js"; +import { CustomAuthAuthority } from "../../../src/custom_auth/core/CustomAuthAuthority.js"; +import { customAuthConfig } from "../test_resources/CustomAuthConfig.js"; + +describe("CustomAuthAuthority", () => { + const authorityUrl = customAuthConfig.auth.authority; + const customAuthProxyDomain = customAuthConfig.customAuth.authApiProxyUrl; + const mockMemoryStorage = new Map(); + const authorityHostname = + authorityUrl && authorityUrl.startsWith("https") + ? authorityUrl.split("/")[2] + : authorityUrl; + const authorityMetadataEntityKey = `authority-metadata-${customAuthConfig.auth.clientId}-${authorityHostname}`; + const mockCacheManager = { + generateAuthorityMetadataCacheKey: jest.fn().mockImplementation(() => { + return authorityMetadataEntityKey; + }), + setAuthorityMetadata: jest.fn().mockImplementation((key, metadata) => { + mockMemoryStorage.set(key, metadata); + }), + } as unknown as BrowserCacheManager; + const mockNetworkModule = {} as unknown as jest.Mocked; + const mockLogger = {} as unknown as jest.Mocked; + const mockConfig = { + auth: { + OIDCOptions: {}, + knownAuthorities: [], + cloudDiscoveryMetadata: "", + authorityMetadata: "", + }, + system: { + protocolMode: "", + }, + } as unknown as jest.Mocked; + + describe("constructor", () => { + it("should correctly parse and store the authority URL", () => { + const customAuthAuthority = new CustomAuthAuthority( + authorityUrl ?? "", + mockConfig, + mockNetworkModule, + mockCacheManager, + mockLogger + ); + expect(customAuthAuthority.canonicalAuthority).toBe( + "https://spasamples.ciamlogin.com/spasamples.onmicrosoft.com/" + ); + }); + + it("should correctly store the customAuthProxyDomain when provided", () => { + const customAuthAuthority = new CustomAuthAuthority( + authorityUrl ?? "", + mockConfig, + mockNetworkModule, + mockCacheManager, + mockLogger, + customAuthProxyDomain + ); + expect(customAuthAuthority["customAuthProxyDomain"]).toBe( + customAuthProxyDomain + ); + }); + + it("should correctly store the customAuthProxyDomain when provided", () => { + const customAuthAuthority = new CustomAuthAuthority( + "https://login.microsoftonline.com/", + mockConfig, + mockNetworkModule, + mockCacheManager, + mockLogger, + customAuthProxyDomain + ); + expect(customAuthAuthority["customAuthProxyDomain"]).toBe( + customAuthProxyDomain + ); + }); + + it("should save authority metadata entity into cache", () => { + const customAuthAuthority = new CustomAuthAuthority( + authorityUrl ?? "", + mockConfig, + mockNetworkModule, + mockCacheManager, + mockLogger + ); + expect(customAuthAuthority.canonicalAuthority).toBe( + "https://spasamples.ciamlogin.com/spasamples.onmicrosoft.com/" + ); + + const authorityHostname = + customAuthAuthority.canonicalAuthorityUrlComponents + .HostNameAndPort; + const authorityMetadataCacheKey = + "authority-metadata-d5e97fb9-24bb-418d-8e7a-4e1918303c92-spasamples.ciamlogin.com"; + const metadataEntity = mockMemoryStorage.get( + authorityMetadataCacheKey + ); + + expect(mockMemoryStorage.has(authorityMetadataCacheKey)).toBe(true); + expect(metadataEntity).toMatchObject({ + aliases: [authorityHostname], + preferred_cache: authorityHostname, + }); + }); + }); + + describe("tenant getter", () => { + it("should extract the tenant from the authority URL hostname", () => { + const customAuthAuthority = new CustomAuthAuthority( + authorityUrl ?? "", + mockConfig, + mockNetworkModule, + mockCacheManager, + mockLogger + ); + expect(customAuthAuthority.tenant).toBe( + "spasamples.onmicrosoft.com" + ); + }); + }); + + describe("getCustomAuthDomain", () => { + it("should return the customAuthProxyDomain when provided", () => { + const customAuthAuthority = new CustomAuthAuthority( + authorityUrl ?? "", + mockConfig, + mockNetworkModule, + mockCacheManager, + mockLogger, + customAuthProxyDomain + ); + expect(customAuthAuthority.getCustomAuthApiDomain()).toBe( + customAuthProxyDomain + ); + }); + + it("should generate the auth API domain based on the authority URL when customAuthProxyDomain is not provided", () => { + const customAuthAuthority = new CustomAuthAuthority( + authorityUrl ?? "", + mockConfig, + mockNetworkModule, + mockCacheManager, + mockLogger + ); + expect(customAuthAuthority.getCustomAuthApiDomain()).toBe( + "https://spasamples.ciamlogin.com/spasamples.onmicrosoft.com/" + ); + }); + }); + + describe("getPreferredCache", () => { + it("should return the host of authority as preferred cache", () => { + const customAuthAuthority = new CustomAuthAuthority( + authorityUrl ?? "", + mockConfig, + mockNetworkModule, + mockCacheManager, + mockLogger, + customAuthProxyDomain + ); + expect(customAuthAuthority.getPreferredCache()).toBe( + "spasamples.ciamlogin.com" + ); + }); + }); + + describe("tokenEndpoint", () => { + it("should return the correct token endpoint", () => { + const customAuthAuthority = new CustomAuthAuthority( + authorityUrl ?? "", + mockConfig, + mockNetworkModule, + mockCacheManager, + mockLogger, + customAuthProxyDomain + ); + expect(customAuthAuthority.tokenEndpoint).toBe( + "https://myspafunctiont1.azurewebsites.net/api/ReverseProxy/oauth2/v2.0/token" + ); + }); + }); +}); diff --git a/lib/msal-browser/test/custom_auth/core/network_client/custom_auth_api/CustomAuthApiClient.spec.ts b/lib/msal-browser/test/custom_auth/core/network_client/custom_auth_api/CustomAuthApiClient.spec.ts new file mode 100644 index 0000000000..719e5d3c72 --- /dev/null +++ b/lib/msal-browser/test/custom_auth/core/network_client/custom_auth_api/CustomAuthApiClient.spec.ts @@ -0,0 +1,34 @@ +import { Logger } from "@azure/msal-browser"; +import { CustomAuthApiClient } from "../../../../../src/custom_auth/core/network_client/custom_auth_api/CustomAuthApiClient.js"; +import { FetchHttpClient } from "../../../../../src/custom_auth/core/network_client/http_client/FetchHttpClient.js"; + +describe("CustomAuthApiClient", () => { + let customAuthApiClient: CustomAuthApiClient; + + beforeEach(() => { + const mockLogger = { + clone: jest.fn(), + verbose: jest.fn(), + info: jest.fn(), + error: jest.fn(), + errorPii: jest.fn(), + } as unknown as jest.Mocked; + customAuthApiClient = new CustomAuthApiClient( + "https://test.com", + "client_id", + new FetchHttpClient(mockLogger) + ); + }); + + it("should initialize signInApiClient correctly", () => { + expect(customAuthApiClient.signInApi).toBeDefined(); + }); + + it("should initialize signUpApiClient correctly", () => { + expect(customAuthApiClient.signUpApi).toBeDefined(); + }); + + it("should initialize resetPasswordApiClient correctly", () => { + expect(customAuthApiClient.resetPasswordApi).toBeDefined(); + }); +}); diff --git a/lib/msal-browser/test/custom_auth/core/network_client/http_client/FetchClient.spec.ts b/lib/msal-browser/test/custom_auth/core/network_client/http_client/FetchClient.spec.ts new file mode 100644 index 0000000000..bb4a60cc05 --- /dev/null +++ b/lib/msal-browser/test/custom_auth/core/network_client/http_client/FetchClient.spec.ts @@ -0,0 +1,69 @@ +import { Logger } from "@azure/msal-browser"; +import { FetchHttpClient } from "../../../../../src/custom_auth/core/network_client/http_client/FetchHttpClient.js"; + +class MockResponse { + public readonly status: number; + public readonly headers: Headers; + private readonly body: any; + + constructor(body: any, init: ResponseInit = {}) { + this.status = init.status || 200; + this.headers = new Headers(init.headers); + this.body = body; + } + + async json() { + return JSON.parse(this.body); + } +} + +describe("FetchHttpClient", () => { + let httpClient: FetchHttpClient; + let mockFetch: jest.Mock; + const mockLogger = { + clone: jest.fn(), + info: jest.fn(), + infoPii: jest.fn(), + verbose: jest.fn(), + verbosePii: jest.fn(), + error: jest.fn(), + trace: jest.fn(), + errorPii: jest.fn(), + tracePii: jest.fn(), + } as unknown as jest.Mocked; + + beforeEach(() => { + // Create a mock for the global fetch + mockFetch = jest.fn(); + global.fetch = mockFetch; + httpClient = new FetchHttpClient(mockLogger); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("sendAsync", () => { + it("should call fetch with correct parameters", async () => { + const url = "https://api.example.com"; + const options: RequestInit = { + method: "GET", + headers: { "Content-Type": "application/json" }, + }; + const mockResponse = new MockResponse(null, { status: 200 }); + mockFetch.mockResolvedValue(mockResponse); + const response = await httpClient.sendAsync(url, options); + expect(mockFetch).toHaveBeenCalledWith(url, options); + expect(response).toBe(mockResponse); + }); + + it("should propagate fetch errors", async () => { + const url = "https://api.example.com"; + const error = new Error("Network error"); + mockFetch.mockRejectedValue(error); + await expect(httpClient.sendAsync(url, {})).rejects.toThrow( + "Network error" + ); + }); + }); +}); diff --git a/lib/msal-browser/test/custom_auth/core/utils/ArgumentValidator.spec.ts b/lib/msal-browser/test/custom_auth/core/utils/ArgumentValidator.spec.ts new file mode 100644 index 0000000000..d4a0c2529f --- /dev/null +++ b/lib/msal-browser/test/custom_auth/core/utils/ArgumentValidator.spec.ts @@ -0,0 +1,85 @@ +import { InvalidArgumentError } from "../../../../src/custom_auth/core/error/InvalidArgumentError.js"; +import { + ensureArgumentIsNotEmptyString, + ensureArgumentIsNotNullOrUndefined, +} from "../../../../src/custom_auth/core/utils/ArgumentValidator.js"; + +describe("ArgumentValidator", () => { + describe("ensureArgumentIsNotEmptyString", () => { + it("should not throw an error if the string is non-empty", () => { + expect(() => { + ensureArgumentIsNotEmptyString("testArg", "validString"); + }).not.toThrow(); + }); + + it("should throw InvalidArgumentError if the string is empty", () => { + expect(() => { + ensureArgumentIsNotEmptyString("testArg", ""); + }).toThrow(InvalidArgumentError); + }); + + it("should throw InvalidArgumentError if the string is only whitespace", () => { + expect(() => { + ensureArgumentIsNotEmptyString("testArg", " "); + }).toThrow(InvalidArgumentError); + }); + + it("should pass correlationId to the error when the string is invalid", () => { + const correlationId = "12345"; + try { + ensureArgumentIsNotEmptyString("testArg", "", correlationId); + } catch (error) { + if (error instanceof InvalidArgumentError) { + expect(error.correlationId).toBe(correlationId); + } else { + throw error; + } + } + }); + }); + + describe("ensureArgumentIsNotNullOrUndefined", () => { + it("should not throw an error if the argument is not null or undefined", () => { + expect(() => { + ensureArgumentIsNotNullOrUndefined("testArg", "validValue"); + }).not.toThrow(); + + expect(() => { + ensureArgumentIsNotNullOrUndefined("testArg", 42); + }).not.toThrow(); + + expect(() => { + ensureArgumentIsNotNullOrUndefined("testArg", {}); + }).not.toThrow(); + }); + + it("should throw InvalidArgumentError if the argument is null", () => { + expect(() => { + ensureArgumentIsNotNullOrUndefined("testArg", null); + }).toThrow(InvalidArgumentError); + }); + + it("should throw InvalidArgumentError if the argument is undefined", () => { + expect(() => { + ensureArgumentIsNotNullOrUndefined("testArg", undefined); + }).toThrow(InvalidArgumentError); + }); + + it("should pass correlationId to the error when the argument is invalid", () => { + const correlationId = "12345"; + try { + ensureArgumentIsNotNullOrUndefined( + "testArg", + null, + correlationId + ); + } catch (error) { + if (error instanceof InvalidArgumentError) { + expect(error.correlationId).toBe(correlationId); + } else { + throw error; + } + } + }); + }); +}); diff --git a/lib/msal-browser/test/custom_auth/core/utils/UrlUtils.spec.ts b/lib/msal-browser/test/custom_auth/core/utils/UrlUtils.spec.ts new file mode 100644 index 0000000000..abf1d657fa --- /dev/null +++ b/lib/msal-browser/test/custom_auth/core/utils/UrlUtils.spec.ts @@ -0,0 +1,73 @@ +import { ParsedUrlError } from "../../../../src/custom_auth/core/error/ParsedUrlError.js"; +import { + buildUrl, + parseUrl, +} from "../../../../src/custom_auth/core/utils/UrlUtils.js"; + +describe("UrlUtils", () => { + describe("parseUrl", () => { + it("should return a valid URL object for a correct URL", () => { + const url = "https://example.com"; + const result = parseUrl(url); + expect(result).toBeInstanceOf(URL); + expect(result.origin).toBe(url); + }); + + it("should throw ParsedUrlError for an invalid URL", () => { + const url = "invalid-url"; + expect(() => parseUrl(url)).toThrow( + new ParsedUrlError( + "invalid_url", + `The URL "${url}" is invalid: TypeError: Invalid URL: invalid-url` + ) + ); + }); + }); + + describe("buildUrl", () => { + test.each([ + [ + "baseUrl does not end with a slash and path does not start with a slash", + "https://example.com", + "path/to/resource", + "https://example.com/path/to/resource", + ], + [ + "baseUrl ends with a slash and path does not start with a slash", + "https://example.com/", + "path/to/resource", + "https://example.com/path/to/resource", + ], + [ + "baseUrl does not end with a slash and path starts with a slash", + "https://example.com", + "/path/to/resource", + "https://example.com/path/to/resource", + ], + [ + "baseUrl ends with a slash and path starts with a slash", + "https://example.com/", + "/path/to/resource", + "https://example.com/path/to/resource", + ], + [ + "URL with query parameters", + "https://example.com", + "path?query=1", + "https://example.com/path?query=1", + ], + [ + "baseUrl contains a subpath", + "https://example.com/sub", + "path/to/resource", + "https://example.com/sub/path/to/resource", + ], + ])( + "should correctly construct a URL when %s", + (name, baseUrl, path, expected) => { + const result = buildUrl(baseUrl, path); + expect(result.toString()).toBe(expected); + } + ); + }); +}); diff --git a/lib/msal-browser/test/custom_auth/get_account/auth_flow/CustomAuthAccountData.spec.ts b/lib/msal-browser/test/custom_auth/get_account/auth_flow/CustomAuthAccountData.spec.ts new file mode 100644 index 0000000000..8c76269a3b --- /dev/null +++ b/lib/msal-browser/test/custom_auth/get_account/auth_flow/CustomAuthAccountData.spec.ts @@ -0,0 +1,267 @@ +import { CustomAuthBrowserConfiguration } from "../../../../src/custom_auth/configuration/CustomAuthConfiguration.js"; +import { CustomAuthSilentCacheClient } from "../../../../src/custom_auth/get_account/interaction_client/CustomAuthSilentCacheClient.js"; +import { CustomAuthAccountData } from "../../../../src/custom_auth/get_account/auth_flow/CustomAuthAccountData.js"; +import { SignOutResult } from "../../../../src/custom_auth/get_account/auth_flow/result/SignOutResult.js"; +import { SignOutError } from "../../../../src/custom_auth/get_account/auth_flow/error_type/GetAccountError.js"; +import { MsalCustomAuthError } from "../../../../src/custom_auth/core/error/MsalCustomAuthError.js"; +import { + AccountInfo, + IdTokenClaims, + InteractionRequiredAuthError, + InteractionRequiredAuthErrorCodes, + Logger, +} from "@azure/msal-common/browser"; +import { AuthenticationResult } from "../../../../src/response/AuthenticationResult.js"; + +describe("CustomAuthAccountData", () => { + let mockAccount: AccountInfo; + let mockConfig: CustomAuthBrowserConfiguration; + let mockCacheClient: CustomAuthSilentCacheClient; + let mockLogger: Logger; + const correlationId = "test-correlation-id"; + let mockAuthenticationResult: AuthenticationResult; + + beforeEach(() => { + mockAccount = { + homeAccountId: "test-home-account-id", + name: "Test User", + username: "test.user@example.com", + environment: "test-environment", + localAccountId: "test-local-account-id", + tenantId: "test-tenant-id", + idToken: "test-id-token", + idTokenClaims: { + name: "Test User", + }, + }; + + mockAuthenticationResult = { + authority: "test-authority", + uniqueId: "test-unique-id", + tenantId: "test-tenant-id", + scopes: ["test-scope"], + account: mockAccount, + idToken: "test-id-token", + idTokenClaims: mockAccount.idTokenClaims as IdTokenClaims, + accessToken: "test-access-token", + fromCache: true, + expiresOn: new Date(), + tokenType: "Bearer", + correlationId: correlationId, + } as AuthenticationResult; + + mockConfig = { + auth: { + authority: "test-authority", + }, + } as CustomAuthBrowserConfiguration; // Mock as needed + mockCacheClient = { + acquireToken: jest.fn(), + getCurrentAccount: jest.fn(), + logout: jest.fn(), + } as unknown as CustomAuthSilentCacheClient; + mockLogger = { + info: jest.fn(), + verbose: jest.fn(), + error: jest.fn(), + errorPii: jest.fn(), + } as unknown as Logger; + }); + + afterEach(() => { + jest.clearAllMocks(); // Clear mocks between tests + }); + + describe("signOut", () => { + it("should sign out the user successfully", async () => { + (mockCacheClient.getCurrentAccount as jest.Mock).mockReturnValue( + mockAccount + ); + + const accountData = new CustomAuthAccountData( + mockAccount, + mockConfig, + mockCacheClient, + mockLogger, + correlationId + ); + const result = await accountData.signOut(); + + expect(mockCacheClient.logout).toHaveBeenCalledWith({ + correlationId: correlationId, + account: mockAccount, + }); + expect(result).toBeInstanceOf(SignOutResult); + expect(mockLogger.verbose).toHaveBeenCalledWith( + "Signing out user", + "test-correlation-id" + ); + expect(mockLogger.verbose).toHaveBeenCalledWith( + "User signed out", + "test-correlation-id" + ); + }); + + it("should handle errors during sign out", async () => { + const error = new Error("Sign out error"); + (mockCacheClient.getCurrentAccount as jest.Mock).mockReturnValue( + mockAccount + ); + (mockCacheClient.logout as jest.Mock).mockRejectedValue(error); + + const accountData = new CustomAuthAccountData( + mockAccount, + mockConfig, + mockCacheClient, + mockLogger, + correlationId + ); + const result = await accountData.signOut(); + + expect(mockLogger.errorPii).toHaveBeenCalledWith( + `An error occurred during sign out: ${error}`, + "test-correlation-id" + ); + expect(result).toBeInstanceOf(SignOutResult); + expect(result.error).toBeDefined(); + }); + + it("should handle no cached account", async () => { + (mockCacheClient.getCurrentAccount as jest.Mock).mockReturnValue( + null + ); + const accountData = new CustomAuthAccountData( + mockAccount, + mockConfig, + mockCacheClient, + mockLogger, + correlationId + ); + const result = await accountData.signOut(); + expect(result).toBeInstanceOf(SignOutResult); + expect(result.error).toBeInstanceOf(SignOutError); + expect(result.error?.isUserNotSignedIn()).toBe(true); + }); + }); + + describe("getAccount", () => { + it("should return the account information", () => { + const accountData = new CustomAuthAccountData( + mockAccount, + mockConfig, + mockCacheClient, + mockLogger, + correlationId + ); + const account = accountData.getAccount(); + expect(account).toEqual(mockAccount); + }); + }); + + describe("getIdToken", () => { + it("should return the id token", () => { + const accountData = new CustomAuthAccountData( + mockAccount, + mockConfig, + mockCacheClient, + mockLogger, + correlationId + ); + const idToken = accountData.getIdToken(); + expect(idToken).toEqual(mockAccount.idToken); + }); + }); + + describe("getClaims", () => { + it("should return the token claims", () => { + const accountData = new CustomAuthAccountData( + mockAccount, + mockConfig, + mockCacheClient, + mockLogger, + correlationId + ); + const claims = accountData.getClaims(); + expect(claims).toEqual(mockAccount.idTokenClaims); + }); + }); + + describe("getAccessToken", () => { + it("should return succeed GetAccessTokenState.Completed with cached tokens", async () => { + (mockCacheClient.getCurrentAccount as jest.Mock).mockReturnValue( + mockAccount + ); + jest.spyOn( + CustomAuthAccountData.prototype as any, + "createCommonSilentFlowRequest" + ).mockReturnValue({}); + (mockCacheClient.acquireToken as jest.Mock).mockResolvedValue( + mockAuthenticationResult + ); + const accountData = new CustomAuthAccountData( + mockAccount, + mockConfig, + mockCacheClient, + mockLogger, + correlationId + ); + + const response = await accountData.getAccessToken({ + forceRefresh: false, + }); + + expect(response).toBeDefined(); + expect(response.isCompleted()).toBe(true); + expect(response.data?.account).toEqual(mockAccount); + expect(response.data?.idToken).toEqual( + mockAuthenticationResult.idToken + ); + }); + + it("should return GetAccessTokenError if there is an error when aquire tokens", async () => { + (mockCacheClient.getCurrentAccount as jest.Mock).mockReturnValue( + mockAccount + ); + const errorCode = + InteractionRequiredAuthErrorCodes.refreshTokenExpired; + const errorMessage = "Refresh token has expired."; + const subError = + "Refresh token has expired, can not use it to get a new access token."; + const mockRefreshTokenExpiredError = + new InteractionRequiredAuthError( + errorCode, + errorMessage, + subError + ); + (mockCacheClient.acquireToken as jest.Mock).mockRejectedValue( + mockRefreshTokenExpiredError + ); + + const accountData = new CustomAuthAccountData( + mockAccount, + mockConfig, + mockCacheClient, + mockLogger, + correlationId + ); + + const response = await accountData.getAccessToken({ + forceRefresh: false, + }); + + expect(response).toBeDefined(); + expect(response.isFailed()).toBe(true); + expect(response.error?.errorData).toEqual( + mockRefreshTokenExpiredError + ); + expect(response.error?.errorData).toBeInstanceOf( + MsalCustomAuthError + ); + + const msalError = response.error?.errorData as MsalCustomAuthError; + expect(msalError.error).toEqual(errorCode); + expect(msalError.errorDescription).toEqual(errorMessage); + expect(msalError.subError).toEqual(subError); + }); + }); +}); diff --git a/lib/msal-browser/test/custom_auth/get_account/auth_flow/error_type/GetAccountError.spec.ts b/lib/msal-browser/test/custom_auth/get_account/auth_flow/error_type/GetAccountError.spec.ts new file mode 100644 index 0000000000..0e2b376333 --- /dev/null +++ b/lib/msal-browser/test/custom_auth/get_account/auth_flow/error_type/GetAccountError.spec.ts @@ -0,0 +1,34 @@ +import { NoCachedAccountFoundError } from "../../../../../src/custom_auth/core/error/NoCachedAccountFoundError.js"; +import { + GetAccountError, + SignOutError, +} from "../../../../../src/custom_auth/get_account/auth_flow/error_type/GetAccountError.js"; +import { UnexpectedError } from "../../../../../src/custom_auth/index.js"; + +describe("GetAccountError", () => { + it("should return true for isCurrentAccountNotFound when error is NoSignedInAccountFound", () => { + const error = new GetAccountError(new NoCachedAccountFoundError()); + expect(error.isCurrentAccountNotFound()).toBe(true); + }); + + it("should return false for isCurrentAccountNotFound when error is not NoSignedInAccountFound", () => { + const error = new GetAccountError( + new UnexpectedError("unknown_error", "Unknown error") + ); + expect(error.isCurrentAccountNotFound()).toBe(false); + }); +}); + +describe("SignOutError", () => { + it("should return true for isUserNotSignedIn when error is NoCachedAccountFoundError", () => { + const error = new SignOutError(new NoCachedAccountFoundError()); + expect(error.isUserNotSignedIn()).toBe(true); + }); + + it("should return false for isUserNotSignedIn when error is not NoCachedAccountFoundError", () => { + const error = new SignOutError( + new UnexpectedError("unknown_error", "Unknown error") + ); + expect(error.isUserNotSignedIn()).toBe(false); + }); +}); diff --git a/lib/msal-browser/test/custom_auth/get_account/interaction_client/CustomAuthSilentCacheClient.spec.ts b/lib/msal-browser/test/custom_auth/get_account/interaction_client/CustomAuthSilentCacheClient.spec.ts new file mode 100644 index 0000000000..bc03b661e3 --- /dev/null +++ b/lib/msal-browser/test/custom_auth/get_account/interaction_client/CustomAuthSilentCacheClient.spec.ts @@ -0,0 +1,455 @@ +import { CustomAuthSilentCacheClient } from "../../../../src/custom_auth/get_account/interaction_client/CustomAuthSilentCacheClient.js"; +import { customAuthConfig } from "../../test_resources/CustomAuthConfig.js"; +import { CustomAuthAuthority } from "../../../../src/custom_auth/core/CustomAuthAuthority.js"; +import { + AccessTokenEntity, + AccountEntity, + CacheHelpers, + CommonSilentFlowRequest, + Constants, + createInteractionRequiredAuthError, + ICrypto, + INetworkModule, + InteractionRequiredAuthErrorCodes, + Logger, + RefreshTokenEntity, + StubPerformanceClient, + TimeUtils, +} from "@azure/msal-common/browser"; +import { + TestTokenResponse, + TestAccountDetails, + TestServerTokenResponse, + TestHomeAccountId, + TestTenantId, + TestIdTokenClaims, + RenewedTokens, +} from "../../test_resources/TestConstants.js"; +import { DefaultScopes } from "../../../../src/custom_auth/CustomAuthConstants.js"; +import { BrowserCacheManager } from "../../../../src/cache/BrowserCacheManager.js"; +import { BrowserConfiguration } from "../../../../src/config/Configuration.js"; +import { INavigationClient } from "../../../../src/navigation/INavigationClient.js"; +import { EventHandler } from "../../../../src/event/EventHandler.js"; +import { CryptoOps } from "../../../../src/crypto/CryptoOps.js"; + +jest.mock("@azure/msal-browser", () => { + const actualModule = jest.requireActual("@azure/msal-browser"); + return { + ...actualModule, + ServerTelemetryManager: jest.fn(), + }; +}); + +describe("CustomAuthSilentCacheClient", () => { + let client: CustomAuthSilentCacheClient; + let mockBrowserConfig: BrowserConfiguration; + let mockCacheManager: BrowserCacheManager; + let mockCrypto: ICrypto; + let mockNetworkModule: INetworkModule; + + const mockNavigationClient = { + navigateExternal: jest.fn(), + } as unknown as jest.Mocked; + + beforeEach(() => { + const serverResponse = { + status: 200, + body: { + token_type: "Bearer", + scope: TestServerTokenResponse.scope, + expires_in: 3600, + ext_expires_in: 3600, + correlation_id: "test-correlation-id", + access_token: RenewedTokens.ACCESS_TOKEN, + refresh_token: RenewedTokens.REFRESH_TOKEN, + id_token: TestTokenResponse.ID_TOKEN, + client_info: TestTokenResponse.CLIENT_INFO, + }, + }; + + mockNetworkModule = { + sendGetRequestAsync: jest.fn(), + sendPostRequestAsync: jest.fn().mockResolvedValue(serverResponse), + } as unknown as jest.Mocked; + + mockBrowserConfig = { + auth: { + clientId: customAuthConfig.auth.clientId, + authority: customAuthConfig.auth.authority, + postLogoutRedirectUri: "http://example.com", + }, + system: { + loggerOptions: { + loggerCallback: jest.fn(), + piiLoggingEnabled: false, + logLevel: 2, + }, + networkClient: mockNetworkModule, + tokenRenewalOffsetSeconds: 300, + }, + cache: { + claimsBasedCachingEnabled: false, + }, + telemetry: {}, + } as unknown as jest.Mocked; + + const mockEventHandler = {} as unknown as jest.Mocked; + const mockPerformanceClient = new StubPerformanceClient(); + const mockedApiClient = {} as unknown as jest.Mocked; + + const mockLogger = { + clone: jest.fn(), + info: jest.fn(), + verbose: jest.fn(), + warning: jest.fn(), + trace: jest.fn(), + tracePii: jest.fn(), + error: jest.fn(), + verbosePii: jest.fn(), + errorPii: jest.fn(), + infoPii: jest.fn(), + } as unknown as jest.Mocked; + + mockLogger.clone.mockReturnValue(mockLogger); + mockCrypto = new CryptoOps(mockLogger); + + mockCacheManager = new BrowserCacheManager( + customAuthConfig.auth.clientId, + mockBrowserConfig.cache, + mockCrypto, + mockLogger, + mockPerformanceClient, + mockEventHandler + ); + + const authority = new CustomAuthAuthority( + customAuthConfig.auth.authority ?? "", + mockBrowserConfig, + mockNetworkModule, + mockCacheManager, + mockLogger, + customAuthConfig.customAuth.authApiProxyUrl + ); + + client = new CustomAuthSilentCacheClient( + mockBrowserConfig, + mockCacheManager, + mockCrypto, + mockLogger, + mockEventHandler, + mockNavigationClient, + mockPerformanceClient, + mockedApiClient, + authority + ); + }); + + afterEach(() => { + jest.clearAllMocks(); // Clear mocks between tests + }); + + describe("getAccessToken", () => { + let accountEntityToCache: AccountEntity; + let accessTokenEntityToCache: AccessTokenEntity; + let refreshTokenEntityToCache: RefreshTokenEntity; + + const defaultScopes = [...DefaultScopes]; + const commonSilentFlowRequest = { + authority: customAuthConfig.auth.authority, + correlationId: "test-correlation-id", + scopes: defaultScopes, + account: TestAccountDetails, + forceRefresh: false, + storeInCache: { + idToken: true, + accessToken: true, + refreshToken: true, + }, + } as CommonSilentFlowRequest; + + beforeEach(() => { + accountEntityToCache = createAccountEntityFromAccountInfo(); + accessTokenEntityToCache = createAccessTokenEntity(mockCrypto); + refreshTokenEntityToCache = createRefreshTokenEntity(); + }); + + afterEach(() => { + mockCacheManager.clear("test-correlation-id"); + }); + + it("should get cached access token successfully and return.", async () => { + await saveTokensIntoCache( + "test-correlation-id", + mockCacheManager, + accountEntityToCache, + accessTokenEntityToCache, + refreshTokenEntityToCache + ); + + const result = await client.acquireToken(commonSilentFlowRequest); + + expect(result).toBeDefined(); + expect(result.accessToken).toBe(accessTokenEntityToCache.secret); + const cachedAccessTokenScopes = + accessTokenEntityToCache.target.split(" "); + expect(result.scopes).toEqual(cachedAccessTokenScopes); + }); + + it("should refresh access token (with valid cached refresh token) when cached access token is invalid.", async () => { + accessTokenEntityToCache.cachedAt = new Date(Date.now() - 1000) + .getTime() + .toString(); + await saveTokensIntoCache( + "test-correlation-id", + mockCacheManager, + accountEntityToCache, + accessTokenEntityToCache, + refreshTokenEntityToCache + ); + + const result = await client.acquireToken(commonSilentFlowRequest); + + expect(result).toBeDefined(); + expect(result.accessToken).toBe(RenewedTokens.ACCESS_TOKEN); + + const refreshTokenKey = mockCacheManager + .getTokenKeys() + .refreshToken.filter((key) => + key.includes(TestHomeAccountId) + )[0]; + const refreshToken = + mockCacheManager.getRefreshTokenCredential(refreshTokenKey); + expect(refreshToken?.secret).toEqual("renewed-refresh-token"); + }); + + it("should renew token when no cached access token found (by giving unmatched scopes)", async () => { + // result in error when fetching access token because given scopes should be subset of cached access token scopes + const unmatchedScope = ["Mail.Read"]; + await saveTokensIntoCache( + "test-correlation-id", + mockCacheManager, + accountEntityToCache, + accessTokenEntityToCache, + refreshTokenEntityToCache + ); + + commonSilentFlowRequest.scopes = unmatchedScope; + + const result = await client.acquireToken(commonSilentFlowRequest); + + expect(result).toBeDefined(); + expect(result.accessToken).toBe(RenewedTokens.ACCESS_TOKEN); + + const refreshTokenKey = mockCacheManager + .getTokenKeys() + .refreshToken.filter((key) => + key.includes(TestHomeAccountId) + )[0]; + const refreshToken = + mockCacheManager.getRefreshTokenCredential(refreshTokenKey); + expect(refreshToken?.secret).toEqual("renewed-refresh-token"); + }); + + it("should skip cache lookup and refresh access token when refreshForced is true", async () => { + await saveTokensIntoCache( + "test-correlation-id", + mockCacheManager, + accountEntityToCache, + accessTokenEntityToCache, + refreshTokenEntityToCache + ); + + commonSilentFlowRequest.forceRefresh = true; + + const result = await client.acquireToken(commonSilentFlowRequest); + + expect(result).toBeDefined(); + expect(result.accessToken).toBe(RenewedTokens.ACCESS_TOKEN); + + const refreshTokenKey = mockCacheManager + .getTokenKeys() + .refreshToken.filter((key) => + key.includes(TestHomeAccountId) + )[0]; + const refreshToken = + mockCacheManager.getRefreshTokenCredential(refreshTokenKey); + expect(refreshToken?.secret).toEqual("renewed-refresh-token"); + }); + + it("should throw error when refresh token is not found", async () => { + await saveTokensIntoCache( + "test-correlation-id", + mockCacheManager, + accountEntityToCache, + accessTokenEntityToCache + ); + + const mockNoTokensFoundError = createInteractionRequiredAuthError( + InteractionRequiredAuthErrorCodes.noTokensFound + ); + + commonSilentFlowRequest.forceRefresh = true; + + expect( + client.acquireToken(commonSilentFlowRequest) + ).rejects.toThrow(mockNoTokensFoundError); + }); + + it("should throw error when refresh token is expired", async () => { + refreshTokenEntityToCache.expiresOn = + TimeUtils.nowSeconds().toString(); + await saveTokensIntoCache( + "test-correlation-id", + mockCacheManager, + accountEntityToCache, + accessTokenEntityToCache, + refreshTokenEntityToCache + ); + + const mockRefreshTokenExpiredError = + createInteractionRequiredAuthError( + InteractionRequiredAuthErrorCodes.refreshTokenExpired + ); + + commonSilentFlowRequest.forceRefresh = true; + + expect( + client.acquireToken(commonSilentFlowRequest) + ).rejects.toThrow(mockRefreshTokenExpiredError); + }); + }); + + describe("getCurrentAccount", () => { + it("should return account from cache", () => { + jest.spyOn(mockCacheManager, "getAllAccounts").mockReturnValue([ + { + homeAccountId: "test-home-account-id", + environment: "test-environment", + tenantId: "test-tenant-id", + username: "test-username", + localAccountId: "test-local-account-id", + }, + { + homeAccountId: "test-home-account-id-2", + environment: "test-environment-2", + tenantId: "test-tenant-id-2", + username: "test-username-2", + localAccountId: "test-local-account-id-2", + }, + ]); + + const account = client.getCurrentAccount("test-corrlation-id"); + + expect(account).toBeDefined(); + expect(account?.homeAccountId).toBe("test-home-account-id"); + expect(account?.tenantId).toBe("test-tenant-id"); + expect(account?.username).toBe("test-username"); + expect(account?.localAccountId).toBe("test-local-account-id"); + expect(account?.environment).toBe("test-environment"); + }); + + it("should return null if no account found", () => { + jest.spyOn(mockCacheManager, "getAllAccounts").mockReturnValue([]); + + const account = client.getCurrentAccount("test-corrlation-id"); + + expect(account).toBe(null); + }); + }); + + describe("logout", () => { + it("should logout successfully", async () => { + jest.spyOn(mockCacheManager, "getActiveAccount").mockReturnValue({ + homeAccountId: "test-home-account-id-2", + environment: "test-environment-2", + tenantId: "test-tenant-id-2", + username: "test-username-2", + localAccountId: "test-local-account-id-2", + }); + + jest.spyOn(mockCacheManager, "removeAccount"); + + await client.logout({ + account: { + homeAccountId: "test-home-account-id", + environment: "test-environment", + tenantId: "test-tenant-id", + username: "test-username", + localAccountId: "test-local-account-id", + }, + }); + + expect(mockCacheManager.removeAccount).toHaveBeenCalled(); + expect(mockNavigationClient.navigateExternal).toHaveBeenCalled(); + }); + }); +}); + +async function saveTokensIntoCache( + correlationId: string, + cacheManager: BrowserCacheManager, + accountEntity?: AccountEntity, + accessTokenEntity?: AccessTokenEntity, + refreshTokenEntity?: RefreshTokenEntity +): Promise { + accountEntity + ? await cacheManager.setAccount(accountEntity, correlationId) + : null; + accessTokenEntity + ? await cacheManager.setAccessTokenCredential( + accessTokenEntity, + correlationId + ) + : null; + refreshTokenEntity + ? await cacheManager.setRefreshTokenCredential( + refreshTokenEntity, + correlationId + ) + : null; +} + +function createAccessTokenEntity(browserCrypto: ICrypto): AccessTokenEntity { + const expiresOn = new Date( + Date.now() + TestServerTokenResponse.expires_in * 1000 + ).getTime(); + + return CacheHelpers.createAccessTokenEntity( + TestHomeAccountId, + TestAccountDetails.environment, + TestTokenResponse.ACCESS_TOKEN, + customAuthConfig.auth.clientId, + TestTenantId, + TestServerTokenResponse.scope, + expiresOn, + expiresOn + 0, + browserCrypto.base64Decode, + undefined, + TestServerTokenResponse.token_type as Constants.AuthenticationScheme + ); +} + +function createRefreshTokenEntity(): RefreshTokenEntity { + return CacheHelpers.createRefreshTokenEntity( + TestHomeAccountId, + TestAccountDetails.environment, + TestServerTokenResponse.refresh_token, + customAuthConfig.auth.clientId + ); +} + +function createAccountEntityFromAccountInfo(): AccountEntity { + console.log( + "🎯 DEBUG: Local createAccountEntityFromAccountInfo called in test!" + ); + + return { + authorityType: Constants.CACHE_ACCOUNT_TYPE_GENERIC, + homeAccountId: TestAccountDetails.homeAccountId, + localAccountId: TestAccountDetails.localAccountId, + realm: TestAccountDetails.tenantId, + environment: TestAccountDetails.environment, + username: TestAccountDetails.username, + name: TestAccountDetails.name, + } as AccountEntity; +} diff --git a/lib/msal-browser/test/custom_auth/integration_tests/GetAccount.spec.ts b/lib/msal-browser/test/custom_auth/integration_tests/GetAccount.spec.ts new file mode 100644 index 0000000000..d25374f7eb --- /dev/null +++ b/lib/msal-browser/test/custom_auth/integration_tests/GetAccount.spec.ts @@ -0,0 +1,181 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthPublicClientApplication } from "../../../src/custom_auth/CustomAuthPublicClientApplication.js"; +import { ICustomAuthPublicClientApplication } from "../../../src/custom_auth/ICustomAuthPublicClientApplication.js"; +import { customAuthConfig } from "../test_resources/CustomAuthConfig.js"; +import { CustomAuthAccountData } from "../../../src/custom_auth/get_account/auth_flow/CustomAuthAccountData.js"; +import { + TestHomeAccountId, + TestTenantId, + TestTokenResponse, + TestUsername, +} from "../test_resources/TestConstants.js"; +import { CustomAuthStandardController } from "../../../src/custom_auth/controller/CustomAuthStandardController.js"; + +describe("GetAccount", () => { + let app: CustomAuthPublicClientApplication; + + beforeEach(async () => { + app = (await CustomAuthPublicClientApplication.create( + customAuthConfig + )) as CustomAuthPublicClientApplication; + + global.fetch = jest.fn(); // Mock the fetch API + }); + + afterEach(() => { + const controller = app[ + "customAuthController" + ] as CustomAuthStandardController; + if ( + controller && + controller["eventHandler"] && + controller["eventHandler"]["broadcastChannel"] + ) { + controller["eventHandler"]["broadcastChannel"].close(); + } + + jest.clearAllMocks(); // Clear mocks between tests + }); + + describe("GetAccount", () => { + it("should return correct account data after the sign-in is successful", async () => { + await signIn(app); + + const accountData = app.getCurrentAccount({ + correlationId: "test-correlation-id", + }); + + expect(accountData).toBeDefined(); + expect(accountData.error).toBeUndefined(); + expect(accountData.isCompleted()).toBe(true); + expect(accountData.data).toBeDefined(); + expect(accountData.data).toBeInstanceOf(CustomAuthAccountData); + expect(accountData.data?.getAccount()).toBeDefined(); + + const accountInfo = accountData.data?.getAccount(); + + expect(accountInfo?.homeAccountId).toStrictEqual(TestHomeAccountId); + expect(accountInfo?.tenantId).toStrictEqual(TestTenantId); + expect(accountInfo?.username).toStrictEqual(TestUsername); + + await accountData.data?.signOut(); + }); + + it("should return error data if the account is not found", async () => { + const accountData = app.getCurrentAccount({ + correlationId: "test-correlation-id", + }); + + expect(accountData).toBeDefined(); + expect(accountData.error).toBeDefined(); + expect(accountData.error?.errorData).toBeDefined(); + expect(accountData.error?.isCurrentAccountNotFound()).toBe(true); + expect(accountData.isFailed()).toBe(true); + expect(accountData.data).toBeUndefined(); + }); + }); + + describe("SignOut", () => { + it("should sign the user out after the sign-in is successful", async () => { + await signIn(app); + + const result = app.getCurrentAccount({ + correlationId: "test-correlation-id", + }); + + const accountData = result.data; + + expect(accountData).toBeDefined(); + + const signOutResult = await accountData?.signOut(); + + expect(signOutResult).toBeDefined(); + expect(signOutResult?.error).toBeUndefined(); + expect(signOutResult?.isCompleted()).toBe(true); + + const accountResultAfterSignOut = app.getCurrentAccount({ + correlationId: "test-correlation-id", + }); + + expect(accountResultAfterSignOut).toBeDefined(); + expect(accountResultAfterSignOut.error).toBeDefined(); + expect( + accountResultAfterSignOut.error?.isCurrentAccountNotFound() + ).toBe(true); + }); + + it("should return error data if try to sign out an user who is not signed in", async () => { + await signIn(app); + + const result = app.getCurrentAccount({ + correlationId: "test-correlation-id", + }); + + await result.data?.signOut(); + + const accountData = result.data; + const signOutResult = await accountData?.signOut(); + + expect(signOutResult).toBeDefined(); + expect(signOutResult?.error).toBeDefined(); + expect(signOutResult?.isFailed()).toBe(true); + expect(signOutResult?.error?.isUserNotSignedIn()).toBe(true); + }); + }); +}); + +async function signIn(app: ICustomAuthPublicClientApplication): Promise { + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-1", + challenge_type: "oob password redirect", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-2", + challenge_type: "password", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: "test-correlation-id", + token_type: "Bearer", + scopes: "test-scope", + expires_in: 3600, + id_token: TestTokenResponse.ID_TOKEN, + access_token: TestTokenResponse.ACCESS_TOKEN, + refresh_token: TestTokenResponse.REFRESH_TOKEN, + client_info: TestTokenResponse.CLIENT_INFO, + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + const signInInputs = { + username: "abc@test.com", + password: "test-pwd", + correlationId: "test-correlation-id", + }; + + await app.signIn(signInInputs); +} diff --git a/lib/msal-browser/test/custom_auth/integration_tests/ResetPassword.spec.ts b/lib/msal-browser/test/custom_auth/integration_tests/ResetPassword.spec.ts new file mode 100644 index 0000000000..85a29ee1e8 --- /dev/null +++ b/lib/msal-browser/test/custom_auth/integration_tests/ResetPassword.spec.ts @@ -0,0 +1,279 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthAccountData } from "../../../src/custom_auth/get_account/auth_flow/CustomAuthAccountData.js"; +import { CustomAuthPublicClientApplication } from "../../../src/custom_auth/CustomAuthPublicClientApplication.js"; +import { ResetPasswordStartResult } from "../../../src/custom_auth/reset_password/auth_flow/result/ResetPasswordStartResult.js"; +import { ResetPasswordSubmitCodeResult } from "../../../src/custom_auth/reset_password/auth_flow/result/ResetPasswordSubmitCodeResult.js"; +import { ResetPasswordSubmitPasswordResult } from "../../../src/custom_auth/reset_password/auth_flow/result/ResetPasswordSubmitPasswordResult.js"; +import { customAuthConfig } from "../test_resources/CustomAuthConfig.js"; +import { SignInResult } from "../../../src/custom_auth/sign_in/auth_flow/result/SignInResult.js"; +import { CustomAuthStandardController } from "../../../src/custom_auth/controller/CustomAuthStandardController.js"; +import { ResetPasswordCodeRequiredState } from "../../../src/custom_auth/reset_password/auth_flow/state/ResetPasswordCodeRequiredState.js"; +import { ResetPasswordPasswordRequiredState } from "../../../src/custom_auth/reset_password/auth_flow/state/ResetPasswordPasswordRequiredState.js"; +import { ResetPasswordCompletedState } from "../../../src/custom_auth/reset_password/auth_flow/state/ResetPasswordCompletedState.js"; + +jest.mock("@azure/msal-common/browser", () => { + const actualModule = jest.requireActual("@azure/msal-common/browser"); + return { + ...actualModule, + ResponseHandler: jest.fn().mockImplementation(() => ({ + handleServerTokenResponse: jest.fn().mockResolvedValue({ + uniqueId: "test-unique-id", + tenantId: "test-tenant-id", + scopes: ["test-scope"], + account: { + homeAccountId: "test-home-account-id", + environment: "test-environment", + tenantId: "test-tenant-id", + username: "test-username", + idToken: "test-id-token", + }, + idToken: "test-id-token", + idTokenClaims: {}, + accessToken: "test-access-token", + refreshToken: "test-refresh-token", + expiresOn: new Date(), + extExpiresOn: new Date(), + }), + })), + }; +}); + +describe("Reset password", () => { + let app: CustomAuthPublicClientApplication; + const correlationId = "test-correlation-id"; + + beforeEach(async () => { + app = (await CustomAuthPublicClientApplication.create( + customAuthConfig + )) as CustomAuthPublicClientApplication; + + global.fetch = jest.fn(); // Mock the fetch API + }); + + afterEach(() => { + const controller = app[ + "customAuthController" + ] as CustomAuthStandardController; + if ( + controller && + controller["eventHandler"] && + controller["eventHandler"]["broadcastChannel"] + ) { + controller["eventHandler"]["broadcastChannel"].close(); + } + + jest.clearAllMocks(); // Clear mocks between tests + }); + + it("should reset password successfully if the new password is valid", async () => { + (fetch as jest.Mock) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-1", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-2", + challenge_type: "oob", + binding_method: "prompt", + challenge_channel: "email", + challenge_target_label: "s****n@o*********m", + code_length: 8, + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-3", + expires_in: 600, + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-4", + poll_interval: 1, + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + status: "in_progress", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + status: "in_progress", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-5", + status: "succeeded", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + token_type: "Bearer", + scopes: "test-scope", + expires_in: 3600, + id_token: "test-id-token", + access_token: "test-access-token", + refresh_token: "test-refresh-token", + client_info: "test-client-info", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + const resetPasswordInputs = { + username: "test@test.com", + correlationId: correlationId, + }; + + const startResult = await app.resetPassword(resetPasswordInputs); + + expect(startResult).toBeInstanceOf(ResetPasswordStartResult); + expect(startResult.error).toBeUndefined(); + expect(startResult.isCodeRequired()).toBe(true); + + const submitCodeResult = await ( + startResult.state as ResetPasswordCodeRequiredState + ).submitCode("12345678"); + + expect(submitCodeResult).toBeInstanceOf(ResetPasswordSubmitCodeResult); + expect(submitCodeResult.error).toBeUndefined(); + expect(submitCodeResult.isPasswordRequired()).toBe(true); + + const submitPasswordResult = await ( + submitCodeResult.state as ResetPasswordPasswordRequiredState + ).submitNewPassword("valid-password"); + + expect(submitPasswordResult).toBeInstanceOf( + ResetPasswordSubmitPasswordResult + ); + expect(submitPasswordResult.error).toBeUndefined(); + expect(submitPasswordResult.isCompleted()).toBe(true); + + const signInResult = await ( + submitPasswordResult.state as ResetPasswordCompletedState + ).signIn(); + + expect(signInResult).toBeInstanceOf(SignInResult); + expect(signInResult.error).toBeUndefined(); + expect(signInResult.isCompleted()).toBe(true); + expect(signInResult.data).toBeDefined(); + expect(signInResult.data).toBeInstanceOf(CustomAuthAccountData); + expect(signInResult.data?.getAccount()?.idToken).toStrictEqual( + "test-id-token" + ); + }); + + it("should reset password failed if the redirect challenge returned", async () => { + (fetch as jest.Mock) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-1", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + challenge_type: "redirect", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + const resetPasswordInputs = { + username: "test@test.com", + correlationId: correlationId, + }; + + const startResult = await app.resetPassword(resetPasswordInputs); + + expect(startResult).toBeInstanceOf(ResetPasswordStartResult); + expect(startResult.error).toBeDefined(); + expect(startResult.isFailed()).toBe(true); + expect(startResult.error?.isRedirectRequired()).toBe(true); + }); + + it("should reset password failed if the given user is not found", async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 400, + json: async () => { + return { + error: "user_not_found", + error_description: + "The user account could not be found. Please check the username and try again.", + error_codes: [1003037], + timestamp: "yyyy-mm-dd 10:15:00Z", + trace_id: "test-trace-id", + correlation_id: correlationId, + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }); + + const resetPasswordInputs = { + username: "test@test.com", + correlationId: correlationId, + }; + + const startResult = await app.resetPassword(resetPasswordInputs); + + expect(startResult).toBeInstanceOf(ResetPasswordStartResult); + expect(startResult.error).toBeDefined(); + expect(startResult.isFailed()).toBe(true); + expect(startResult.error?.isUserNotFound()).toBe(true); + }); +}); diff --git a/lib/msal-browser/test/custom_auth/integration_tests/SignIn.spec.ts b/lib/msal-browser/test/custom_auth/integration_tests/SignIn.spec.ts new file mode 100644 index 0000000000..49a63d11e4 --- /dev/null +++ b/lib/msal-browser/test/custom_auth/integration_tests/SignIn.spec.ts @@ -0,0 +1,438 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthPublicClientApplication } from "../../../src/custom_auth/CustomAuthPublicClientApplication.js"; +import { SignInResult } from "../../../src/custom_auth/sign_in/auth_flow/result/SignInResult.js"; +import { SignInSubmitCodeResult } from "../../../src/custom_auth/sign_in/auth_flow/result/SignInSubmitCodeResult.js"; +import { SignInSubmitPasswordResult } from "../../../src/custom_auth/sign_in/auth_flow/result/SignInSubmitPasswordResult.js"; +import { customAuthConfig } from "../test_resources/CustomAuthConfig.js"; +import { CustomAuthAccountData } from "../../../src/custom_auth/get_account/auth_flow/CustomAuthAccountData.js"; +import { CustomAuthStandardController } from "../../../src/custom_auth/controller/CustomAuthStandardController.js"; +import { SignInCodeRequiredState } from "../../../src/custom_auth/sign_in/auth_flow/state/SignInCodeRequiredState.js"; +import { SignInPasswordRequiredState } from "../../../src/custom_auth/sign_in/auth_flow/state/SignInPasswordRequiredState.js"; + +jest.mock("@azure/msal-common/browser", () => { + const actualModule = jest.requireActual("@azure/msal-common/browser"); + return { + ...actualModule, + ResponseHandler: jest.fn().mockImplementation(() => ({ + handleServerTokenResponse: jest.fn().mockResolvedValue({ + uniqueId: "test-unique-id", + tenantId: "test-tenant-id", + scopes: ["test-scope"], + account: { + homeAccountId: "test-home-account-id", + environment: "test-environment", + tenantId: "test-tenant-id", + username: "test-username", + }, + idToken: "test-id-token", + idTokenClaims: {}, + accessToken: "test-access-token", + refreshToken: "test-refresh-token", + expiresOn: new Date(), + extExpiresOn: new Date(), + }), + })), + }; +}); + +describe("Sign in", () => { + let app: CustomAuthPublicClientApplication; + const correlationId = "test-correlation-id"; + + beforeEach(async () => { + app = (await CustomAuthPublicClientApplication.create( + customAuthConfig + )) as CustomAuthPublicClientApplication; + + global.fetch = jest.fn(); // Mock the fetch API + }); + + afterEach(() => { + const controller = app[ + "customAuthController" + ] as CustomAuthStandardController; + if ( + controller && + controller["eventHandler"] && + controller["eventHandler"]["broadcastChannel"] + ) { + controller["eventHandler"]["broadcastChannel"].close(); + } + + jest.clearAllMocks(); // Clear mocks between tests + }); + + it("should sign in successfully if the challenge type is password and password is provided initially", async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-1", + challenge_type: "oob password redirect", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-2", + challenge_type: "password", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + token_type: "Bearer", + scopes: "test-scope", + expires_in: 3600, + id_token: "test-id-token", + access_token: "test-access-token", + refresh_token: "test-refresh-token", + client_info: "test-client-info", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + const signInInputs = { + username: "test@test.com", + password: "password", + correlationId: correlationId, + }; + + const result = await app.signIn(signInInputs); + + expect(result).toBeInstanceOf(SignInResult); + expect(result.error).toBeUndefined(); + expect(result.isCompleted()).toBe(true); + expect(result.data).toBeDefined(); + expect(result.data).toBeInstanceOf(CustomAuthAccountData); + }); + + it("should sign in successfully if the challenge type is oob", async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + continuation_token: "test-continuation-token-1", + challenge_type: "oob password redirect", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + continuation_token: "test-continuation-token-2", + challenge_type: "oob", + code_length: 8, + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + token_type: "Bearer", + scopes: "test-scope", + expires_in: 3600, + id_token: "test-id-token", + access_token: "test-access-token", + refresh_token: "test-refresh-token", + client_info: "test-client-info", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + const signInInputs = { + username: "test@test.com", + correlationId: correlationId, + }; + + const signInResult = await app.signIn(signInInputs); + + expect(signInResult).toBeInstanceOf(SignInResult); + expect(signInResult.error).toBeUndefined(); + expect(signInResult.isCodeRequired()).toBe(true); + + const state = signInResult.state as SignInCodeRequiredState; + const submitCodeResult = await state.submitCode("12345678"); + + expect(submitCodeResult).toBeDefined(); + expect(submitCodeResult).toBeInstanceOf(SignInSubmitCodeResult); + expect(submitCodeResult.data).toBeInstanceOf(CustomAuthAccountData); + }); + + it("should sign in successfully if the challenge type is password", async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + continuation_token: "test-continuation-token-1", + challenge_type: "oob password redirect", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + continuation_token: "test-continuation-token-2", + challenge_type: "password", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + token_type: "Bearer", + scopes: "test-scope", + expires_in: 3600, + id_token: "test-id-token", + access_token: "test-access-token", + refresh_token: "test-refresh-token", + client_info: "test-client-info", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + const signInInputs = { + username: "test@test.com", + correlationId: correlationId, + }; + + const signInResult = await app.signIn(signInInputs); + + expect(signInResult).toBeInstanceOf(SignInResult); + expect(signInResult.error).toBeUndefined(); + expect(signInResult.isPasswordRequired()).toBe(true); + + const state = signInResult.state as SignInPasswordRequiredState; + + const submitCodeResult = await state.submitPassword("valid-password"); + + expect(submitCodeResult).toBeDefined(); + expect(submitCodeResult).toBeInstanceOf(SignInSubmitPasswordResult); + expect(submitCodeResult.data).toBeInstanceOf(CustomAuthAccountData); + }); + + it("should sign in failed with error if the challenge type is redirect", async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + continuation_token: "test-continuation-token-1", + challenge_type: "oob password redirect", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + challenge_type: "redirect", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + const signInInputs = { + username: "test@test.com", + correlationId: correlationId, + }; + + const signInResult = await app.signIn(signInInputs); + + expect(signInResult).toBeInstanceOf(SignInResult); + expect(signInResult.error).toBeDefined(); + expect(signInResult.isFailed()).toBe(true); + expect(signInResult.error?.isRedirectRequired()).toBe(true); + }); + + it("should sign in failed with error if the given user is not found", async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 400, + json: async () => { + return { + error: "user_not_found", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }); + + const signInInputs = { + username: "test@test.com", + correlationId: correlationId, + }; + + const signInResult = await app.signIn(signInInputs); + + expect(signInResult).toBeInstanceOf(SignInResult); + expect(signInResult.error).toBeDefined(); + expect(signInResult.isFailed()).toBe(true); + expect(signInResult.error?.isUserNotFound()).toBe(true); + }); + + it("should sign in failed if the challenge type is password but given password is incorrect", async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + continuation_token: "test-continuation-token-1", + challenge_type: "oob password redirect", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + continuation_token: "test-continuation-token-2", + challenge_type: "password", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 400, + json: async () => { + return { + error: "invalid_grant", + error_description: + "AADSTS901007: Error validating credentials due to invalid username or password.", + error_codes: [50126], + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }); + + const signInInputs = { + username: "test@test.com", + correlationId: correlationId, + password: "invalid-password", + }; + + const signInResult = await app.signIn(signInInputs); + + expect(signInResult).toBeInstanceOf(SignInResult); + expect(signInResult.error).toBeDefined(); + expect(signInResult.isFailed()).toBe(true); + expect(signInResult.error?.isPasswordIncorrect()).toBe(true); + }); + + it("should sign in failed if the challenge type is oob but given code is incorrect", async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + continuation_token: "test-continuation-token-1", + challenge_type: "oob password redirect", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + continuation_token: "test-continuation-token-2", + challenge_type: "oob", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 400, + json: async () => { + return { + error: "invalid_grant", + error_description: + "AADSTS901007: Error validating credentials due to invalid username or password.", + error_codes: [], + suberror: "invalid_oob_value", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }); + + const signInInputs = { + username: "test@test.com", + correlationId: correlationId, + }; + + const signInResult = await app.signIn(signInInputs); + + expect(signInResult).toBeInstanceOf(SignInResult); + expect(signInResult.error).toBeUndefined(); + expect(signInResult.isCodeRequired()).toBe(true); + + const state = signInResult.state as SignInCodeRequiredState; + + const submitCodeResult = await state.submitCode("invalid-code"); + + expect(submitCodeResult).toBeDefined(); + expect(submitCodeResult).toBeInstanceOf(SignInSubmitCodeResult); + expect(submitCodeResult.error).toBeDefined(); + expect(submitCodeResult.error?.isInvalidCode()).toBe(true); + }); +}); diff --git a/lib/msal-browser/test/custom_auth/integration_tests/SignUp.spec.ts b/lib/msal-browser/test/custom_auth/integration_tests/SignUp.spec.ts new file mode 100644 index 0000000000..264a4e69f7 --- /dev/null +++ b/lib/msal-browser/test/custom_auth/integration_tests/SignUp.spec.ts @@ -0,0 +1,698 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { CustomAuthAccountData } from "../../../src/custom_auth/get_account/auth_flow/CustomAuthAccountData.js"; +import { CustomAuthPublicClientApplication } from "../../../src/custom_auth/CustomAuthPublicClientApplication.js"; +import { SignUpSubmitCodeResult } from "../../../src/custom_auth/sign_up/auth_flow/result/SignUpSubmitCodeResult.js"; +import { SignUpSubmitPasswordResult } from "../../../src/custom_auth/sign_up/auth_flow/result/SignUpSubmitPasswordResult.js"; +import { customAuthConfig } from "../test_resources/CustomAuthConfig.js"; +import { SignInResult } from "../../../src/custom_auth/sign_in/auth_flow/result/SignInResult.js"; +import { SignUpInputs } from "../../../src/custom_auth/CustomAuthActionInputs.js"; +import { UserAccountAttributes } from "../../../src/custom_auth/UserAccountAttributes.js"; +import { SignUpResult } from "../../../src/custom_auth/sign_up/auth_flow/result/SignUpResult.js"; +import { SignUpSubmitAttributesResult } from "../../../src/custom_auth/sign_up/auth_flow/result/SignUpSubmitAttributesResult.js"; +import { CustomAuthStandardController } from "../../../src/custom_auth/controller/CustomAuthStandardController.js"; +import { SignUpCodeRequiredState } from "../../../src/custom_auth/sign_up/auth_flow/state/SignUpCodeRequiredState.js"; +import { SignUpCompletedState } from "../../../src/custom_auth/sign_up/auth_flow/state/SignUpCompletedState.js"; +import { SignUpPasswordRequiredState } from "../../../src/custom_auth/sign_up/auth_flow/state/SignUpPasswordRequiredState.js"; +import { SignUpAttributesRequiredState } from "../../../src/custom_auth/sign_up/auth_flow/state/SignUpAttributesRequiredState.js"; + +jest.mock("@azure/msal-common/browser", () => { + const actualModule = jest.requireActual("@azure/msal-common/browser"); + return { + ...actualModule, + ResponseHandler: jest.fn().mockImplementation(() => ({ + handleServerTokenResponse: jest.fn().mockResolvedValue({ + uniqueId: "test-unique-id", + tenantId: "test-tenant-id", + scopes: ["test-scope"], + account: { + homeAccountId: "test-home-account-id", + environment: "test-environment", + tenantId: "test-tenant-id", + username: "test-username", + idToken: "test-id-token", + }, + idToken: "test-id-token", + idTokenClaims: {}, + accessToken: "test-access-token", + refreshToken: "test-refresh-token", + expiresOn: new Date(), + extExpiresOn: new Date(), + }), + })), + }; +}); + +describe("Sign up", () => { + let app: CustomAuthPublicClientApplication; + const correlationId = "test-correlation-id"; + + beforeEach(async () => { + app = (await CustomAuthPublicClientApplication.create( + customAuthConfig + )) as CustomAuthPublicClientApplication; + + global.fetch = jest.fn(); // Mock the fetch API + }); + + afterEach(() => { + const controller = app[ + "customAuthController" + ] as CustomAuthStandardController; + if ( + controller && + controller["eventHandler"] && + controller["eventHandler"]["broadcastChannel"] + ) { + controller["eventHandler"]["broadcastChannel"].close(); + } + + jest.clearAllMocks(); // Clear mocks between tests + }); + + it("should sign up successfully if no password is provided when starting the password reset", async () => { + (fetch as jest.Mock) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-1", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-2", + challenge_type: "oob", + binding_method: "prompt", + challenge_channel: "email", + challenge_target_label: "s****n@o*********m", + code_length: 8, + interval: 300, + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 400, + json: async () => { + return { + continuation_token: "test-continuation-token-4", + error: "credential_required", + error_description: "Credential required.", + error_codes: [55103], + timestamp: "yy-mm-dd 02:37:33Z", + trace_id: "test-trace-id", + correlation_id: correlationId, + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-5", + challenge_type: "password", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-6", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + token_type: "Bearer", + scopes: "test-scope", + expires_in: 3600, + id_token: "test-id-token", + access_token: "test-access-token", + refresh_token: "test-refresh-token", + client_info: "test-client-info", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + const attributes: UserAccountAttributes = { + city: "test-city", + }; + + const signUpInputs: SignUpInputs = { + username: "test@test.com", + correlationId: correlationId, + attributes: attributes, + }; + + const startResult = await app.signUp(signUpInputs); + + expect(startResult).toBeInstanceOf(SignUpResult); + expect(startResult.error).toBeUndefined(); + expect(startResult.isCodeRequired()).toBe(true); + + const submitCodeResult = await ( + startResult.state as SignUpCodeRequiredState + ).submitCode("12345678"); + + expect(submitCodeResult).toBeInstanceOf(SignUpSubmitCodeResult); + expect(submitCodeResult.error).toBeUndefined(); + expect(submitCodeResult.isPasswordRequired()).toBe(true); + + const submitPasswordResult = await ( + submitCodeResult.state as SignUpPasswordRequiredState + ).submitPassword("valid-password"); + + expect(submitPasswordResult).toBeInstanceOf(SignUpSubmitPasswordResult); + expect(submitPasswordResult.error).toBeUndefined(); + expect(submitPasswordResult.isCompleted()).toBe(true); + + const signInResult = await ( + submitPasswordResult.state as SignUpCompletedState + ).signIn(); + + expect(signInResult).toBeInstanceOf(SignInResult); + expect(signInResult.error).toBeUndefined(); + expect(signInResult.isCompleted()).toBe(true); + expect(signInResult.data).toBeDefined(); + expect(signInResult.data).toBeInstanceOf(CustomAuthAccountData); + expect(signInResult.data?.getAccount()?.idToken).toStrictEqual( + "test-id-token" + ); + }); + + it("should sign up successfully if attributes are required after starting the password reset", async () => { + (fetch as jest.Mock) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-1", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-2", + challenge_type: "oob", + binding_method: "prompt", + challenge_channel: "email", + challenge_target_label: "s****n@o*********m", + code_length: 8, + interval: 300, + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 400, + json: async () => { + return { + error: "attributes_required", + error_description: "User attributes required", + error_codes: [55106], + timestamp: "yy-mm-dd 02:37:33Z", + trace_id: "test-trace-id", + correlation_id: correlationId, + continuation_token: "test-continuation-token-3", + required_attributes: [ + { + name: "displayName", + type: "string", + required: true, + options: { + regex: ".*@.**$", + }, + }, + { + name: "extension_2588abcdwhtfeehjjeeqwertc_age", + type: "string", + required: true, + }, + { + name: "postalCode", + type: "string", + required: true, + options: { + regex: "^[1-9][0-9]*$", + }, + }, + ], + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-4", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + token_type: "Bearer", + scopes: "test-scope", + expires_in: 3600, + id_token: "test-id-token", + access_token: "test-access-token", + refresh_token: "test-refresh-token", + client_info: "test-client-info", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + const attributes: UserAccountAttributes = { + city: "test-city", + }; + + const signUpInputs: SignUpInputs = { + username: "test@test.com", + correlationId: correlationId, + attributes: attributes, + }; + + const startResult = await app.signUp(signUpInputs); + + expect(startResult).toBeInstanceOf(SignUpResult); + expect(startResult.error).toBeUndefined(); + expect(startResult.isCodeRequired()).toBe(true); + + const submitCodeResult = await ( + startResult.state as SignUpCodeRequiredState + ).submitCode("12345678"); + + expect(submitCodeResult).toBeInstanceOf(SignUpSubmitCodeResult); + expect(submitCodeResult.error).toBeUndefined(); + expect(submitCodeResult.isAttributesRequired()).toBe(true); + expect( + ( + submitCodeResult.state as SignUpAttributesRequiredState + )?.getRequiredAttributes().length + ).toBe(3); + + const requiredAttributes: UserAccountAttributes = { + displayName: "test-display-name", + }; + const submitAttributesResult = await ( + submitCodeResult.state as SignUpAttributesRequiredState + ).submitAttributes(requiredAttributes); + + expect(submitAttributesResult).toBeInstanceOf( + SignUpSubmitAttributesResult + ); + expect(submitAttributesResult.error).toBeUndefined(); + expect(submitAttributesResult.isCompleted()).toBe(true); + + const signInResult = await ( + submitAttributesResult.state as SignUpCompletedState + ).signIn(); + + expect(signInResult).toBeInstanceOf(SignInResult); + expect(signInResult.error).toBeUndefined(); + expect(signInResult.isCompleted()).toBe(true); + expect(signInResult.data).toBeDefined(); + expect(signInResult.data).toBeInstanceOf(CustomAuthAccountData); + expect(signInResult.data?.getAccount()?.idToken).toStrictEqual( + "test-id-token" + ); + }); + + it("should sign up successfully if password and attributes are required after starting the password reset", async () => { + (fetch as jest.Mock) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-1", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-2", + challenge_type: "oob", + binding_method: "prompt", + challenge_channel: "email", + challenge_target_label: "s****n@o*********m", + code_length: 8, + interval: 300, + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 400, + json: async () => { + return { + continuation_token: "test-continuation-token-3", + error: "credential_required", + error_description: "Credential required.", + error_codes: [55103], + timestamp: "yy-mm-dd 02:37:33Z", + trace_id: "test-trace-id", + correlation_id: correlationId, + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-4", + challenge_type: "password", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 400, + json: async () => { + return { + error: "attributes_required", + error_description: "User attributes required", + error_codes: [55106], + timestamp: "yy-mm-dd 02:37:33Z", + trace_id: "test-trace-id", + correlation_id: correlationId, + continuation_token: "test-continuation-token-5", + required_attributes: [ + { + name: "displayName", + type: "string", + required: true, + options: { + regex: ".*@.**$", + }, + }, + { + name: "extension_2588abcdwhtfeehjjeeqwertc_age", + type: "string", + required: true, + }, + { + name: "postalCode", + type: "string", + required: true, + options: { + regex: "^[1-9][0-9]*$", + }, + }, + ], + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-6", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + token_type: "Bearer", + scopes: "test-scope", + expires_in: 3600, + id_token: "test-id-token", + access_token: "test-access-token", + refresh_token: "test-refresh-token", + client_info: "test-client-info", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + const attributes: UserAccountAttributes = { + city: "test-city", + }; + + const signUpInputs: SignUpInputs = { + username: "test@test.com", + correlationId: correlationId, + attributes: attributes, + }; + + const startResult = await app.signUp(signUpInputs); + + expect(startResult).toBeInstanceOf(SignUpResult); + expect(startResult.error).toBeUndefined(); + expect(startResult.isCodeRequired()).toBe(true); + + const submitCodeResult = await ( + startResult.state as SignUpCodeRequiredState + ).submitCode("12345678"); + + expect(submitCodeResult).toBeInstanceOf(SignUpSubmitCodeResult); + expect(submitCodeResult.error).toBeUndefined(); + expect(submitCodeResult.isPasswordRequired()).toBe(true); + + const submitPasswordResult = await ( + submitCodeResult.state as SignUpPasswordRequiredState + ).submitPassword("valid-password"); + + expect(submitPasswordResult).toBeInstanceOf(SignUpSubmitPasswordResult); + expect(submitPasswordResult.error).toBeUndefined(); + expect(submitPasswordResult.isAttributesRequired()).toBe(true); + + const requiredAttributes: UserAccountAttributes = { + displayName: "test-display-name", + }; + const submitAttributesResult = await ( + submitPasswordResult.state as SignUpAttributesRequiredState + ).submitAttributes(requiredAttributes); + + expect(submitAttributesResult).toBeInstanceOf( + SignUpSubmitAttributesResult + ); + expect(submitAttributesResult.error).toBeUndefined(); + expect(submitAttributesResult.isCompleted()).toBe(true); + + const signInResult = await ( + submitAttributesResult.state as SignUpCompletedState + ).signIn(); + + expect(signInResult).toBeInstanceOf(SignInResult); + expect(signInResult.error).toBeUndefined(); + expect(signInResult.isCompleted()).toBe(true); + expect(signInResult.data).toBeDefined(); + expect(signInResult.data).toBeInstanceOf(CustomAuthAccountData); + expect(signInResult.data?.getAccount()?.idToken).toStrictEqual( + "test-id-token" + ); + }); + + it("should sign up successfully if the password and attributes are provided when starting the password reset", async () => { + (fetch as jest.Mock) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-1", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-2", + challenge_type: "oob", + binding_method: "prompt", + challenge_channel: "email", + challenge_target_label: "s****n@o*********m", + code_length: 8, + interval: 300, + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-3", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + token_type: "Bearer", + scopes: "test-scope", + expires_in: 3600, + id_token: "test-id-token", + access_token: "test-access-token", + refresh_token: "test-refresh-token", + client_info: "test-client-info", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + const attributes: UserAccountAttributes = { + city: "test-city", + }; + + const signUpInputs: SignUpInputs = { + username: "test@test.com", + correlationId: correlationId, + password: "valid-password", + attributes: attributes, + }; + + const startResult = await app.signUp(signUpInputs); + + expect(startResult).toBeInstanceOf(SignUpResult); + expect(startResult.error).toBeUndefined(); + expect(startResult.isCodeRequired()).toBe(true); + + const submitCodeResult = await ( + startResult.state as SignUpCodeRequiredState + ).submitCode("12345678"); + + expect(submitCodeResult).toBeInstanceOf(SignUpSubmitCodeResult); + expect(submitCodeResult.error).toBeUndefined(); + expect(submitCodeResult.isCompleted()).toBe(true); + + const signInResult = await ( + submitCodeResult.state as SignUpCompletedState + ).signIn(); + + expect(signInResult).toBeInstanceOf(SignInResult); + expect(signInResult.error).toBeUndefined(); + expect(signInResult.isCompleted()).toBe(true); + expect(signInResult.data).toBeDefined(); + expect(signInResult.data).toBeInstanceOf(CustomAuthAccountData); + expect(signInResult.data?.getAccount()?.idToken).toStrictEqual( + "test-id-token" + ); + }); + + it("should sign up failed if the redirect challenge returned", async () => { + (fetch as jest.Mock) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-1", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + challenge_type: "redirect", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + const signUpInputs = { + username: "test@test.com", + correlationId: correlationId, + }; + + const startResult = await app.signUp(signUpInputs); + + expect(startResult).toBeInstanceOf(SignUpResult); + expect(startResult.error).toBeDefined(); + expect(startResult.isFailed()).toBe(true); + expect(startResult.error?.isRedirectRequired()).toBe(true); + }); + + it("should sign up failed if the given user is not found", async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 400, + json: async () => { + return { + error: "user_already_exists", + error_description: + "It looks like you may already have an account.", + error_codes: [1003037], + timestamp: "yyyy-mm-dd 10:15:00Z", + trace_id: "test-trace-id", + correlation_id: correlationId, + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }); + + const signUpInputs = { + username: "test@test.com", + correlationId: correlationId, + }; + + const startResult = await app.signUp(signUpInputs); + + expect(startResult).toBeInstanceOf(SignUpResult); + expect(startResult.error).toBeDefined(); + expect(startResult.isFailed()).toBe(true); + expect(startResult.error?.isUserAlreadyExists()).toBe(true); + }); +}); diff --git a/lib/msal-browser/test/custom_auth/reset_password/auth_flow/error_type/ResetPasswordError.spec.ts b/lib/msal-browser/test/custom_auth/reset_password/auth_flow/error_type/ResetPasswordError.spec.ts new file mode 100644 index 0000000000..8219e21a90 --- /dev/null +++ b/lib/msal-browser/test/custom_auth/reset_password/auth_flow/error_type/ResetPasswordError.spec.ts @@ -0,0 +1,133 @@ +import { + CustomAuthApiError, + RedirectError, +} from "../../../../../src/custom_auth/core/error/CustomAuthApiError.js"; +import * as CustomAuthApiErrorCode from "../../../../../src/custom_auth/core/network_client/custom_auth_api/types/ApiErrorCodes.js"; +import * as CustomAuthApiSuberror from "../../../../../src/custom_auth/core/network_client/custom_auth_api/types/ApiSuberrors.js"; +import { InvalidArgumentError } from "../../../../../src/custom_auth/index.js"; +import { + ResetPasswordError, + ResetPasswordResendCodeError, + ResetPasswordSubmitCodeError, + ResetPasswordSubmitPasswordError, +} from "../../../../../src/custom_auth/reset_password/auth_flow/error_type/ResetPasswordError.js"; + +describe("ResetPasswordError", () => { + it("should correctly identify user not found error", () => { + const error = new CustomAuthApiError( + "user_not_found", + "User not found" + ); + const resetPasswordError = new ResetPasswordError(error); + expect(resetPasswordError.isUserNotFound()).toBe(true); + }); + + it("should correctly identify invalid username error", () => { + const error = new InvalidArgumentError("Invalid username"); + const resetPasswordError = new ResetPasswordError(error); + expect(resetPasswordError.isInvalidUsername()).toBe(true); + + const error2 = new CustomAuthApiError( + "Some Error", + "username parameter is empty or not valid", + undefined, + [90100] + ); + const resetPasswordError2 = new ResetPasswordError(error2); + expect(resetPasswordError2.isInvalidUsername()).toBe(true); + }); + + it("should correctly identify unsupported challenge type error", () => { + const error = new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_REQUEST, + "The challenge_type list parameter contains an unsupported challenge type" + ); + const resetPasswordError = new ResetPasswordError(error); + expect(resetPasswordError.isUnsupportedChallengeType()).toBe(true); + + const error2 = new CustomAuthApiError( + CustomAuthApiErrorCode.UNSUPPORTED_CHALLENGE_TYPE, + "Unsupported challenge type" + ); + const resetPasswordError2 = new ResetPasswordError(error2); + expect(resetPasswordError2.isUnsupportedChallengeType()).toBe(true); + }); + + it("should correctly identify redirect error", () => { + const error = new RedirectError("Redirecting..."); + const resetPasswordError = new ResetPasswordError(error); + expect(resetPasswordError.isRedirectRequired()).toBe(true); + }); +}); + +describe("ResetPasswordSubmitPasswordError", () => { + it("should correctly identify invalid password error", () => { + const error = new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_GRANT, + "Invalid password", + undefined, + undefined, + CustomAuthApiSuberror.PASSWORD_IS_INVALID + ); + const resetPasswordError = new ResetPasswordSubmitPasswordError(error); + expect(resetPasswordError.isInvalidPassword()).toBe(true); + + const error2 = new InvalidArgumentError("password is required"); + const resetPasswordError2 = new ResetPasswordSubmitPasswordError( + error2 + ); + expect(resetPasswordError2.isInvalidPassword()).toBe(true); + }); + + it("should correctly identify password reset failed error", () => { + const error1 = new CustomAuthApiError( + "password_reset_timeout", + "Password reset timeout" + ); + const resetPasswordError1 = new ResetPasswordSubmitPasswordError( + error1 + ); + expect(resetPasswordError1.isPasswordResetFailed()).toBe(true); + + const error2 = new CustomAuthApiError( + "password_change_failed", + "Password reset is failed" + ); + const resetPasswordError2 = new ResetPasswordSubmitPasswordError( + error2 + ); + expect(resetPasswordError2.isPasswordResetFailed()).toBe(true); + }); +}); + +describe("ResetPasswordSubmitCodeError", () => { + it("should correctly identify invalid code error", () => { + const error = new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_GRANT, + "Invalid code", + undefined, + undefined, + CustomAuthApiSuberror.INVALID_OOB_VALUE + ); + const resetPasswordError = new ResetPasswordSubmitCodeError(error); + expect(resetPasswordError.isInvalidCode()).toBe(true); + + const error2 = new InvalidArgumentError("Invalid code"); + const resetPasswordError2 = new ResetPasswordSubmitCodeError(error2); + expect(resetPasswordError2.isInvalidCode()).toBe(true); + }); + + it("should correctly identify redirect error", () => { + const error = new RedirectError("Redirecting..."); + const resetPasswordError = new ResetPasswordSubmitCodeError(error); + expect(resetPasswordError.isRedirectRequired()).toBe(true); + }); +}); + +describe("ResetPasswordResendCodeError", () => { + it("should correctly identify redirect error", () => { + const error = new RedirectError("Redirecting..."); + const resetPasswordError = new ResetPasswordResendCodeError(error); + expect(resetPasswordError.isRedirectRequired()).toBe(true); + }); +}); diff --git a/lib/msal-browser/test/custom_auth/reset_password/auth_flow/state/ResetPasswordCodeRequiredState.spec.ts b/lib/msal-browser/test/custom_auth/reset_password/auth_flow/state/ResetPasswordCodeRequiredState.spec.ts new file mode 100644 index 0000000000..ab3bca43c6 --- /dev/null +++ b/lib/msal-browser/test/custom_auth/reset_password/auth_flow/state/ResetPasswordCodeRequiredState.spec.ts @@ -0,0 +1,132 @@ +import { CustomAuthBrowserConfiguration } from "../../../../../src/custom_auth/configuration/CustomAuthConfiguration.js"; +import { InvalidArgumentError } from "../../../../../src/custom_auth/core/error/InvalidArgumentError.js"; +import { ResetPasswordSubmitCodeError } from "../../../../../src/custom_auth/reset_password/auth_flow/error_type/ResetPasswordError.js"; +import { ResetPasswordResendCodeResult } from "../../../../../src/custom_auth/reset_password/auth_flow/result/ResetPasswordResendCodeResult.js"; +import { ResetPasswordSubmitCodeResult } from "../../../../../src/custom_auth/reset_password/auth_flow/result/ResetPasswordSubmitCodeResult.js"; +import { ResetPasswordCodeRequiredState } from "../../../../../src/custom_auth/reset_password/auth_flow/state/ResetPasswordCodeRequiredState.js"; +import { ResetPasswordClient } from "../../../../../src/custom_auth/reset_password/interaction_client/ResetPasswordClient.js"; +import { Logger } from "@azure/msal-browser"; +import { SignInClient } from "../../../../../src/custom_auth/sign_in/interaction_client/SignInClient.js"; +import { CustomAuthSilentCacheClient } from "../../../../../src/custom_auth/get_account/interaction_client/CustomAuthSilentCacheClient.js"; + +describe("ResetPasswordCodeRequiredState", () => { + const mockConfig = { + auth: { clientId: "test-client-id" }, + customAuth: { challengeTypes: ["code"] }, + } as unknown as jest.Mocked; + + const mockResetPasswordClient = { + submitCode: jest.fn(), + resendCode: jest.fn(), + } as unknown as jest.Mocked; + + const mockSignInClient = {} as unknown as jest.Mocked; + + const mockLogger = { + info: jest.fn(), + verbose: jest.fn(), + error: jest.fn(), + errorPii: jest.fn(), + } as unknown as jest.Mocked; + + const username = "testuser"; + const correlationId = "test-correlation-id"; + const continuationToken = "test-continuation-token"; + + let state: ResetPasswordCodeRequiredState; + + beforeEach(() => { + state = new ResetPasswordCodeRequiredState({ + correlationId: correlationId, + logger: mockLogger, + continuationToken: continuationToken, + config: mockConfig, + resetPasswordClient: mockResetPasswordClient, + signInClient: mockSignInClient, + cacheClient: + {} as unknown as jest.Mocked, + username: username, + codeLength: 8, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("submitCode", () => { + it("should return an error result if code is empty", async () => { + const result = await state.submitCode(""); + + expect(result.isFailed()).toBeTruthy(); + expect(result.error).toBeInstanceOf(ResetPasswordSubmitCodeError); + expect(result.error?.isInvalidCode()).toBe(true); + expect(result.error?.errorData).toBeInstanceOf( + InvalidArgumentError + ); + expect(result.error?.errorData?.errorDescription).toContain("code"); + }); + + it("should successfully submit a code and return password required state", async () => { + mockResetPasswordClient.submitCode.mockResolvedValue({ + correlationId: correlationId, + continuationToken: "continuation-token", + }); + + const result = await state.submitCode("12345678"); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(ResetPasswordSubmitCodeResult); + expect(result.isPasswordRequired()).toBe(true); + expect(mockResetPasswordClient.submitCode).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: ["code"], + continuationToken: continuationToken, + code: "12345678", + username: username, + }); + }); + + it("should successfully submit a code and return password-required state if password is required", async () => { + mockResetPasswordClient.submitCode.mockResolvedValue({ + correlationId: correlationId, + continuationToken: "new-continuation-token", + }); + + const result = await state.submitCode("12345678"); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(ResetPasswordSubmitCodeResult); + expect(result.isPasswordRequired()).toBe(true); + expect(mockResetPasswordClient.submitCode).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: ["code"], + continuationToken: continuationToken, + code: "12345678", + username: username, + }); + }); + }); + + describe("resendCode", () => { + it("should successfully resend a code and return a code required state", async () => { + mockResetPasswordClient.resendCode.mockResolvedValue({ + correlationId: correlationId, + continuationToken: "new-continuation-token", + challengeChannel: "code", + challengeTargetLabel: "email", + codeLength: 6, + bindingMethod: "email-otp", + }); + + const result = await state.resendCode(); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(ResetPasswordResendCodeResult); + expect(result.data).toBeUndefined(); + expect(result.isCodeRequired()).toBeTruthy(); + }); + }); +}); diff --git a/lib/msal-browser/test/custom_auth/reset_password/auth_flow/state/ResetPasswordPasswordRequiredState.spec.ts b/lib/msal-browser/test/custom_auth/reset_password/auth_flow/state/ResetPasswordPasswordRequiredState.spec.ts new file mode 100644 index 0000000000..9cd2f90ad8 --- /dev/null +++ b/lib/msal-browser/test/custom_auth/reset_password/auth_flow/state/ResetPasswordPasswordRequiredState.spec.ts @@ -0,0 +1,127 @@ +import { CustomAuthBrowserConfiguration } from "../../../../../src/custom_auth/configuration/CustomAuthConfiguration.js"; +import { InvalidArgumentError } from "../../../../../src/custom_auth/core/error/InvalidArgumentError.js"; +import { ResetPasswordSubmitPasswordError } from "../../../../../src/custom_auth/reset_password/auth_flow/error_type/ResetPasswordError.js"; +import { ResetPasswordSubmitPasswordResult } from "../../../../../src/custom_auth/reset_password/auth_flow/result/ResetPasswordSubmitPasswordResult.js"; +import { ResetPasswordClient } from "../../../../../src/custom_auth/reset_password/interaction_client/ResetPasswordClient.js"; +import { Logger } from "@azure/msal-browser"; +import { SignInClient } from "../../../../../src/custom_auth/sign_in/interaction_client/SignInClient.js"; +import { CustomAuthSilentCacheClient } from "../../../../../src/custom_auth/get_account/interaction_client/CustomAuthSilentCacheClient.js"; +import { ResetPasswordPasswordRequiredState } from "../../../../../src/custom_auth/reset_password/auth_flow/state/ResetPasswordPasswordRequiredState.js"; +import { CustomAuthApiError } from "../../../../../src/custom_auth/core/error/CustomAuthApiError.js"; + +describe("ResetPasswordPasswordRequiredState", () => { + const mockConfig = { + auth: { clientId: "test-client-id" }, + customAuth: { challengeTypes: ["password"] }, + } as unknown as jest.Mocked; + + const mockResetPasswordClient = { + submitNewPassword: jest.fn(), + } as unknown as jest.Mocked; + + const mockSignInClient = {} as unknown as jest.Mocked; + + const mockLogger = { + info: jest.fn(), + verbose: jest.fn(), + error: jest.fn(), + errorPii: jest.fn(), + } as unknown as jest.Mocked; + + const username = "testuser"; + const correlationId = "test-correlation-id"; + const continuationToken = "test-continuation-token"; + + let state: ResetPasswordPasswordRequiredState; + + beforeEach(() => { + state = new ResetPasswordPasswordRequiredState({ + correlationId: correlationId, + logger: mockLogger, + continuationToken: continuationToken, + config: mockConfig, + resetPasswordClient: mockResetPasswordClient, + signInClient: mockSignInClient, + cacheClient: + {} as unknown as jest.Mocked, + username: username, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("submitPassword", () => { + it("should return an error result if password is empty", async () => { + const result = await state.submitNewPassword(""); + + expect(result.isFailed()).toBeTruthy(); + expect(result.error).toBeInstanceOf( + ResetPasswordSubmitPasswordError + ); + expect(result.error?.isInvalidPassword()).toBe(true); + expect(result.error?.errorData).toBeInstanceOf( + InvalidArgumentError + ); + expect(result.error?.errorData?.errorDescription).toContain( + "password" + ); + }); + + it("should successfully submit a password and return completed state", async () => { + mockResetPasswordClient.submitNewPassword.mockResolvedValue({ + correlationId: correlationId, + continuationToken: "new-continuation-token", + }); + + const result = await state.submitNewPassword("valid-password"); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(ResetPasswordSubmitPasswordResult); + expect(result.isCompleted()).toBe(true); + expect( + mockResetPasswordClient.submitNewPassword + ).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: ["password"], + continuationToken: continuationToken, + newPassword: "valid-password", + username: username, + }); + }); + + it("should successfully submit a password and return completed state", async () => { + mockResetPasswordClient.submitNewPassword.mockRejectedValue( + new CustomAuthApiError( + "invalid_grant", + "Invalid grant", + correlationId, + [], + "password_too_weak" + ) + ); + + const result = await state.submitNewPassword("valid-password"); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(ResetPasswordSubmitPasswordResult); + expect(result.isFailed()).toBe(true); + expect(result.error).toBeInstanceOf( + ResetPasswordSubmitPasswordError + ); + expect(result.error?.isInvalidPassword()).toBe(true); + expect( + mockResetPasswordClient.submitNewPassword + ).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: ["password"], + continuationToken: continuationToken, + newPassword: "valid-password", + username: username, + }); + }); + }); +}); diff --git a/lib/msal-browser/test/custom_auth/reset_password/interaction_client/ResetPasswordClient.spec.ts b/lib/msal-browser/test/custom_auth/reset_password/interaction_client/ResetPasswordClient.spec.ts new file mode 100644 index 0000000000..64eab53bef --- /dev/null +++ b/lib/msal-browser/test/custom_auth/reset_password/interaction_client/ResetPasswordClient.spec.ts @@ -0,0 +1,394 @@ +jest.mock("../../../../src/custom_auth/CustomAuthConstants.js", () => ({ + PasswordResetPollingTimeoutInMs: 5000, + ChallengeType: { + PASSWORD: "password", + OOB: "oob", + REDIRECT: "redirect", + }, + ResetPasswordPollStatus: { + IN_PROGRESS: "in_progress", + SUCCEEDED: "succeeded", + FAILED: "failed", + NOT_STARTED: "not_started", + }, +})); + +import { ResetPasswordClient } from "../../../../src/custom_auth/reset_password/interaction_client/ResetPasswordClient.js"; +import { customAuthConfig } from "../../test_resources/CustomAuthConfig.js"; +import { CustomAuthAuthority } from "../../../../src/custom_auth/core/CustomAuthAuthority.js"; +import { ChallengeType } from "../../../../src/custom_auth/CustomAuthConstants.js"; +import * as CustomAuthApiErrorCode from "../../../../src/custom_auth/core/network_client/custom_auth_api/types/ApiErrorCodes.js"; +import { BrowserConfiguration } from "../../../../src/config/Configuration.js"; +import { + ICrypto, + INetworkModule, + IPerformanceClient, + Logger, +} from "@azure/msal-common/browser"; +import { BrowserCacheManager } from "../../../../src/cache/BrowserCacheManager.js"; +import { EventHandler } from "../../../../src/event/EventHandler.js"; +import { INavigationClient } from "../../../../src/navigation/INavigationClient.js"; + +jest.mock( + "../../../../src/custom_auth/core/network_client/custom_auth_api/CustomAuthApiClient.js", + () => { + let signInApiClient = { + initiate: jest.fn(), + requestChallenge: jest.fn(), + requestTokensWithPassword: jest.fn(), + requestTokensWithOob: jest.fn(), + signInWithContinuationToken: jest.fn(), + }; + let signUpApiClient = { + start: jest.fn(), + requestChallenge: jest.fn(), + continueWithCode: jest.fn(), + continueWithPassword: jest.fn(), + continueWithAttributes: jest.fn(), + }; + let resetPasswordApiClient = { + start: jest.fn(), + requestChallenge: jest.fn(), + continueWithCode: jest.fn(), + submitNewPassword: jest.fn(), + pollCompletion: jest.fn(), + }; + + const CustomAuthApiClient = jest.fn().mockImplementation(() => ({ + signInApi: signInApiClient, + signUpApi: signUpApiClient, + resetPasswordApi: resetPasswordApiClient, + })); + + const mockedApiClient = new CustomAuthApiClient(); + return { + mockedApiClient, + signInApiClient, + signUpApiClient, + resetPasswordApiClient, + }; + } +); + +describe("ResetPasswordClient", () => { + let client: ResetPasswordClient; + let authority: CustomAuthAuthority; + const { + mockedApiClient, + signInApiClient, + signUpApiClient, + resetPasswordApiClient, + } = jest.requireMock( + "../../../../src/custom_auth/core/network_client/custom_auth_api/CustomAuthApiClient.js" + ); + const mockConfig = { + auth: { + OIDCOptions: {}, + knownAuthorities: [], + cloudDiscoveryMetadata: "", + authorityMetadata: "", + }, + system: { + protocolMode: "", + }, + } as unknown as jest.Mocked; + + beforeEach(() => { + jest.resetAllMocks(); + const mockBrowserConfiguration = { + system: { + networkClient: { + sendGetRequestAsync: jest.fn(), + sendPostRequestAsync: jest.fn(), + } as unknown as jest.Mocked, + }, + auth: { + clientId: customAuthConfig.auth.clientId, + }, + } as unknown as jest.Mocked; + + const mockCacheManager = { + getWrapperMetadata: jest.fn(), + getServerTelemetry: jest.fn(), + generateAuthorityMetadataCacheKey: jest.fn(), + setAuthorityMetadata: jest.fn(), + } as unknown as jest.Mocked; + mockCacheManager.getWrapperMetadata.mockReturnValue(["", ""]); + mockCacheManager.getServerTelemetry.mockReturnValue(null); + + const mockCrypto = { + createNewGuid: jest.fn(), + } as unknown as jest.Mocked; + + const mockEventHandler = {} as unknown as jest.Mocked; + const mockNavigationClient = + {} as unknown as jest.Mocked; + const mockPerformanceClient = + {} as unknown as jest.Mocked; + const mockNetworkModule = {} as unknown as jest.Mocked; + + const mockLogger = { + clone: jest.fn(), + verbose: jest.fn(), + info: jest.fn(), + infoPii: jest.fn(), + error: jest.fn(), + errorPii: jest.fn(), + } as unknown as jest.Mocked; + mockLogger.clone.mockReturnValue(mockLogger); + + authority = new CustomAuthAuthority( + customAuthConfig.auth.authority ?? "", + mockConfig, + mockNetworkModule, + mockCacheManager, + mockLogger, + customAuthConfig.customAuth.authApiProxyUrl + ); + + client = new ResetPasswordClient( + mockBrowserConfiguration, + mockCacheManager, + mockCrypto, + mockLogger, + mockEventHandler, + mockNavigationClient, + mockPerformanceClient, + mockedApiClient, + authority + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("start", () => { + it("should return ResetPasswordCodeRequiredResult suceesfully", async () => { + resetPasswordApiClient.start.mockResolvedValue({ + continuation_token: "continuation_token_1", + }); + resetPasswordApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.OOB, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + code_length: 6, + challenge_channel: "email", + challenge_target_label: "email", + binding_method: "email", + }); + + const result = await client.start({ + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ + ChallengeType.OOB, + ChallengeType.PASSWORD, + ChallengeType.REDIRECT, + ], + correlationId: "corr123", + }); + + expect(result.correlationId).toBe("corr123"); + expect(result.continuationToken).toBe("continuation_token_2"); + expect(result.codeLength).toBe(6); + expect(result.challengeChannel).toBe("email"); + expect(result.challengeTargetLabel).toBe("email"); + expect(result.bindingMethod).toBe("email"); + }); + + it("should return ResetPasswordPasswordRequiredResult with error when challenge type is not OOB", async () => { + resetPasswordApiClient.start.mockResolvedValue({ + continuation_token: "continuation_token_1", + }); + resetPasswordApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.PASSWORD, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + }); + + await expect( + client.start({ + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ + ChallengeType.OOB, + ChallengeType.PASSWORD, + ChallengeType.REDIRECT, + ], + correlationId: "corr123", + }) + ).rejects.toMatchObject({ + error: CustomAuthApiErrorCode.UNSUPPORTED_CHALLENGE_TYPE, + errorDescription: "Unsupported challenge type 'password'.", + correlationId: "corr123", + }); + }); + }); + + describe("submitCode", () => { + it("should return ResetPasswordPasswordRequiredResult successfully", async () => { + resetPasswordApiClient.continueWithCode.mockResolvedValue({ + continuation_token: "continuation_token_2", + correlation_id: "corr123", + expires_in: 3600, + }); + + const result = await client.submitCode({ + code: "123456", + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ + ChallengeType.OOB, + ChallengeType.PASSWORD, + ChallengeType.REDIRECT, + ], + correlationId: "corr123", + }); + + expect(result.correlationId).toBe("corr123"); + expect(result.continuationToken).toBe("continuation_token_2"); + }); + }); + + describe("submitNewPassword", () => { + it("should return ResetPasswordCompletedResult for valid password", async () => { + resetPasswordApiClient.submitNewPassword.mockResolvedValue({ + continuation_token: "continuation_token_2", + poll_interval: 1, + correlation_id: "corr123", + }); + + resetPasswordApiClient.pollCompletion + .mockResolvedValueOnce({ + status: "in-progress", + correlation_id: "corr123", + }) + .mockResolvedValueOnce({ + status: "in-progress", + correlation_id: "corr123", + }) + .mockResolvedValueOnce({ + status: "succeeded", + continuation_token: "continuation_token_3", + correlation_id: "corr123", + }); + + const result = await client.submitNewPassword({ + newPassword: "123456", + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ + ChallengeType.OOB, + ChallengeType.PASSWORD, + ChallengeType.REDIRECT, + ], + correlationId: "corr123", + }); + + expect(result.correlationId).toBe("corr123"); + expect(result.continuationToken).toBe("continuation_token_3"); + expect(resetPasswordApiClient.pollCompletion).toHaveBeenCalledTimes( + 3 + ); + }, 5000); + + it("should return ResetPasswordCompletedResult with error if the password-change is failed", async () => { + resetPasswordApiClient.submitNewPassword.mockResolvedValue({ + continuation_token: "continuation_token_2", + poll_interval: 1, + correlation_id: "corr123", + }); + + resetPasswordApiClient.pollCompletion.mockResolvedValue({ + status: "failed", + correlation_id: "corr123", + }); + + await expect( + client.submitNewPassword({ + newPassword: "123456", + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ + ChallengeType.OOB, + ChallengeType.PASSWORD, + ChallengeType.REDIRECT, + ], + correlationId: "corr123", + }) + ).rejects.toMatchObject({ + error: CustomAuthApiErrorCode.PASSWORD_CHANGE_FAILED, + errorDescription: "Password is failed to be reset.", + correlationId: "corr123", + }); + }, 5000); + + it("should return ResetPasswordCompletedResult with error if the reset password is timeout", async () => { + resetPasswordApiClient.submitNewPassword.mockResolvedValue({ + continuation_token: "continuation_token_2", + poll_interval: 1, + correlation_id: "corr123", + }); + + resetPasswordApiClient.pollCompletion.mockResolvedValue({ + status: "in-progress", + correlation_id: "corr123", + }); + + await expect( + client.submitNewPassword({ + newPassword: "123456", + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ + ChallengeType.OOB, + ChallengeType.PASSWORD, + ChallengeType.REDIRECT, + ], + correlationId: "corr123", + }) + ).rejects.toMatchObject({ + error: CustomAuthApiErrorCode.PASSWORD_RESET_TIMEOUT, + errorDescription: "Password reset flow has timed out.", + correlationId: "corr123", + }); + }, 10000); + }); + + describe("resendCode", () => { + it("should return ResetPasswordCodeRequiredResult", async () => { + resetPasswordApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.OOB, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + code_length: 6, + challenge_channel: "email", + challenge_target_label: "email", + binding_method: "email", + }); + + const result = await client.resendCode({ + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ + ChallengeType.OOB, + ChallengeType.PASSWORD, + ChallengeType.REDIRECT, + ], + correlationId: "corr123", + }); + + expect(result.correlationId).toBe("corr123"); + expect(result.continuationToken).toBe("continuation_token_2"); + expect(result.codeLength).toBe(6); + expect(result.challengeChannel).toBe("email"); + expect(result.challengeTargetLabel).toBe("email"); + }); + }); +}); diff --git a/lib/msal-browser/test/custom_auth/sign_in/auth_flow/error_type/SignInError.spec.ts b/lib/msal-browser/test/custom_auth/sign_in/auth_flow/error_type/SignInError.spec.ts new file mode 100644 index 0000000000..a464fe3d69 --- /dev/null +++ b/lib/msal-browser/test/custom_auth/sign_in/auth_flow/error_type/SignInError.spec.ts @@ -0,0 +1,128 @@ +import { + CustomAuthApiError, + RedirectError, +} from "../../../../../src/custom_auth/core/error/CustomAuthApiError.js"; +import { InvalidArgumentError } from "../../../../../src/custom_auth/index.js"; +import { + SignInError, + SignInSubmitCodeError, + SignInSubmitPasswordError, +} from "../../../../../src/custom_auth/sign_in/auth_flow/error_type/SignInError.js"; + +describe("SignInError", () => { + const mockErrorData = { + error: "", + errorDescription: "", + }; + + it("should return true for isUserNotFound when error is USER_NOT_FOUND", () => { + const errorData = { ...mockErrorData, error: "user_not_found" }; + const signInError = new SignInError(errorData as any); + expect(signInError.isUserNotFound()).toBe(true); + }); + + it("should return true for isInvalidUsername when errorDescription mentions username", () => { + const errorData = new CustomAuthApiError( + "invalid_request", + "username parameter is empty or not valid", + "correlation-id", + [90100] + ); + + const signInError = new SignInError(errorData as any); + expect(signInError.isInvalidUsername()).toBe(true); + }); + + it("should return true for isInvalidPassword when error matches INVALID_GRANT with 50126", () => { + const errorData = new CustomAuthApiError( + "invalid_grant", + "Invalid grant", + "correlation-id", + [50126] + ); + const signInError = new SignInError(errorData); + expect(signInError.isPasswordIncorrect()).toBe(true); + }); + + it("should return true for isInvalidPassword when error is InvalidArgumentError and message includes 'password'", () => { + const errorData = new InvalidArgumentError("password"); + const signInError = new SignInError(errorData); + expect(signInError.isPasswordIncorrect()).toBe(true); + }); + + it("should return true for isUnsupportedChallengeType when error matches unsupported types", () => { + const errorData = { + ...mockErrorData, + error: "unsupported_challenge_type", + }; + const signInError = new SignInError(errorData as any); + expect(signInError.isUnsupportedChallengeType()).toBe(true); + }); + + it("should return true for isRedirect when error is an instance of RedirectError", () => { + const redirectError = new RedirectError(mockErrorData as any); + const signInError = new SignInError(redirectError as any); + expect(signInError.isRedirectRequired()).toBe(true); + }); + + it("should return false for all methods when error data does not match any condition", () => { + const errorData = { ...mockErrorData, error: "some_other_error" }; + const signInError = new SignInError(errorData as any); + + expect(signInError.isUserNotFound()).toBe(false); + expect(signInError.isInvalidUsername()).toBe(false); + expect(signInError.isPasswordIncorrect()).toBe(false); + expect(signInError.isUnsupportedChallengeType()).toBe(false); + expect(signInError.isRedirectRequired()).toBe(false); + }); + + it("should return true for isTokenExpired when error matches token expired types", () => { + const errorData = new CustomAuthApiError( + "expired_token", + "expired token", + "correlation-id", + [] + ); + const signInError = new SignInError(errorData as any); + expect(signInError.isTokenExpired()).toBe(true); + }); +}); + +describe("SignInSubmitPasswordError", () => { + it("should return true for isInvalidPassword when error matches INVALID_GRANT with 50126", () => { + const errorData = new CustomAuthApiError( + "invalid_grant", + "Invalid grant", + "correlation-id", + [50126] + ); + const submitPasswordError = new SignInSubmitPasswordError(errorData); + expect(submitPasswordError.isInvalidPassword()).toBe(true); + }); + + it("should return true for isInvalidPassword when error is InvalidArgumentError and message includes 'password'", () => { + const errorData = new InvalidArgumentError("password"); + const submitPasswordError = new SignInSubmitPasswordError(errorData); + expect(submitPasswordError.isInvalidPassword()).toBe(true); + }); +}); + +describe("SignInSubmitCodeError", () => { + it("should return true for isInvalidCode when error matches INVALID_GRANT and INVALID_OOB_VALUE", () => { + const errorData = new CustomAuthApiError( + "invalid_grant", + "Invalid grant", + "correlation-id", + [], + "invalid_oob_value" + ); + const submitCodeError = new SignInSubmitCodeError(errorData); + expect(submitCodeError.isInvalidCode()).toBe(true); + }); + + it("should return true for isInvalidCode when error is InvalidArgumentError and message includes 'code'", () => { + const errorData = new InvalidArgumentError("code"); + const submitCodeError = new SignInSubmitCodeError(errorData); + expect(submitCodeError.isInvalidCode()).toBe(true); + }); +}); diff --git a/lib/msal-browser/test/custom_auth/sign_in/auth_flow/state/SignInCodeRequiredState.spec.ts b/lib/msal-browser/test/custom_auth/sign_in/auth_flow/state/SignInCodeRequiredState.spec.ts new file mode 100644 index 0000000000..3dd5ad6c50 --- /dev/null +++ b/lib/msal-browser/test/custom_auth/sign_in/auth_flow/state/SignInCodeRequiredState.spec.ts @@ -0,0 +1,203 @@ +import { CustomAuthAccountData } from "../../../../../src/custom_auth/get_account/auth_flow/CustomAuthAccountData.js"; +import { CustomAuthBrowserConfiguration } from "../../../../../src/custom_auth/configuration/CustomAuthConfiguration.js"; +import { InvalidArgumentError } from "../../../../../src/custom_auth/core/error/InvalidArgumentError.js"; +import { + SignInResendCodeError, + SignInSubmitCodeError, +} from "../../../../../src/custom_auth/sign_in/auth_flow/error_type/SignInError.js"; +import { SignInResendCodeResult } from "../../../../../src/custom_auth/sign_in/auth_flow/result/SignInResendCodeResult.js"; +import { SignInSubmitCodeResult } from "../../../../../src/custom_auth/sign_in/auth_flow/result/SignInSubmitCodeResult.js"; +import { + createSignInCodeSendResult, + createSignInCompleteResult, +} from "../../../../../src/custom_auth/sign_in/interaction_client/result/SignInActionResult.js"; +import { SignInClient } from "../../../../../src/custom_auth/sign_in/interaction_client/SignInClient.js"; +import { Logger } from "@azure/msal-browser"; +import { CustomAuthSilentCacheClient } from "../../../../../src/custom_auth/get_account/interaction_client/CustomAuthSilentCacheClient.js"; +import { SignInCodeRequiredState } from "../../../../../src/custom_auth/sign_in/auth_flow/state/SignInCodeRequiredState.js"; +import { DefaultCustomAuthApiCodeLength } from "../../../../../src/custom_auth/CustomAuthConstants.js"; + +describe("SignInCodeRequiredState", () => { + const mockConfig = { + auth: { clientId: "test-client-id" }, + customAuth: { challengeTypes: ["code"] }, + } as unknown as jest.Mocked; + + const mockSignInClient = { + submitCode: jest.fn(), + resendCode: jest.fn(), + } as unknown as jest.Mocked; + + const mockCacheClient = + {} as unknown as jest.Mocked; + + const mockLogger = { + info: jest.fn(), + verbose: jest.fn(), + error: jest.fn(), + errorPii: jest.fn(), + } as unknown as jest.Mocked; + + const username = "testuser"; + const correlationId = "test-correlation-id"; + const continuationToken = "test-continuation-token"; + + let state: SignInCodeRequiredState; + + beforeEach(() => { + state = new SignInCodeRequiredState({ + username: username, + signInClient: mockSignInClient, + cacheClient: mockCacheClient, + correlationId: correlationId, + logger: mockLogger, + continuationToken: continuationToken, + config: mockConfig, + codeLength: 8, + scopes: ["scope1", "scope2"], + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("submitCode", () => { + it("should return an error result if code is invalid", async () => { + let result = await state.submitCode(""); + + expect(result.isFailed()).toBeTruthy(); + expect(result.error).toBeInstanceOf(SignInSubmitCodeError); + expect(result.error?.isInvalidCode()).toBe(true); + expect(result.error?.errorData).toBeInstanceOf( + InvalidArgumentError + ); + expect(result.error?.errorData?.errorDescription).toContain("code"); + }); + + it("should successfully submit a code and return a result", async () => { + mockSignInClient.submitCode.mockResolvedValue( + createSignInCompleteResult({ + correlationId: correlationId, + authenticationResult: { + accessToken: "test-access-token", + idToken: "test-id-token", + expiresOn: new Date(Date.now() + 3600 * 1000), + tokenType: "Bearer", + correlationId: correlationId, + authority: "https://test-authority.com", + tenantId: "test-tenant-id", + scopes: [], + account: { + homeAccountId: "", + environment: "", + tenantId: "test-tenant-id", + username: username, + localAccountId: "", + idToken: "test-id-token", + }, + idTokenClaims: {}, + fromCache: false, + uniqueId: "test-unique-id", + }, + }) + ); + + const result = await state.submitCode("12345678"); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignInSubmitCodeResult); + expect(result.data).toBeInstanceOf(CustomAuthAccountData); + expect(mockSignInClient.submitCode).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: ["code"], + scopes: ["scope1", "scope2"], + continuationToken: continuationToken, + code: "12345678", + username: username, + }); + }); + + it("should return an error result if submitCode throws an error", async () => { + const mockError = new Error("Submission failed"); + mockSignInClient.submitCode.mockRejectedValue(mockError); + + const result = await state.submitCode("valid-code"); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignInSubmitCodeResult); + expect(result.error).toBeDefined(); + expect(result.error).toBeInstanceOf(SignInSubmitCodeError); + }); + + it("should still trigger the call to submit code even if no codeLength returned from previous call", async () => { + mockSignInClient.submitCode.mockResolvedValue( + createSignInCompleteResult({ + correlationId: correlationId, + authenticationResult: { + accessToken: "test-access-token", + idToken: "test-id-token", + expiresOn: new Date(Date.now() + 3600 * 1000), + tokenType: "Bearer", + correlationId: correlationId, + authority: "https://test-authority.com", + tenantId: "test-tenant-id", + scopes: [], + account: { + homeAccountId: "", + environment: "", + tenantId: "test-tenant-id", + username: username, + localAccountId: "", + idToken: "test-id-token", + }, + idTokenClaims: {}, + fromCache: false, + uniqueId: "test-unique-id", + }, + }) + ); + + (state as any)["stateParameters"]["codeLength"] = + DefaultCustomAuthApiCodeLength; + const result = await state.submitCode("12345678"); + expect(result.isCompleted()).toBeTruthy(); + expect(result.error).toBeUndefined(); + }); + }); + + describe("resendCode", () => { + it("should successfully resend a code and return a result", async () => { + mockSignInClient.resendCode.mockResolvedValue( + createSignInCodeSendResult({ + correlationId: correlationId, + continuationToken: "new-continuation-token", + challengeChannel: "code", + challengeTargetLabel: "email", + codeLength: 6, + bindingMethod: "email-otp", + }) + ); + + const result = await state.resendCode(); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignInResendCodeResult); + expect(result.data).toBeUndefined(); + expect(result.isCodeRequired()).toBeTruthy(); + }); + + it("should return an error result if resendCode throws an error", async () => { + const mockError = new Error("Resend code failed"); + mockSignInClient.resendCode.mockRejectedValue(mockError); + + const result = await state.resendCode(); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignInResendCodeResult); + expect(result.error).toBeDefined(); + expect(result.error).toBeInstanceOf(SignInResendCodeError); + }); + }); +}); diff --git a/lib/msal-browser/test/custom_auth/sign_in/auth_flow/state/SignInContinuationState.spec.ts b/lib/msal-browser/test/custom_auth/sign_in/auth_flow/state/SignInContinuationState.spec.ts new file mode 100644 index 0000000000..14d0a19c3b --- /dev/null +++ b/lib/msal-browser/test/custom_auth/sign_in/auth_flow/state/SignInContinuationState.spec.ts @@ -0,0 +1,114 @@ +import { Logger } from "@azure/msal-browser"; +import { CustomAuthAccountData } from "../../../../../src/custom_auth/get_account/auth_flow/CustomAuthAccountData.js"; +import { CustomAuthBrowserConfiguration } from "../../../../../src/custom_auth/configuration/CustomAuthConfiguration.js"; +import { SignInError } from "../../../../../src/custom_auth/sign_in/auth_flow/error_type/SignInError.js"; +import { SignInResult } from "../../../../../src/custom_auth/sign_in/auth_flow/result/SignInResult.js"; +import { SignInContinuationState } from "../../../../../src/custom_auth/sign_in/auth_flow/state/SignInContinuationState.js"; +import { createSignInCompleteResult } from "../../../../../src/custom_auth/sign_in/interaction_client/result/SignInActionResult.js"; +import { SignInClient } from "../../../../../src/custom_auth/sign_in/interaction_client/SignInClient.js"; +import { SignInScenario } from "../../../../../src/custom_auth/sign_in/auth_flow/SignInScenario.js"; +import { CustomAuthSilentCacheClient } from "../../../../../src/custom_auth/get_account/interaction_client/CustomAuthSilentCacheClient.js"; + +describe("SignInContinuationState", () => { + const mockConfig = { + auth: { clientId: "test-client-id" }, + customAuth: { challengeTypes: ["code", "password", "redirect"] }, + } as unknown as jest.Mocked; + + const mockSignInClient = { + signInWithContinuationToken: jest.fn(), + } as unknown as jest.Mocked; + + const mockLogger = { + info: jest.fn(), + verbose: jest.fn(), + error: jest.fn(), + errorPii: jest.fn(), + } as unknown as jest.Mocked; + + const mockCacheClient = + {} as unknown as jest.Mocked; + + const username = "testuser"; + const correlationId = "test-correlation-id"; + const continuationToken = "test-continuation-token"; + + let state: SignInContinuationState; + + beforeEach(() => { + state = new SignInContinuationState({ + username: username, + signInClient: mockSignInClient, + cacheClient: mockCacheClient, + correlationId: correlationId, + logger: mockLogger, + continuationToken: continuationToken, + config: mockConfig, + signInScenario: SignInScenario.SignInAfterSignUp, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should successfully sign in and return a result", async () => { + mockSignInClient.signInWithContinuationToken.mockResolvedValue( + createSignInCompleteResult({ + correlationId: correlationId, + authenticationResult: { + accessToken: "test-access-token", + idToken: "test-id-token", + expiresOn: new Date(Date.now() + 3600 * 1000), + tokenType: "Bearer", + correlationId: correlationId, + authority: "https://test-authority.com", + tenantId: "test-tenant-id", + scopes: [], + account: { + homeAccountId: "", + environment: "", + tenantId: "test-tenant-id", + username: username, + localAccountId: "", + idToken: "test-id-token", + }, + idTokenClaims: {}, + fromCache: false, + uniqueId: "test-unique-id", + }, + }) + ); + + const result = await state.signIn({ scopes: ["scope1", "scope2"] }); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignInResult); + expect(result.data).toBeInstanceOf(CustomAuthAccountData); + expect( + mockSignInClient.signInWithContinuationToken + ).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: ["code", "password", "redirect"], + scopes: ["scope1", "scope2"], + continuationToken: continuationToken, + username: username, + signInScenario: SignInScenario.SignInAfterSignUp, + }); + }); + + it("should return an error result if signIn throws an error", async () => { + const mockError = new Error("Sign in failed"); + mockSignInClient.signInWithContinuationToken.mockRejectedValue( + mockError + ); + + const result = await state.signIn(); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignInResult); + expect(result.error).toBeDefined(); + expect(result.error).toBeInstanceOf(SignInError); + }); +}); diff --git a/lib/msal-browser/test/custom_auth/sign_in/auth_flow/state/SignInPasswordRequiredState.spec.ts b/lib/msal-browser/test/custom_auth/sign_in/auth_flow/state/SignInPasswordRequiredState.spec.ts new file mode 100644 index 0000000000..734cee9be1 --- /dev/null +++ b/lib/msal-browser/test/custom_auth/sign_in/auth_flow/state/SignInPasswordRequiredState.spec.ts @@ -0,0 +1,120 @@ +import { Logger } from "@azure/msal-browser"; +import { CustomAuthAccountData } from "../../../../../src/custom_auth/get_account/auth_flow/CustomAuthAccountData.js"; +import { CustomAuthBrowserConfiguration } from "../../../../../src/custom_auth/configuration/CustomAuthConfiguration.js"; +import { InvalidArgumentError } from "../../../../../src/custom_auth/core/error/InvalidArgumentError.js"; +import { SignInSubmitPasswordError } from "../../../../../src/custom_auth/sign_in/auth_flow/error_type/SignInError.js"; +import { SignInSubmitPasswordResult } from "../../../../../src/custom_auth/sign_in/auth_flow/result/SignInSubmitPasswordResult.js"; +import { SignInPasswordRequiredState } from "../../../../../src/custom_auth/sign_in/auth_flow/state/SignInPasswordRequiredState.js"; +import { createSignInCompleteResult } from "../../../../../src/custom_auth/sign_in/interaction_client/result/SignInActionResult.js"; +import { SignInClient } from "../../../../../src/custom_auth/sign_in/interaction_client/SignInClient.js"; +import { CustomAuthSilentCacheClient } from "../../../../../src/custom_auth/get_account/interaction_client/CustomAuthSilentCacheClient.js"; + +describe("SignInPasswordRequiredState", () => { + const mockConfig = { + auth: { clientId: "test-client-id" }, + customAuth: { challengeTypes: ["password"] }, + } as unknown as jest.Mocked; + + const mockSignInClient = { + submitPassword: jest.fn(), + } as unknown as jest.Mocked; + + const mockLogger = { + info: jest.fn(), + verbose: jest.fn(), + error: jest.fn(), + errorPii: jest.fn(), + } as unknown as jest.Mocked; + + const mockCacheClient = + {} as unknown as jest.Mocked; + + const username = "testuser"; + const correlationId = "test-correlation-id"; + const continuationToken = "test-continuation-token"; + + let state: SignInPasswordRequiredState; + + beforeEach(() => { + state = new SignInPasswordRequiredState({ + username: username, + signInClient: mockSignInClient, + cacheClient: mockCacheClient, + correlationId: correlationId, + logger: mockLogger, + continuationToken: continuationToken, + config: mockConfig, + scopes: ["scope1", "scope2"], + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should return an error result if password is empty", async () => { + const result = await state.submitPassword(""); + + expect(result.isFailed()).toBe(true); + expect(result.error).toBeInstanceOf(SignInSubmitPasswordError); + expect(result.error?.errorData).toBeInstanceOf(InvalidArgumentError); + expect(result.error?.errorData?.errorDescription).toContain("password"); + }); + + it("should successfully submit a password and return a result", async () => { + mockSignInClient.submitPassword.mockResolvedValue( + createSignInCompleteResult({ + correlationId: correlationId, + authenticationResult: { + accessToken: "test-access-token", + idToken: "test-id-token", + expiresOn: new Date(Date.now() + 3600 * 1000), + tokenType: "Bearer", + correlationId: correlationId, + authority: "https://test-authority.com", + tenantId: "test-tenant-id", + scopes: [], + account: { + homeAccountId: "", + environment: "", + tenantId: "test-tenant-id", + username: username, + localAccountId: "", + idToken: "test-id-token", + }, + idTokenClaims: {}, + fromCache: false, + uniqueId: "test-unique-id", + }, + }) + ); + + const result = await state.submitPassword("valid-password"); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignInSubmitPasswordResult); + expect(result.isCompleted()).toBe(true); + expect(result.data).toBeInstanceOf(CustomAuthAccountData); + expect(mockSignInClient.submitPassword).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: ["password"], + scopes: ["scope1", "scope2"], + continuationToken: continuationToken, + password: "valid-password", + username: username, + }); + }); + + it("should return an error result if submitPassword throws an error", async () => { + const mockError = new Error("Submission failed"); + mockSignInClient.submitPassword.mockRejectedValue(mockError); + + const result = await state.submitPassword("valid-password"); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignInSubmitPasswordResult); + expect(result.error).toBeDefined(); + expect(result.error).toBeInstanceOf(SignInSubmitPasswordError); + }); +}); diff --git a/lib/msal-browser/test/custom_auth/sign_in/interation_client/SignInClient.spec.ts b/lib/msal-browser/test/custom_auth/sign_in/interation_client/SignInClient.spec.ts new file mode 100644 index 0000000000..c00383c369 --- /dev/null +++ b/lib/msal-browser/test/custom_auth/sign_in/interation_client/SignInClient.spec.ts @@ -0,0 +1,412 @@ +import { SignInClient } from "../../../../src/custom_auth/sign_in/interaction_client/SignInClient.js"; +import { customAuthConfig } from "../../test_resources/CustomAuthConfig.js"; +import { CustomAuthAuthority } from "../../../../src/custom_auth/core/CustomAuthAuthority.js"; +import { ChallengeType } from "../../../../src/custom_auth/CustomAuthConstants.js"; +import { + SIGN_IN_CODE_SEND_RESULT_TYPE, + SIGN_IN_COMPLETED_RESULT_TYPE, + SIGN_IN_PASSWORD_REQUIRED_RESULT_TYPE, + SignInCodeSendResult, +} from "../../../../src/custom_auth/sign_in/interaction_client/result/SignInActionResult.js"; +import { SignInScenario } from "../../../../src/custom_auth/sign_in/auth_flow/SignInScenario.js"; +import { + ICrypto, + INetworkModule, + IPerformanceClient, + Logger, +} from "@azure/msal-common/browser"; +import { BrowserConfiguration } from "../../../../src/config/Configuration.js"; +import { BrowserCacheManager } from "../../../../src/cache/BrowserCacheManager.js"; +import { EventHandler } from "../../../../src/event/EventHandler.js"; +import { INavigationClient } from "../../../../src/navigation/INavigationClient.js"; + +jest.mock( + "../../../../src/custom_auth/core/network_client/custom_auth_api/CustomAuthApiClient.js", + () => { + let signInApiClient = { + initiate: jest.fn(), + requestChallenge: jest.fn(), + requestTokensWithPassword: jest.fn(), + requestTokensWithOob: jest.fn(), + requestTokenWithContinuationToken: jest.fn(), + }; + let signUpApiClient = { + start: jest.fn(), + requestChallenge: jest.fn(), + continueWithCode: jest.fn(), + continueWithPassword: jest.fn(), + continueWithAttributes: jest.fn(), + }; + let resetPasswordApiClient = { + start: jest.fn(), + requestChallenge: jest.fn(), + continueWithCode: jest.fn(), + submitNewPassword: jest.fn(), + pollCompletion: jest.fn(), + }; + + // Set up the prototype or instance methods/properties + const CustomAuthApiClient = jest.fn().mockImplementation(() => ({ + signInApi: signInApiClient, + signUpApi: signUpApiClient, + resetPasswordApi: resetPasswordApiClient, + })); + + const mockedApiClient = new CustomAuthApiClient(); + return { + mockedApiClient, + signInApiClient, + signUpApiClient, + resetPasswordApiClient, + }; + } +); + +describe("SignInClient", () => { + let client: SignInClient; + let authority: CustomAuthAuthority; + const { + mockedApiClient, + signInApiClient, + signUpApiClient, + resetPasswordApiClient, + } = jest.requireMock( + "../../../../src/custom_auth/core/network_client/custom_auth_api/CustomAuthApiClient.js" + ); + + beforeEach(() => { + jest.resetAllMocks(); + const mockBrowserConfiguration = { + system: { + networkClient: { + sendGetRequestAsync: jest.fn(), + sendPostRequestAsync: jest.fn(), + } as unknown as jest.Mocked, + }, + auth: { + clientId: customAuthConfig.auth.clientId, + }, + } as unknown as jest.Mocked; + + const mockCacheManager = { + getWrapperMetadata: jest.fn(), + getServerTelemetry: jest.fn(), + generateAuthorityMetadataCacheKey: jest.fn(), + setAuthorityMetadata: jest.fn(), + } as unknown as jest.Mocked; + mockCacheManager.getWrapperMetadata.mockReturnValue(["", ""]); + mockCacheManager.getServerTelemetry.mockReturnValue(null); + const mockNetworkModule = {} as unknown as jest.Mocked; + + const mockCrypto = { + createNewGuid: jest.fn(), + } as unknown as jest.Mocked; + + const mockEventHandler = {} as unknown as jest.Mocked; + const mockNavigationClient = + {} as unknown as jest.Mocked; + const mockPerformanceClient = + {} as unknown as jest.Mocked; + + const mockLogger = { + clone: jest.fn(), + verbose: jest.fn(), + info: jest.fn(), + errorPii: jest.fn(), + } as unknown as jest.Mocked; + mockLogger.clone.mockReturnValue(mockLogger); + + const mockConfig = { + auth: { + OIDCOptions: {}, + knownAuthorities: [], + cloudDiscoveryMetadata: "", + authorityMetadata: "", + }, + system: { + protocolMode: "", + }, + } as unknown as jest.Mocked; + + authority = new CustomAuthAuthority( + customAuthConfig.auth.authority ?? "", + mockConfig, + mockNetworkModule, + mockCacheManager, + mockLogger, + customAuthConfig.customAuth.authApiProxyUrl + ); + + client = new SignInClient( + mockBrowserConfiguration, + mockCacheManager, + mockCrypto, + mockLogger, + mockEventHandler, + mockNavigationClient, + mockPerformanceClient, + mockedApiClient, + authority + ); + + (client as any).tokenResponseHandler = { + handleServerTokenResponse: jest.fn().mockResolvedValue({ + uniqueId: "test-unique-id", + tenantId: "test-tenant-id", + scopes: ["test-scope"], + account: { + homeAccountId: "test-home-account-id", + environment: "test-environment", + tenantId: "test-tenant-id", + username: "abc@abc.com", + }, + idToken: "test-id-token", + idTokenClaims: {}, + accessToken: "test-access-token", + refreshToken: "test-refresh-token", + expiresOn: new Date(), + extExpiresOn: new Date(), + tokenType: "Bearer", + authority: + "https://spasamples.ciamlogin.com/spasamples.onmicrosoft.com/", + }), + } as any; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("start", () => { + it("should return SignInCodeSendResult when challenge type is OOB", async () => { + signInApiClient.initiate.mockResolvedValue({ + continuation_token: "continuation_token_1", + }); + signInApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.OOB, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + code_length: 6, + challenge_channel: "email", + challenge_target_label: "email", + }); + + const result = await client.start({ + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ + ChallengeType.OOB, + ChallengeType.PASSWORD, + ChallengeType.REDIRECT, + ], + correlationId: "corr123", + }); + + expect(result.type === SIGN_IN_CODE_SEND_RESULT_TYPE).toBeTruthy(); + + const codeSendResult = result as SignInCodeSendResult; + expect(codeSendResult.correlationId).toBe("corr123"); + expect(codeSendResult.continuationToken).toBe( + "continuation_token_2" + ); + expect(codeSendResult.codeLength).toBe(6); + expect(codeSendResult.challengeChannel).toBe("email"); + expect(codeSendResult.challengeTargetLabel).toBe("email"); + }); + + it("should return SignInContinuationTokenResult when challenge type is PASSWORD", async () => { + signInApiClient.initiate.mockResolvedValue({ + continuation_token: "continuation_token_1", + }); + signInApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.PASSWORD, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + }); + + const result = await client.start({ + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ + ChallengeType.OOB, + ChallengeType.PASSWORD, + ChallengeType.REDIRECT, + ], + correlationId: "corr123", + }); + + expect(result.type).toStrictEqual( + SIGN_IN_PASSWORD_REQUIRED_RESULT_TYPE + ); + expect(result.correlationId).toBe("corr123"); + expect(result.continuationToken).toBe("continuation_token_2"); + }); + }); + + describe("submitCode", () => { + it("should return SignInCompleteResult for valid code", async () => { + signInApiClient.requestTokensWithOob.mockResolvedValue({ + correlation_id: "test-correlation-id", + access_token: "test-access-token", + refresh_token: "test-refresh-token", + id_token: "test-id-token", + expires_in: 3600, + token_type: "Bearer", + }); + + const result = await client.submitCode({ + code: "123456", + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ + ChallengeType.OOB, + ChallengeType.PASSWORD, + ChallengeType.REDIRECT, + ], + correlationId: "corr123", + scopes: [], + }); + + expect(result.type).toStrictEqual(SIGN_IN_COMPLETED_RESULT_TYPE); + expect(result.correlationId).toBe("test-correlation-id"); + expect(result.authenticationResult).toBeDefined(); + expect(result.authenticationResult.accessToken).toBe( + "test-access-token" + ); + expect(result.authenticationResult.idToken).toBe("test-id-token"); + expect(result.authenticationResult.expiresOn).toBeDefined(); + expect(result.authenticationResult.tokenType).toBe("Bearer"); + expect(result.authenticationResult.authority).toBe( + authority.canonicalAuthority + ); + expect(result.authenticationResult.tenantId).toBe("test-tenant-id"); + expect(result.authenticationResult.account).toBeDefined(); + expect(result.authenticationResult.account.username).toBe( + "abc@abc.com" + ); + }); + }); + + describe("submitPassword", () => { + it("should return SignInCompleteResult for valid password", async () => { + signInApiClient.requestTokensWithPassword.mockResolvedValue({ + correlation_id: "test-correlation-id", + access_token: "test-access-token", + refresh_token: "test-refresh-token", + id_token: "test-id-token", + expires_in: 3600, + token_type: "Bearer", + }); + + const result = await client.submitPassword({ + password: "123456", + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ + ChallengeType.OOB, + ChallengeType.PASSWORD, + ChallengeType.REDIRECT, + ], + correlationId: "corr123", + scopes: [], + }); + + expect(result.type).toStrictEqual(SIGN_IN_COMPLETED_RESULT_TYPE); + expect(result.correlationId).toBe("test-correlation-id"); + expect(result.authenticationResult).toBeDefined(); + expect(result.authenticationResult.accessToken).toBe( + "test-access-token" + ); + expect(result.authenticationResult.idToken).toBe("test-id-token"); + expect(result.authenticationResult.expiresOn).toBeDefined(); + expect(result.authenticationResult.tokenType).toBe("Bearer"); + expect(result.authenticationResult.authority).toBe( + authority.canonicalAuthority + ); + expect(result.authenticationResult.tenantId).toBe("test-tenant-id"); + expect(result.authenticationResult.account).toBeDefined(); + expect(result.authenticationResult.account.username).toBe( + "abc@abc.com" + ); + }); + }); + + describe("resendCode", () => { + it("should return SignInCodeSendResult", async () => { + signInApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.OOB, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + code_length: 6, + challenge_channel: "email", + challenge_target_label: "email", + }); + + const result = await client.resendCode({ + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ + ChallengeType.OOB, + ChallengeType.PASSWORD, + ChallengeType.REDIRECT, + ], + correlationId: "corr123", + }); + + expect(result.correlationId).toBe("corr123"); + expect(result.continuationToken).toBe("continuation_token_2"); + expect(result.codeLength).toBe(6); + expect(result.challengeChannel).toBe("email"); + expect(result.challengeTargetLabel).toBe("email"); + }); + }); + + describe("signInWithContinuationToken", () => { + it("should return SignInCompleteResult", async () => { + signInApiClient.requestTokenWithContinuationToken.mockResolvedValue( + { + correlation_id: "test-correlation-id", + access_token: "test-access-token", + refresh_token: "test-refresh-token", + id_token: "test-id-token", + expires_in: 3600, + token_type: "Bearer", + } + ); + + const result = await client.signInWithContinuationToken({ + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ + ChallengeType.OOB, + ChallengeType.PASSWORD, + ChallengeType.REDIRECT, + ], + correlationId: "corr123", + scopes: [], + signInScenario: SignInScenario.SignInAfterSignUp, + }); + + expect(result.correlationId).toBe("test-correlation-id"); + expect(result.authenticationResult).toBeDefined(); + expect(result.authenticationResult.accessToken).toBe( + "test-access-token" + ); + expect(result.authenticationResult.idToken).toBe("test-id-token"); + expect(result.authenticationResult.expiresOn).toBeDefined(); + expect(result.authenticationResult.tokenType).toBe("Bearer"); + expect(result.authenticationResult.authority).toBe( + authority.canonicalAuthority + ); + expect(result.authenticationResult.tenantId).toBe("test-tenant-id"); + expect(result.authenticationResult.account).toBeDefined(); + expect(result.authenticationResult.account.username).toBe( + "abc@abc.com" + ); + }); + }); +}); diff --git a/lib/msal-browser/test/custom_auth/sign_up/auth_flow/error_type/SignUpError.spec.ts b/lib/msal-browser/test/custom_auth/sign_up/auth_flow/error_type/SignUpError.spec.ts new file mode 100644 index 0000000000..f38030c112 --- /dev/null +++ b/lib/msal-browser/test/custom_auth/sign_up/auth_flow/error_type/SignUpError.spec.ts @@ -0,0 +1,189 @@ +import { + CustomAuthApiError, + RedirectError, +} from "../../../../../src/custom_auth/core/error/CustomAuthApiError.js"; +import * as CustomAuthApiErrorCode from "../../../../../src/custom_auth/core/network_client/custom_auth_api/types/ApiErrorCodes.js"; +import * as CustomAuthApiSuberror from "../../../../../src/custom_auth/core/network_client/custom_auth_api/types/ApiSuberrors.js"; +import { InvalidArgumentError } from "../../../../../src/custom_auth/index.js"; +import { + SignUpError, + SignUpResendCodeError, + SignUpSubmitAttributesError, + SignUpSubmitCodeError, + SignUpSubmitPasswordError, +} from "../../../../../src/custom_auth/sign_up/auth_flow/error_type/SignUpError.js"; + +describe("SignUpError", () => { + it("should correctly identify user already exists error", () => { + const error = new CustomAuthApiError( + CustomAuthApiErrorCode.USER_ALREADY_EXISTS, + "User already exists" + ); + const signUpError = new SignUpError(error); + expect(signUpError.isUserAlreadyExists()).toBe(true); + }); + + it("should correctly identify invalid username error", () => { + const error = new InvalidArgumentError("Invalid username"); + const signUpError = new SignUpError(error); + expect(signUpError.isInvalidUsername()).toBe(true); + + const error2 = new CustomAuthApiError( + "Some Error", + "username parameter is empty or not valid", + undefined, + [90100] + ); + const signUpError2 = new SignUpError(error2); + expect(signUpError2.isInvalidUsername()).toBe(true); + }); + + it("should correctly identify invalid password error", () => { + const error = new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_GRANT, + "Invalid password", + undefined, + undefined, + CustomAuthApiSuberror.PASSWORD_IS_INVALID + ); + const signUpError = new SignUpError(error); + expect(signUpError.isInvalidPassword()).toBe(true); + }); + + it("should correctly identify missing required attributes error", () => { + const error = new CustomAuthApiError( + CustomAuthApiErrorCode.ATTRIBUTES_REQUIRED, + "Attributes required" + ); + const signUpError = new SignUpError(error); + expect(signUpError.isMissingRequiredAttributes()).toBe(true); + }); + + it("should correctly identify attributes validation failed error", () => { + const error = new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_GRANT, + "Attributes validation failed", + undefined, + undefined, + CustomAuthApiSuberror.ATTRIBUTE_VALIATION_FAILED + ); + const signUpError = new SignUpError(error); + expect(signUpError.isAttributesValidationFailed()).toBe(true); + }); + + it("should correctly identify unsupported challenge type error", () => { + const error = new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_REQUEST, + "The challenge_type list parameter contains an unsupported challenge type" + ); + const signUpError = new SignUpError(error); + expect(signUpError.isUnsupportedChallengeType()).toBe(true); + + const error2 = new CustomAuthApiError( + CustomAuthApiErrorCode.UNSUPPORTED_CHALLENGE_TYPE, + "Unsupported challenge type" + ); + const signUpError2 = new SignUpError(error2); + expect(signUpError2.isUnsupportedChallengeType()).toBe(true); + }); + + it("should correctly identify redirect error", () => { + const error = new RedirectError("Redirecting..."); + const signUpError = new SignUpError(error); + expect(signUpError.isRedirectRequired()).toBe(true); + }); +}); + +describe("SignUpSubmitPasswordError", () => { + it("should correctly identify invalid password error", () => { + const error = new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_GRANT, + "Invalid password", + undefined, + undefined, + CustomAuthApiSuberror.PASSWORD_IS_INVALID + ); + const signUpError = new SignUpSubmitPasswordError(error); + expect(signUpError.isInvalidPassword()).toBe(true); + + const error2 = new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_GRANT, + "Incorrect password", + undefined, + [50126] + ); + const signUpError2 = new SignUpSubmitPasswordError(error2); + expect(signUpError2.isInvalidPassword()).toBe(true); + + const error3 = new InvalidArgumentError("password is required"); + const signUpError3 = new SignUpSubmitPasswordError(error3); + expect(signUpError3.isInvalidPassword()).toBe(true); + }); + + it("should correctly identify redirect error", () => { + const error = new RedirectError("Redirecting..."); + const signUpError = new SignUpSubmitPasswordError(error); + expect(signUpError.isRedirectRequired()).toBe(true); + }); +}); + +describe("SignUpSubmitCodeError", () => { + it("should correctly identify invalid code error", () => { + const error = new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_GRANT, + "Invalid code", + undefined, + undefined, + CustomAuthApiSuberror.INVALID_OOB_VALUE + ); + const signUpError = new SignUpSubmitCodeError(error); + expect(signUpError.isInvalidCode()).toBe(true); + + const error2 = new InvalidArgumentError("Invalid code"); + const signUpError2 = new SignUpSubmitCodeError(error2); + expect(signUpError2.isInvalidCode()).toBe(true); + }); + + it("should correctly identify redirect error", () => { + const error = new RedirectError("Redirecting..."); + const signUpError = new SignUpSubmitCodeError(error); + expect(signUpError.isRedirectRequired()).toBe(true); + }); +}); + +describe("SignUpSubmitAttributesError", () => { + it("should correctly identify missing required attributes error", () => { + const error = new CustomAuthApiError( + CustomAuthApiErrorCode.ATTRIBUTES_REQUIRED, + "Attributes required" + ); + const signUpError = new SignUpSubmitAttributesError(error); + expect(signUpError.isMissingRequiredAttributes()).toBe(true); + }); + + it("should correctly identify attributes validation failed error", () => { + const error = new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_GRANT, + "Attributes validation failed", + undefined, + undefined, + CustomAuthApiSuberror.ATTRIBUTE_VALIATION_FAILED + ); + const signUpError = new SignUpSubmitAttributesError(error); + expect(signUpError.isAttributesValidationFailed()).toBe(true); + }); + + it("should correctly identify redirect error", () => { + const error = new RedirectError("Redirecting..."); + const signUpError = new SignUpSubmitAttributesError(error); + expect(signUpError.isRedirectRequired()).toBe(true); + }); +}); + +describe("SignUpResendCodeError", () => { + it("should correctly identify redirect error", () => { + const error = new RedirectError("Redirecting..."); + const signUpError = new SignUpResendCodeError(error); + expect(signUpError.isRedirectRequired()).toBe(true); + }); +}); diff --git a/lib/msal-browser/test/custom_auth/sign_up/auth_flow/state/SignUpAttributesRequiredState.spec.ts b/lib/msal-browser/test/custom_auth/sign_up/auth_flow/state/SignUpAttributesRequiredState.spec.ts new file mode 100644 index 0000000000..884c450654 --- /dev/null +++ b/lib/msal-browser/test/custom_auth/sign_up/auth_flow/state/SignUpAttributesRequiredState.spec.ts @@ -0,0 +1,104 @@ +import { CustomAuthBrowserConfiguration } from "../../../../../src/custom_auth/configuration/CustomAuthConfiguration.js"; +import { SignUpSubmitAttributesError } from "../../../../../src/custom_auth/sign_up/auth_flow/error_type/SignUpError.js"; +import { SignUpSubmitAttributesResult } from "../../../../../src/custom_auth/sign_up/auth_flow/result/SignUpSubmitAttributesResult.js"; +import { SignUpAttributesRequiredState } from "../../../../../src/custom_auth/sign_up/auth_flow/state/SignUpAttributesRequiredState.js"; +import { createSignUpCompletedResult } from "../../../../../src/custom_auth/sign_up/interaction_client/result/SignUpActionResult.js"; +import { SignUpClient } from "../../../../../src/custom_auth/sign_up/interaction_client/SignUpClient.js"; +import { Logger } from "@azure/msal-browser"; +import { SignInClient } from "../../../../../src/custom_auth/sign_in/interaction_client/SignInClient.js"; +import { UserAccountAttributes } from "../../../../../src/custom_auth/UserAccountAttributes.js"; +import { CustomAuthSilentCacheClient } from "../../../../../src/custom_auth/get_account/interaction_client/CustomAuthSilentCacheClient.js"; + +describe("SignUpAttributesRequiredState", () => { + const mockConfig = { + auth: { clientId: "test-client-id" }, + customAuth: { challengeTypes: ["attributes"] }, + } as unknown as jest.Mocked; + + const mockSignUpClient = { + submitAttributes: jest.fn(), + } as unknown as jest.Mocked; + + const mockSignInClient = {} as unknown as jest.Mocked; + + const mockLogger = { + info: jest.fn(), + verbose: jest.fn(), + error: jest.fn(), + errorPii: jest.fn(), + } as unknown as jest.Mocked; + + const username = "testuser"; + const correlationId = "test-correlation-id"; + const continuationToken = "test-continuation-token"; + const requiredAttributes: UserAccountAttributes = { + displayName: "test-value", + }; + + let state: SignUpAttributesRequiredState; + + beforeEach(() => { + state = new SignUpAttributesRequiredState({ + username: username, + signUpClient: mockSignUpClient, + signInClient: mockSignInClient, + cacheClient: + {} as unknown as jest.Mocked, + correlationId: correlationId, + logger: mockLogger, + continuationToken: continuationToken, + config: mockConfig, + requiredAttributes: [ + { + name: "name", + type: "string", + }, + ], + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("submitAttributes", () => { + it("should return an error result if attributes is empty", async () => { + const result1 = await state.submitAttributes( + null as unknown as UserAccountAttributes + ); + + expect(result1.isFailed()).toBeTruthy(); + expect(result1.error).toBeInstanceOf(SignUpSubmitAttributesError); + expect(result1.error?.isAttributesValidationFailed()).toBe(true); + + const result2 = await state.submitAttributes({}); + + expect(result2.isFailed()).toBeTruthy(); + expect(result2.error).toBeInstanceOf(SignUpSubmitAttributesError); + expect(result2.error?.isAttributesValidationFailed()).toBe(true); + }); + + it("should successfully submit a attributes and return completed state if no credentail required", async () => { + mockSignUpClient.submitAttributes.mockResolvedValue( + createSignUpCompletedResult({ + correlationId: correlationId, + continuationToken: "continuation-token", + }) + ); + + const result = await state.submitAttributes(requiredAttributes); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignUpSubmitAttributesResult); + expect(result.isCompleted()).toBe(true); + expect(mockSignUpClient.submitAttributes).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: ["attributes"], + continuationToken: continuationToken, + attributes: requiredAttributes, + username: username, + }); + }); + }); +}); diff --git a/lib/msal-browser/test/custom_auth/sign_up/auth_flow/state/SignUpCodeRequiredState.spec.ts b/lib/msal-browser/test/custom_auth/sign_up/auth_flow/state/SignUpCodeRequiredState.spec.ts new file mode 100644 index 0000000000..b6d56e7a28 --- /dev/null +++ b/lib/msal-browser/test/custom_auth/sign_up/auth_flow/state/SignUpCodeRequiredState.spec.ts @@ -0,0 +1,175 @@ +import { CustomAuthBrowserConfiguration } from "../../../../../src/custom_auth/configuration/CustomAuthConfiguration.js"; +import { InvalidArgumentError } from "../../../../../src/custom_auth/core/error/InvalidArgumentError.js"; +import { SignUpSubmitCodeError } from "../../../../../src/custom_auth/sign_up/auth_flow/error_type/SignUpError.js"; +import { SignUpResendCodeResult } from "../../../../../src/custom_auth/sign_up/auth_flow/result/SignUpResendCodeResult.js"; +import { SignUpSubmitCodeResult } from "../../../../../src/custom_auth/sign_up/auth_flow/result/SignUpSubmitCodeResult.js"; +import { SignUpCodeRequiredState } from "../../../../../src/custom_auth/sign_up/auth_flow/state/SignUpCodeRequiredState.js"; +import { + createSignUpAttributesRequiredResult, + createSignUpCodeRequiredResult, + createSignUpCompletedResult, + createSignUpPasswordRequiredResult, +} from "../../../../../src/custom_auth/sign_up/interaction_client/result/SignUpActionResult.js"; +import { SignUpClient } from "../../../../../src/custom_auth/sign_up/interaction_client/SignUpClient.js"; +import { Logger } from "@azure/msal-browser"; +import { SignInClient } from "../../../../../src/custom_auth/sign_in/interaction_client/SignInClient.js"; +import { CustomAuthSilentCacheClient } from "../../../../../src/custom_auth/get_account/interaction_client/CustomAuthSilentCacheClient.js"; + +describe("SignUpCodeRequiredState", () => { + const mockConfig = { + auth: { clientId: "test-client-id" }, + customAuth: { challengeTypes: ["code"] }, + } as unknown as jest.Mocked; + + const mockSignUpClient = { + submitCode: jest.fn(), + resendCode: jest.fn(), + } as unknown as jest.Mocked; + + const mockSignInClient = {} as unknown as jest.Mocked; + + const mockLogger = { + info: jest.fn(), + verbose: jest.fn(), + error: jest.fn(), + errorPii: jest.fn(), + } as unknown as jest.Mocked; + + const username = "testuser"; + const correlationId = "test-correlation-id"; + const continuationToken = "test-continuation-token"; + + let state: SignUpCodeRequiredState; + + beforeEach(() => { + state = new SignUpCodeRequiredState({ + username: username, + signUpClient: mockSignUpClient, + signInClient: mockSignInClient, + cacheClient: + {} as unknown as jest.Mocked, + correlationId: correlationId, + logger: mockLogger, + continuationToken: continuationToken, + config: mockConfig, + codeLength: 8, + codeResendInterval: 60, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("submitCode", () => { + it("should return an error result if code is empty", async () => { + const result = await state.submitCode(""); + + expect(result.isFailed()).toBeTruthy(); + expect(result.error).toBeInstanceOf(SignUpSubmitCodeError); + expect(result.error?.isInvalidCode()).toBe(true); + expect(result.error?.errorData).toBeInstanceOf( + InvalidArgumentError + ); + expect(result.error?.errorData?.errorDescription).toContain("code"); + }); + + it("should successfully submit a code and return completed state if no credentail required", async () => { + mockSignUpClient.submitCode.mockResolvedValue( + createSignUpCompletedResult({ + correlationId: correlationId, + continuationToken: "continuation-token", + }) + ); + + const result = await state.submitCode("12345678"); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignUpSubmitCodeResult); + expect(result.isCompleted()).toBe(true); + expect(mockSignUpClient.submitCode).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: ["code"], + continuationToken: continuationToken, + code: "12345678", + username: username, + }); + }); + + it("should successfully submit a code and return password-required state if password is required", async () => { + mockSignUpClient.submitCode.mockResolvedValue( + createSignUpPasswordRequiredResult({ + correlationId: correlationId, + continuationToken: "continuation-token", + }) + ); + + const result = await state.submitCode("12345678"); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignUpSubmitCodeResult); + expect(result.isPasswordRequired()).toBe(true); + expect(mockSignUpClient.submitCode).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: ["code"], + continuationToken: continuationToken, + code: "12345678", + username: username, + }); + }); + + it("should successfully submit a code and return attributes-required state if attributes are required", async () => { + mockSignUpClient.submitCode.mockResolvedValue( + createSignUpAttributesRequiredResult({ + correlationId: correlationId, + continuationToken: "continuation-token", + requiredAttributes: [ + { + name: "name", + type: "string", + }, + ], + }) + ); + + const result = await state.submitCode("12345678"); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignUpSubmitCodeResult); + expect(result.isAttributesRequired()).toBe(true); + expect(mockSignUpClient.submitCode).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: ["code"], + continuationToken: continuationToken, + code: "12345678", + username: username, + }); + }); + }); + + describe("resendCode", () => { + it("should successfully resend a code and return a code required state", async () => { + mockSignUpClient.resendCode.mockResolvedValue( + createSignUpCodeRequiredResult({ + correlationId: correlationId, + continuationToken: "new-continuation-token", + challengeChannel: "code", + challengeTargetLabel: "email", + codeLength: 6, + interval: 60, + bindingMethod: "email-otp", + }) + ); + + const result = await state.resendCode(); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignUpResendCodeResult); + expect(result.data).toBeUndefined(); + expect(result.isCodeRequired()).toBeTruthy(); + }); + }); +}); diff --git a/lib/msal-browser/test/custom_auth/sign_up/auth_flow/state/SignUpPasswordRequiredState.spec.ts b/lib/msal-browser/test/custom_auth/sign_up/auth_flow/state/SignUpPasswordRequiredState.spec.ts new file mode 100644 index 0000000000..64c15d714b --- /dev/null +++ b/lib/msal-browser/test/custom_auth/sign_up/auth_flow/state/SignUpPasswordRequiredState.spec.ts @@ -0,0 +1,125 @@ +import { CustomAuthBrowserConfiguration } from "../../../../../src/custom_auth/configuration/CustomAuthConfiguration.js"; +import { InvalidArgumentError } from "../../../../../src/custom_auth/core/error/InvalidArgumentError.js"; +import { SignUpSubmitPasswordError } from "../../../../../src/custom_auth/sign_up/auth_flow/error_type/SignUpError.js"; +import { SignUpSubmitPasswordResult } from "../../../../../src/custom_auth/sign_up/auth_flow/result/SignUpSubmitPasswordResult.js"; +import { SignUpPasswordRequiredState } from "../../../../../src/custom_auth/sign_up/auth_flow/state/SignUpPasswordRequiredState.js"; +import { + createSignUpAttributesRequiredResult, + createSignUpCompletedResult, +} from "../../../../../src/custom_auth/sign_up/interaction_client/result/SignUpActionResult.js"; +import { SignUpClient } from "../../../../../src/custom_auth/sign_up/interaction_client/SignUpClient.js"; +import { Logger } from "@azure/msal-browser"; +import { SignInClient } from "../../../../../src/custom_auth/sign_in/interaction_client/SignInClient.js"; +import { CustomAuthSilentCacheClient } from "../../../../../src/custom_auth/get_account/interaction_client/CustomAuthSilentCacheClient.js"; + +describe("SignUpPasswordRequiredState", () => { + const mockConfig = { + auth: { clientId: "test-client-id" }, + customAuth: { challengeTypes: ["password"] }, + } as unknown as jest.Mocked; + + const mockSignUpClient = { + submitPassword: jest.fn(), + } as unknown as jest.Mocked; + + const mockSignInClient = {} as unknown as jest.Mocked; + + const mockLogger = { + info: jest.fn(), + verbose: jest.fn(), + error: jest.fn(), + errorPii: jest.fn(), + } as unknown as jest.Mocked; + + const username = "testuser"; + const correlationId = "test-correlation-id"; + const continuationToken = "test-continuation-token"; + + let state: SignUpPasswordRequiredState; + + beforeEach(() => { + state = new SignUpPasswordRequiredState({ + username: username, + signUpClient: mockSignUpClient, + signInClient: mockSignInClient, + cacheClient: + {} as unknown as jest.Mocked, + correlationId: correlationId, + logger: mockLogger, + continuationToken: continuationToken, + config: mockConfig, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("submitPassword", () => { + it("should return an error result if password is empty", async () => { + const result = await state.submitPassword(""); + + expect(result.isFailed()).toBeTruthy(); + expect(result.error).toBeInstanceOf(SignUpSubmitPasswordError); + expect(result.error?.isInvalidPassword()).toBe(true); + expect(result.error?.errorData).toBeInstanceOf( + InvalidArgumentError + ); + expect(result.error?.errorData?.errorDescription).toContain( + "password" + ); + }); + + it("should successfully submit a password and return completed state if no credentail required", async () => { + mockSignUpClient.submitPassword.mockResolvedValue( + createSignUpCompletedResult({ + correlationId: correlationId, + continuationToken: "continuation-token", + }) + ); + + const result = await state.submitPassword("valid-password"); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignUpSubmitPasswordResult); + expect(result.isCompleted()).toBe(true); + expect(mockSignUpClient.submitPassword).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: ["password"], + continuationToken: continuationToken, + password: "valid-password", + username: username, + }); + }); + + it("should successfully submit a password and return attributes-required state if attributes are required", async () => { + mockSignUpClient.submitPassword.mockResolvedValue( + createSignUpAttributesRequiredResult({ + correlationId: correlationId, + continuationToken: "continuation-token", + requiredAttributes: [ + { + name: "name", + type: "string", + }, + ], + }) + ); + + const result = await state.submitPassword("valid-password"); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignUpSubmitPasswordResult); + expect(result.isAttributesRequired()).toBe(true); + expect(mockSignUpClient.submitPassword).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: ["password"], + continuationToken: continuationToken, + password: "valid-password", + username: username, + }); + }); + }); +}); diff --git a/lib/msal-browser/test/custom_auth/sign_up/interaction_client/SignUpClient.spec.ts b/lib/msal-browser/test/custom_auth/sign_up/interaction_client/SignUpClient.spec.ts new file mode 100644 index 0000000000..1414b1b2c1 --- /dev/null +++ b/lib/msal-browser/test/custom_auth/sign_up/interaction_client/SignUpClient.spec.ts @@ -0,0 +1,692 @@ +import { SignUpClient } from "../../../../src/custom_auth/sign_up/interaction_client/SignUpClient.js"; +import { customAuthConfig } from "../../test_resources/CustomAuthConfig.js"; +import { CustomAuthAuthority } from "../../../../src/custom_auth/core/CustomAuthAuthority.js"; +import { ChallengeType } from "../../../../src/custom_auth/CustomAuthConstants.js"; +import { + SIGN_UP_ATTRIBUTES_REQUIRED_RESULT_TYPE, + SIGN_UP_CODE_REQUIRED_RESULT_TYPE, + SIGN_UP_COMPLETED_RESULT_TYPE, + SIGN_UP_PASSWORD_REQUIRED_RESULT_TYPE, + SignUpCodeRequiredResult, +} from "../../../../src/custom_auth/sign_up/interaction_client/result/SignUpActionResult.js"; +import { CustomAuthApiError } from "../../../../src/custom_auth/index.js"; +import * as CustomAuthApiErrorCode from "../../../../src/custom_auth/core/network_client/custom_auth_api/types/ApiErrorCodes.js"; +import { + ICrypto, + INetworkModule, + IPerformanceClient, + Logger, +} from "@azure/msal-common/browser"; +import { BrowserConfiguration } from "../../../../src/config/Configuration.js"; +import { BrowserCacheManager } from "../../../../src/cache/BrowserCacheManager.js"; +import { EventHandler } from "../../../../src/event/EventHandler.js"; +import { INavigationClient } from "../../../../src/navigation/INavigationClient.js"; + +jest.mock( + "../../../../src/custom_auth/core/network_client/custom_auth_api/CustomAuthApiClient.js", + () => { + let signInApiClient = { + initiate: jest.fn(), + requestChallenge: jest.fn(), + requestTokensWithPassword: jest.fn(), + requestTokensWithOob: jest.fn(), + signInWithContinuationToken: jest.fn(), + }; + let signUpApiClient = { + start: jest.fn(), + requestChallenge: jest.fn(), + continueWithCode: jest.fn(), + continueWithPassword: jest.fn(), + continueWithAttributes: jest.fn(), + }; + let resetPasswordApiClient = { + start: jest.fn(), + requestChallenge: jest.fn(), + continueWithCode: jest.fn(), + submitNewPassword: jest.fn(), + pollCompletion: jest.fn(), + }; + + const CustomAuthApiClient = jest.fn().mockImplementation(() => ({ + signInApi: signInApiClient, + signUpApi: signUpApiClient, + resetPasswordApi: resetPasswordApiClient, + })); + + const mockedApiClient = new CustomAuthApiClient(); + return { + mockedApiClient, + signInApiClient, + signUpApiClient, + resetPasswordApiClient, + }; + } +); + +describe("SignUpClient", () => { + let client: SignUpClient; + let authority: CustomAuthAuthority; + const { + mockedApiClient, + signInApiClient, + signUpApiClient, + resetPasswordApiClient, + } = jest.requireMock( + "../../../../src/custom_auth/core/network_client/custom_auth_api/CustomAuthApiClient.js" + ); + beforeEach(() => { + jest.resetAllMocks(); + const mockBrowserConfiguration = { + system: { + networkClient: { + sendGetRequestAsync: jest.fn(), + sendPostRequestAsync: jest.fn(), + } as unknown as jest.Mocked, + }, + auth: { + clientId: customAuthConfig.auth.clientId, + }, + } as unknown as jest.Mocked; + + const mockCacheManager = { + getWrapperMetadata: jest.fn(), + getServerTelemetry: jest.fn(), + generateAuthorityMetadataCacheKey: jest.fn(), + setAuthorityMetadata: jest.fn(), + } as unknown as jest.Mocked; + mockCacheManager.getWrapperMetadata.mockReturnValue(["", ""]); + mockCacheManager.getServerTelemetry.mockReturnValue(null); + + const mockCrypto = { + createNewGuid: jest.fn(), + } as unknown as jest.Mocked; + + const mockEventHandler = {} as unknown as jest.Mocked; + const mockNavigationClient = + {} as unknown as jest.Mocked; + const mockPerformanceClient = + {} as unknown as jest.Mocked; + const mockNetworkModule = {} as unknown as jest.Mocked; + + const mockLogger = { + clone: jest.fn(), + verbose: jest.fn(), + info: jest.fn(), + error: jest.fn(), + errorPii: jest.fn(), + } as unknown as jest.Mocked; + mockLogger.clone.mockReturnValue(mockLogger); + + const mockConfig = { + auth: { + OIDCOptions: {}, + knownAuthorities: [], + cloudDiscoveryMetadata: "", + authorityMetadata: "", + }, + system: { + protocolMode: "", + }, + } as unknown as jest.Mocked; + + authority = new CustomAuthAuthority( + customAuthConfig.auth.authority ?? "", + mockConfig, + mockNetworkModule, + mockCacheManager, + mockLogger, + customAuthConfig.customAuth.authApiProxyUrl + ); + + client = new SignUpClient( + mockBrowserConfiguration, + mockCacheManager, + mockCrypto, + mockLogger, + mockEventHandler, + mockNavigationClient, + mockPerformanceClient, + mockedApiClient, + authority + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe("start", () => { + it("should return SignUpCodeRequiredResult when challenge type is OOB", async () => { + signUpApiClient.start.mockResolvedValue({ + continuation_token: "continuation_token_1", + }); + signUpApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.OOB, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + code_length: 6, + challenge_channel: "email", + challenge_target_label: "email", + }); + + const result = await client.start({ + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ + ChallengeType.OOB, + ChallengeType.PASSWORD, + ChallengeType.REDIRECT, + ], + correlationId: "corr123", + }); + + expect(result.type).toStrictEqual( + SIGN_UP_CODE_REQUIRED_RESULT_TYPE + ); + const codeSendResult = result as SignUpCodeRequiredResult; + expect(codeSendResult.correlationId).toBe("corr123"); + expect(codeSendResult.continuationToken).toBe( + "continuation_token_2" + ); + expect(codeSendResult.codeLength).toBe(6); + expect(codeSendResult.challengeChannel).toBe("email"); + expect(codeSendResult.challengeTargetLabel).toBe("email"); + }); + + it("should return SignUpPasswordRequiredResult when challenge type is PASSWORD", async () => { + signUpApiClient.start.mockResolvedValue({ + continuation_token: "continuation_token_1", + }); + signUpApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.PASSWORD, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + }); + + const result = await client.start({ + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ + ChallengeType.OOB, + ChallengeType.PASSWORD, + ChallengeType.REDIRECT, + ], + correlationId: "corr123", + }); + + expect(result.type).toStrictEqual( + SIGN_UP_PASSWORD_REQUIRED_RESULT_TYPE + ); + expect(result.correlationId).toBe("corr123"); + expect(result.continuationToken).toBe("continuation_token_2"); + }); + }); + + describe("submitCode", () => { + it("should return SignUpCompletedResult for valid code", async () => { + signUpApiClient.continueWithCode.mockResolvedValue({ + continuation_token: "continuation_token_2", + }); + + const result = await client.submitCode({ + code: "123456", + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ + ChallengeType.OOB, + ChallengeType.PASSWORD, + ChallengeType.REDIRECT, + ], + correlationId: "corr123", + }); + + expect(result.type).toStrictEqual(SIGN_UP_COMPLETED_RESULT_TYPE); + expect(result.correlationId).toBe("corr123"); + expect(result.continuationToken).toBe("continuation_token_2"); + }); + + it("should return SignUpPasswordRequiredResult if password is required", async () => { + signUpApiClient.continueWithCode.mockRejectedValue( + new CustomAuthApiError( + CustomAuthApiErrorCode.CREDENTIAL_REQUIRED, + "Password required", + "corr123", + [55103], + undefined, + undefined, + "continuation_token_1" + ) + ); + + signUpApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.PASSWORD, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + }); + + const result = await client.submitCode({ + code: "123456", + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ + ChallengeType.OOB, + ChallengeType.PASSWORD, + ChallengeType.REDIRECT, + ], + correlationId: "corr123", + }); + + expect(result.type).toStrictEqual( + SIGN_UP_PASSWORD_REQUIRED_RESULT_TYPE + ); + expect(result.correlationId).toBe("corr123"); + expect(result.continuationToken).toBe("continuation_token_2"); + + expect(signUpApiClient.requestChallenge).toHaveBeenCalledWith( + expect.objectContaining({ + correlationId: "corr123", + continuation_token: "continuation_token_1", + }) + ); + }); + + it("should throw error if credential is required but challenge type password isn't supported", async () => { + signUpApiClient.continueWithCode.mockRejectedValue( + new CustomAuthApiError( + CustomAuthApiErrorCode.CREDENTIAL_REQUIRED, + "Password required", + "corr123", + [55103], + undefined, + undefined, + "continuation_token_1" + ) + ); + + signUpApiClient.requestChallenge.mockResolvedValue({ + challenge_type: "passkey", + correlation_id: "corr123", + continuation_token: "continuation_token_2", + }); + + await expect( + client.submitCode({ + code: "123456", + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ + ChallengeType.OOB, + ChallengeType.PASSWORD, + ChallengeType.REDIRECT, + ], + correlationId: "corr123", + }) + ).rejects.toMatchObject({ + error: CustomAuthApiErrorCode.UNSUPPORTED_CHALLENGE_TYPE, + errorDescription: "Unsupported challenge type 'passkey'.", + correlationId: "corr123", + }); + + expect(signUpApiClient.requestChallenge).toHaveBeenCalledWith( + expect.objectContaining({ + correlationId: "corr123", + continuation_token: "continuation_token_1", + }) + ); + }); + + it("should return SignUpAttributesRequiredResult if attributes are required", async () => { + signUpApiClient.continueWithCode.mockRejectedValue( + new CustomAuthApiError( + CustomAuthApiErrorCode.ATTRIBUTES_REQUIRED, + "User attributes required", + "corr123", + [55106], + undefined, + [ + { + name: "name", + type: "string", + }, + ], + "continuation_token_1" + ) + ); + + const result = await client.submitCode({ + code: "123456", + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ + ChallengeType.OOB, + ChallengeType.PASSWORD, + ChallengeType.REDIRECT, + ], + correlationId: "corr123", + }); + + expect(result.type).toStrictEqual( + SIGN_UP_ATTRIBUTES_REQUIRED_RESULT_TYPE + ); + expect(result.correlationId).toBe("corr123"); + expect(result.continuationToken).toBe("continuation_token_1"); + }); + }); + + describe("submitPassword", () => { + it("should return SignUpCompletedResult for valid password", async () => { + signUpApiClient.continueWithPassword.mockResolvedValue({ + continuation_token: "continuation_token_2", + }); + + const result = await client.submitPassword({ + password: "123456", + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ + ChallengeType.OOB, + ChallengeType.PASSWORD, + ChallengeType.REDIRECT, + ], + correlationId: "corr123", + }); + + expect(result.type).toStrictEqual(SIGN_UP_COMPLETED_RESULT_TYPE); + expect(result.correlationId).toBe("corr123"); + expect(result.continuationToken).toBe("continuation_token_2"); + }); + + it("should return SignUpCodeRequiredResult if oob is required", async () => { + signUpApiClient.continueWithPassword.mockRejectedValue( + new CustomAuthApiError( + CustomAuthApiErrorCode.CREDENTIAL_REQUIRED, + "credential required", + "corr123", + [55103], + undefined, + undefined, + "continuation_token_1" + ) + ); + + signUpApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.OOB, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + code_length: 6, + challenge_channel: "email", + challenge_target_label: "email", + }); + + const result = await client.submitPassword({ + password: "123456", + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ + ChallengeType.OOB, + ChallengeType.PASSWORD, + ChallengeType.REDIRECT, + ], + correlationId: "corr123", + }); + + expect(result.type).toStrictEqual( + SIGN_UP_CODE_REQUIRED_RESULT_TYPE + ); + expect(result.correlationId).toBe("corr123"); + expect(result.continuationToken).toBe("continuation_token_2"); + + expect(signUpApiClient.requestChallenge).toHaveBeenCalledWith( + expect.objectContaining({ + correlationId: "corr123", + continuation_token: "continuation_token_1", + }) + ); + }); + + it("should return SignUpAttributesRequiredResult if attributes are required", async () => { + signUpApiClient.continueWithPassword.mockRejectedValue( + new CustomAuthApiError( + CustomAuthApiErrorCode.ATTRIBUTES_REQUIRED, + "User attributes required", + "corr123", + [55106], + undefined, + [ + { + name: "name", + type: "string", + }, + ], + "continuation_token_1" + ) + ); + + const result = await client.submitPassword({ + password: "123456", + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ + ChallengeType.OOB, + ChallengeType.PASSWORD, + ChallengeType.REDIRECT, + ], + correlationId: "corr123", + }); + + expect(result.type).toStrictEqual( + SIGN_UP_ATTRIBUTES_REQUIRED_RESULT_TYPE + ); + expect(result.correlationId).toBe("corr123"); + expect(result.continuationToken).toBe("continuation_token_1"); + }); + }); + + describe("submitAttributes", () => { + it("should return SignUpCompletedResult for valid password", async () => { + signUpApiClient.continueWithAttributes.mockResolvedValue({ + continuation_token: "continuation_token_2", + }); + + const result = await client.submitAttributes({ + attributes: { name: "John Doe" }, + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ + ChallengeType.OOB, + ChallengeType.PASSWORD, + ChallengeType.REDIRECT, + ], + correlationId: "corr123", + }); + + expect(result.type).toStrictEqual(SIGN_UP_COMPLETED_RESULT_TYPE); + expect(result.correlationId).toBe("corr123"); + expect(result.continuationToken).toBe("continuation_token_2"); + }); + + it("should return SignUpCodeRequiredResult if oob is required", async () => { + signUpApiClient.continueWithAttributes.mockRejectedValue( + new CustomAuthApiError( + CustomAuthApiErrorCode.CREDENTIAL_REQUIRED, + "credential required", + "corr123", + [55103], + undefined, + undefined, + "continuation_token_1" + ) + ); + + signUpApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.OOB, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + code_length: 6, + challenge_channel: "email", + target_challenge_label: "email", + }); + + const result = await client.submitAttributes({ + attributes: { name: "John Doe" }, + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ + ChallengeType.OOB, + ChallengeType.PASSWORD, + ChallengeType.REDIRECT, + ], + correlationId: "corr123", + }); + + expect(result.type).toStrictEqual( + SIGN_UP_CODE_REQUIRED_RESULT_TYPE + ); + expect(result.correlationId).toBe("corr123"); + expect(result.continuationToken).toBe("continuation_token_2"); + + expect(signUpApiClient.requestChallenge).toHaveBeenCalledWith( + expect.objectContaining({ + correlationId: "corr123", + continuation_token: "continuation_token_1", + }) + ); + }); + + it("should return SignUpPasswordRequiredResult if password is required", async () => { + signUpApiClient.continueWithAttributes.mockRejectedValue( + new CustomAuthApiError( + CustomAuthApiErrorCode.CREDENTIAL_REQUIRED, + "Password required", + "corr123", + [55103], + undefined, + undefined, + "continuation_token_1" + ) + ); + + signUpApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.PASSWORD, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + }); + + const result = await client.submitAttributes({ + attributes: { name: "John Doe" }, + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ + ChallengeType.OOB, + ChallengeType.PASSWORD, + ChallengeType.REDIRECT, + ], + correlationId: "corr123", + }); + + expect(result.type).toStrictEqual( + SIGN_UP_PASSWORD_REQUIRED_RESULT_TYPE + ); + expect(result.correlationId).toBe("corr123"); + expect(result.continuationToken).toBe("continuation_token_2"); + + expect(signUpApiClient.requestChallenge).toHaveBeenCalledWith( + expect.objectContaining({ + correlationId: "corr123", + continuation_token: "continuation_token_1", + }) + ); + }); + + it("should throw error if some required attributes are missing", async () => { + signUpApiClient.continueWithAttributes.mockRejectedValue( + new CustomAuthApiError( + CustomAuthApiErrorCode.ATTRIBUTES_REQUIRED, + "User attributes required", + "corr123", + [55106], + undefined, + [ + { + name: "name", + type: "string", + }, + ], + "continuation_token_1" + ) + ); + + await expect( + client.submitAttributes({ + attributes: { name: "John Doe" }, + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ + ChallengeType.OOB, + ChallengeType.PASSWORD, + ChallengeType.REDIRECT, + ], + correlationId: "corr123", + }) + ).rejects.toMatchObject({ + error: CustomAuthApiErrorCode.ATTRIBUTES_REQUIRED, + errorDescription: "User attributes required", + correlationId: "corr123", + errorCodes: [], + subError: "", + attributes: [ + { + name: "name", + type: "string", + }, + ], + continuationToken: "continuation_token_1", + }); + }); + }); + + describe("resendCode", () => { + it("should return SignUpCodeRequiredResult", async () => { + signUpApiClient.requestChallenge.mockResolvedValue({ + challenge_type: ChallengeType.OOB, + correlation_id: "corr123", + continuation_token: "continuation_token_2", + code_length: 6, + challenge_channel: "email", + challenge_target_label: "email", + }); + + const result = await client.resendCode({ + continuationToken: "continuation_token_1", + username: "abc@abc.com", + clientId: customAuthConfig.auth.clientId, + challengeType: [ + ChallengeType.OOB, + ChallengeType.PASSWORD, + ChallengeType.REDIRECT, + ], + correlationId: "corr123", + }); + + expect(result.type).toStrictEqual( + SIGN_UP_CODE_REQUIRED_RESULT_TYPE + ); + expect(result.correlationId).toBe("corr123"); + expect(result.continuationToken).toBe("continuation_token_2"); + expect(result.codeLength).toBe(6); + expect(result.challengeChannel).toBe("email"); + expect(result.challengeTargetLabel).toBe("email"); + }); + }); +}); diff --git a/lib/msal-browser/test/custom_auth/test_resources/CustomAuthConfig.ts b/lib/msal-browser/test/custom_auth/test_resources/CustomAuthConfig.ts new file mode 100644 index 0000000000..335559e7f8 --- /dev/null +++ b/lib/msal-browser/test/custom_auth/test_resources/CustomAuthConfig.ts @@ -0,0 +1,48 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { LogLevel } from "@azure/msal-browser"; +import { CustomAuthConfiguration } from "../../../src/custom_auth/configuration/CustomAuthConfiguration.js"; + +export const customAuthConfig: CustomAuthConfiguration = { + customAuth: { + challengeTypes: ["password", "oob", "redirect"], + authApiProxyUrl: + "https://myspafunctiont1.azurewebsites.net/api/ReverseProxy/", + }, + auth: { + clientId: "d5e97fb9-24bb-418d-8e7a-4e1918303c92", + authority: "https://spasamples.ciamlogin.com/", + redirectUri: "/", + }, + cache: { + cacheLocation: "sessionStorage", + }, + system: { + loggerOptions: { + loggerCallback: (level, message, containsPii) => { + if (containsPii) { + return; + } + switch (level) { + case LogLevel.Error: + console.info(`[Error] ${message}`); + return; + case LogLevel.Info: + console.info(`[Info] ${message}`); + return; + case LogLevel.Verbose: + console.info(`[Verbose] ${message}`); + return; + case LogLevel.Warning: + console.info(`[Warning] ${message}`); + return; + default: + return; + } + }, + }, + }, +}; diff --git a/lib/msal-browser/test/custom_auth/test_resources/TestConstants.ts b/lib/msal-browser/test/custom_auth/test_resources/TestConstants.ts new file mode 100644 index 0000000000..e5ed9e9928 --- /dev/null +++ b/lib/msal-browser/test/custom_auth/test_resources/TestConstants.ts @@ -0,0 +1,57 @@ +/* + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +export const TestTokenResponse = { + ACCESS_TOKEN: "fake-access-token", + REFRESH_TOKEN: "fake-refresh-token", + // This is a mock id token with a valid signature (signed by HS265 with a fake secret key), but the claims are not real. + ID_TOKEN: + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiI4ZDljNzYzNS0wOTMzLTRiOTctYjJhZC03YzUzZDkxZGY1ZGEiLCJpc3MiOiJodHRwczovL2QzN2U1NjQ1LTQxNzAtNGNlMC1hNjE4LTFiOTAwOGIxNGU1OC5jaWFtbG9naW4uY29tL2QzN2U1NjQ1LTQxNzAtNGNlMC1hNjE4LTFiOTAwOGIxNGU1OC92Mi4wIiwiaWF0IjoxNzQwMDQ5Mjg4LCJuYmYiOjE3NDAwNDkyODgsImV4cCI6MTc0MDA1MzE4OCwiYWlvIjoiQVdRQW0vOFpBQUFBM1phQmdmWkRhaGhUOGVadThTUzhtUHFxelRIbjk5QjBIMmlUa3NvZW9mbW9pMTIya2ZvaXNqZmVnREVUVTFSczc0TkNUMDlUeUVWWjM0c3NNVnVmaHFDTVRYYjFnTUlLSFBUdEF2MlVBa2p1akZuZCtaZE8iLCJpZHAiOiJtYWlsIiwibmFtZSI6InVua25vd24iLCJvaWQiOiJkOGRjY2VlOC1iOGJjLTQ1MmMtOGJjYy1hNmViOTUzZGI0NTkiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJhYmNAdGVzdC5jb20iLCJyaCI6IjEuQWM4QXpYUzVIc1VOcHNmZXdmZWtDZmFKU3huMVUxeFVCTHhzZmV3ZnNmZVBBSTdQQUEuIiwic2lkIjoiZGNiMDQ4NjItZjk1Ni00MzAxLWIzZmMtMGZkMzhmYTViZTdmIiwic3ViIjoiYkh5VlVkUHNmc2Fmc2RmZU16TDdhM1JYdklVbGJlSVVZQVoxMm8iLCJ0aWQiOiJkMzdlNTY0NS00MTcwLTRjZTAtYTYxOC0xYjkwMDhiMTRlNTgiLCJ1dGkiOiJZWDFPREZKX3NlZnVFbUhaZGZodWVKRERCbFFEQUEiLCJ2ZXIiOiIyLjAifQ.M0FBAIMmwwGTGpVbGFEWBy3vUfBEqNdem9MT2L5r39Y", + CLIENT_INFO: + "eyJ1aWQiOiI1MTIyZWZiMS1mM2EzLTRhNWQtYjVhZS1jNTQ3NGVhMWM3YmQiLCJ1dGlkIjoiZDM3ZTU2NDUtNDE3MC00Y2UwLWE2MTgtMWI5MDA4YjE0ZTU4In0=", +} as const; + +export const TestHomeAccountId = + "5122efb1-f3a3-4a5d-b5ae-c5474ea1c7bd.d37e5645-4170-4ce0-a618-1b9008b14e58"; // fake homeAccountId +export const TestTenantId = "d37e5645-4170-4ce0-a618-1b9008b14e58"; // fake tenantId +export const TestUsername = "abc@test.com"; // fake username + +export const TestAccountDetails = { + homeAccountId: TestHomeAccountId, + environment: "spasamples.ciamlogin.com", + tenantId: TestTenantId, + username: TestUsername, + localAccountId: "d8dcce8-b8bc-452c-8bcc-a6eb953db459", + idTokenClaims: { + tid: TestTenantId, + oid: "dcb04862-f956-4301-b3fc-0fd38fa5be7f", + preferred_username: TestUsername, + }, + name: "Test User", + idToken: TestTokenResponse.ID_TOKEN, +}; + +// mock response of POST /token endpoint when renew access token +export const TestServerTokenResponse = { + status: 200, + token_type: "Bearer", + scope: "openid profile User.Read email", + expires_in: 3600, + access_token: TestTokenResponse.ACCESS_TOKEN, + refresh_token: TestTokenResponse.REFRESH_TOKEN, + id_token: TestTokenResponse.ID_TOKEN, + client_info: TestTokenResponse.CLIENT_INFO, + correlation_id: "correlation-id", +}; + +// // mock decoded id token claims +export const TestIdTokenClaims = { + name: "unknown", +}; + +export const RenewedTokens = { + ACCESS_TOKEN: "renewed-access-token", + REFRESH_TOKEN: "renewed-refresh-token", +}; diff --git a/lib/msal-browser/tsconfig.custom-auth.build.json b/lib/msal-browser/tsconfig.custom-auth.build.json new file mode 100644 index 0000000000..3177289d6b --- /dev/null +++ b/lib/msal-browser/tsconfig.custom-auth.build.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist/custom-auth-path", + }, + "include": ["src"] +}