From 771a72659a508bccfa9169930c2665482724898a Mon Sep 17 00:00:00 2001 From: Jian Shen <45603194+shenj@users.noreply.github.com> Date: Wed, 18 Jun 2025 22:23:31 +0100 Subject: [PATCH 1/4] Add native auth feature to support the external ID authentication (sign-in, sign-up and SSPR) (#7599) The changes in this PR include: - Add native auth feature to supporte the external ID authentication (sign-in, sign-up and SSPR). --------- Signed-off-by: dependabot[bot] Co-authored-by: Jian Shen Co-authored-by: nguyencuong2596 <164532638+nguyencuong2596@users.noreply.github.com> Co-authored-by: Alban Xhaferllari Co-authored-by: Alban Co-authored-by: yongdiw Co-authored-by: Thomas Norling Co-authored-by: Sameera Gajjarapu Co-authored-by: Robbie-Microsoft <87724641+Robbie-Microsoft@users.noreply.github.com> Co-authored-by: MSAL.js Release Automation Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: shylasummers --- CODEOWNERS | 1 + README.md | 7 + ...-3240f33d-ab3b-4b05-9d0f-816c70517f13.json | 7 + lib/msal-browser/package.json | 10 + lib/msal-browser/rollup.config.js | 74 +- .../src/custom_auth/CustomAuthActionInputs.ts | 37 + .../src/custom_auth/CustomAuthConstants.ts | 50 ++ .../CustomAuthPublicClientApplication.ts | 158 ++++ .../ICustomAuthPublicClientApplication.ts | 51 ++ .../src/custom_auth/UserAccountAttributes.ts | 16 + .../configuration/CustomAuthConfiguration.ts | 22 + .../CustomAuthStandardController.ts | 509 +++++++++++++ .../ICustomAuthStandardController.ts | 53 ++ .../custom_auth/core/CustomAuthAuthority.ts | 112 +++ .../core/auth_flow/AuthFlowErrorBase.ts | 140 ++++ .../core/auth_flow/AuthFlowResultBase.ts | 55 ++ .../core/auth_flow/AuthFlowState.ts | 73 ++ .../core/error/CustomAuthApiError.ts | 41 + .../custom_auth/core/error/CustomAuthError.ts | 20 + .../src/custom_auth/core/error/HttpError.ts | 13 + .../custom_auth/core/error/HttpErrorCodes.ts | 7 + .../core/error/InvalidArgumentError.ts | 15 + .../core/error/InvalidConfigurationError.ts | 13 + .../error/InvalidConfigurationErrorCodes.ts | 8 + .../core/error/MethodNotImplementedError.ts | 15 + .../core/error/MsalCustomAuthError.ts | 22 + .../core/error/NoCachedAccountFoundError.ts | 17 + .../custom_auth/core/error/ParsedUrlError.ts | 13 + .../core/error/ParsedUrlErrorCodes.ts | 6 + .../custom_auth/core/error/UnexpectedError.ts | 25 + .../core/error/UnsupportedEnvironmentError.ts | 17 + .../core/error/UserAccountAttributeError.ts | 15 + .../error/UserAccountAttributeErrorCodes.ts | 6 + .../core/error/UserAlreadySignedInError.ts | 17 + .../CustomAuthInteractionClientBase.ts | 92 +++ .../CustomAuthInterationClientFactory.ts | 57 ++ .../custom_auth_api/BaseApiClient.ts | 168 +++++ .../custom_auth_api/CustomAuthApiClient.ts | 38 + .../custom_auth_api/CustomAuthApiEndpoint.ts | 18 + .../custom_auth_api/ICustomAuthApiClient.ts | 13 + .../custom_auth_api/ResetPasswordApiClient.ts | 172 +++++ .../custom_auth_api/SignInApiClient.ts | 186 +++++ .../custom_auth_api/SignupApiClient.ts | 141 ++++ .../custom_auth_api/types/ApiErrorCodes.ts | 26 + .../types/ApiErrorResponseTypes.ts | 36 + .../custom_auth_api/types/ApiRequestTypes.ts | 91 +++ .../custom_auth_api/types/ApiResponseTypes.ts | 66 ++ .../custom_auth_api/types/ApiSuberrors.ts | 14 + .../custom_auth_api/types/ApiTypesBase.ts | 15 + .../http_client/FetchHttpClient.ts | 86 +++ .../network_client/http_client/IHttpClient.ts | 54 ++ .../custom_auth/core/telemetry/PublicApiId.ts | 37 + .../core/utils/ArgumentValidator.ts | 26 + .../src/custom_auth/core/utils/UrlUtils.ts | 25 + .../auth_flow/CustomAuthAccountData.ts | 185 +++++ .../auth_flow/error_type/GetAccountError.ts | 45 ++ .../auth_flow/result/GetAccessTokenResult.ts | 72 ++ .../auth_flow/result/GetAccountResult.ts | 69 ++ .../auth_flow/result/SignOutResult.ts | 62 ++ .../auth_flow/state/GetAccessTokenState.ts | 16 + .../auth_flow/state/GetAccountState.ts | 16 + .../auth_flow/state/SignOutState.ts | 16 + .../CustomAuthSilentCacheClient.ts | 209 ++++++ lib/msal-browser/src/custom_auth/index.ts | 185 +++++ .../CustomAuthOperatingContext.ts | 43 ++ .../error_type/ResetPasswordError.ts | 96 +++ .../result/ResetPasswordResendCodeResult.ts | 76 ++ .../result/ResetPasswordStartResult.ts | 70 ++ .../result/ResetPasswordSubmitCodeResult.ts | 70 ++ .../ResetPasswordSubmitPasswordResult.ts | 65 ++ .../state/ResetPasswordCodeRequiredState.ts | 130 ++++ .../state/ResetPasswordCompletedState.ts | 11 + .../state/ResetPasswordFailedState.ts | 11 + .../ResetPasswordPasswordRequiredState.ts | 73 ++ .../auth_flow/state/ResetPasswordState.ts | 29 + .../state/ResetPasswordStateParameters.ts | 25 + .../interaction_client/ResetPasswordClient.ts | 311 ++++++++ .../parameter/ResetPasswordParams.ts | 28 + .../result/ResetPasswordActionResult.ts | 21 + .../sign_in/auth_flow/SignInScenario.ts | 12 + .../auth_flow/error_type/SignInError.ts | 79 ++ .../result/SignInResendCodeResult.ts | 67 ++ .../sign_in/auth_flow/result/SignInResult.ts | 87 +++ .../result/SignInSubmitCodeResult.ts | 44 ++ .../result/SignInSubmitCredentialResult.ts | 43 ++ .../result/SignInSubmitPasswordResult.ts | 41 + .../state/SignInCodeRequiredState.ts | 141 ++++ .../auth_flow/state/SignInCompletedState.ts | 12 + .../state/SignInContinuationState.ts | 71 ++ .../auth_flow/state/SignInFailedState.ts | 11 + .../state/SignInPasswordRequiredState.ts | 83 +++ .../sign_in/auth_flow/state/SignInState.ts | 34 + .../auth_flow/state/SignInStateParameters.ts | 32 + .../interaction_client/SignInClient.ts | 396 ++++++++++ .../parameter/SignInParams.ts | 39 + .../result/SignInActionResult.ts | 65 ++ .../auth_flow/error_type/SignUpError.ts | 138 ++++ .../result/SignUpResendCodeResult.ts | 70 ++ .../sign_up/auth_flow/result/SignUpResult.ts | 88 +++ .../result/SignUpSubmitAttributesResult.ts | 70 ++ .../result/SignUpSubmitCodeResult.ts | 90 +++ .../result/SignUpSubmitPasswordResult.ts | 80 ++ .../state/SignUpAttributesRequiredState.ts | 115 +++ .../state/SignUpCodeRequiredState.ts | 196 +++++ .../auth_flow/state/SignUpCompletedState.ts | 11 + .../auth_flow/state/SignUpFailedState.ts | 11 + .../state/SignUpPasswordRequiredState.ts | 112 +++ .../sign_up/auth_flow/state/SignUpState.ts | 34 + .../auth_flow/state/SignUpStateParameters.ts | 31 + .../interaction_client/SignUpClient.ts | 496 +++++++++++++ .../parameter/SignUpParams.ts | 36 + .../result/SignUpActionResult.ts | 77 ++ .../CustomAuthPublicClientApplication.spec.ts | 184 +++++ .../CustomAuthStandardController.spec.ts | 442 +++++++++++ .../core/CustomAuthAuthority.spec.ts | 183 +++++ .../CustomAuthApiClient.spec.ts | 34 + .../http_client/FetchClient.spec.ts | 69 ++ .../core/utils/ArgumentValidator.spec.ts | 85 +++ .../custom_auth/core/utils/UrlUtils.spec.ts | 73 ++ .../auth_flow/CustomAuthAccountData.spec.ts | 267 +++++++ .../error_type/GetAccountError.spec.ts | 34 + .../CustomAuthSilentCacheClient.spec.ts | 448 +++++++++++ .../integration_tests/GetAccount.spec.ts | 181 +++++ .../integration_tests/ResetPassword.spec.ts | 279 +++++++ .../integration_tests/SignIn.spec.ts | 438 +++++++++++ .../integration_tests/SignUp.spec.ts | 698 ++++++++++++++++++ .../error_type/ResetPasswordError.spec.ts | 133 ++++ .../ResetPasswordCodeRequiredState.spec.ts | 132 ++++ ...ResetPasswordPasswordRequiredState.spec.ts | 127 ++++ .../ResetPasswordClient.spec.ts | 393 ++++++++++ .../auth_flow/error_type/SignInError.spec.ts | 128 ++++ .../state/SignInCodeRequiredState.spec.ts | 203 +++++ .../state/SignInContinuationState.spec.ts | 114 +++ .../state/SignInPasswordRequiredState.spec.ts | 120 +++ .../interation_client/SignInClient.spec.ts | 411 +++++++++++ .../auth_flow/error_type/SignUpError.spec.ts | 189 +++++ .../SignUpAttributesRequiredState.spec.ts | 104 +++ .../state/SignUpCodeRequiredState.spec.ts | 175 +++++ .../state/SignUpPasswordRequiredState.spec.ts | 125 ++++ .../interaction_client/SignUpClient.spec.ts | 691 +++++++++++++++++ .../test_resources/CustomAuthConfig.ts | 49 ++ .../test_resources/TestConstants.ts | 57 ++ .../tsconfig.custom-auth.build.json | 8 + 143 files changed, 14417 insertions(+), 9 deletions(-) create mode 100644 change/@azure-msal-browser-3240f33d-ab3b-4b05-9d0f-816c70517f13.json create mode 100644 lib/msal-browser/src/custom_auth/CustomAuthActionInputs.ts create mode 100644 lib/msal-browser/src/custom_auth/CustomAuthConstants.ts create mode 100644 lib/msal-browser/src/custom_auth/CustomAuthPublicClientApplication.ts create mode 100644 lib/msal-browser/src/custom_auth/ICustomAuthPublicClientApplication.ts create mode 100644 lib/msal-browser/src/custom_auth/UserAccountAttributes.ts create mode 100644 lib/msal-browser/src/custom_auth/configuration/CustomAuthConfiguration.ts create mode 100644 lib/msal-browser/src/custom_auth/controller/CustomAuthStandardController.ts create mode 100644 lib/msal-browser/src/custom_auth/controller/ICustomAuthStandardController.ts create mode 100644 lib/msal-browser/src/custom_auth/core/CustomAuthAuthority.ts create mode 100644 lib/msal-browser/src/custom_auth/core/auth_flow/AuthFlowErrorBase.ts create mode 100644 lib/msal-browser/src/custom_auth/core/auth_flow/AuthFlowResultBase.ts create mode 100644 lib/msal-browser/src/custom_auth/core/auth_flow/AuthFlowState.ts create mode 100644 lib/msal-browser/src/custom_auth/core/error/CustomAuthApiError.ts create mode 100644 lib/msal-browser/src/custom_auth/core/error/CustomAuthError.ts create mode 100644 lib/msal-browser/src/custom_auth/core/error/HttpError.ts create mode 100644 lib/msal-browser/src/custom_auth/core/error/HttpErrorCodes.ts create mode 100644 lib/msal-browser/src/custom_auth/core/error/InvalidArgumentError.ts create mode 100644 lib/msal-browser/src/custom_auth/core/error/InvalidConfigurationError.ts create mode 100644 lib/msal-browser/src/custom_auth/core/error/InvalidConfigurationErrorCodes.ts create mode 100644 lib/msal-browser/src/custom_auth/core/error/MethodNotImplementedError.ts create mode 100644 lib/msal-browser/src/custom_auth/core/error/MsalCustomAuthError.ts create mode 100644 lib/msal-browser/src/custom_auth/core/error/NoCachedAccountFoundError.ts create mode 100644 lib/msal-browser/src/custom_auth/core/error/ParsedUrlError.ts create mode 100644 lib/msal-browser/src/custom_auth/core/error/ParsedUrlErrorCodes.ts create mode 100644 lib/msal-browser/src/custom_auth/core/error/UnexpectedError.ts create mode 100644 lib/msal-browser/src/custom_auth/core/error/UnsupportedEnvironmentError.ts create mode 100644 lib/msal-browser/src/custom_auth/core/error/UserAccountAttributeError.ts create mode 100644 lib/msal-browser/src/custom_auth/core/error/UserAccountAttributeErrorCodes.ts create mode 100644 lib/msal-browser/src/custom_auth/core/error/UserAlreadySignedInError.ts create mode 100644 lib/msal-browser/src/custom_auth/core/interaction_client/CustomAuthInteractionClientBase.ts create mode 100644 lib/msal-browser/src/custom_auth/core/interaction_client/CustomAuthInterationClientFactory.ts create mode 100644 lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/BaseApiClient.ts create mode 100644 lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/CustomAuthApiClient.ts create mode 100644 lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/CustomAuthApiEndpoint.ts create mode 100644 lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/ICustomAuthApiClient.ts create mode 100644 lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/ResetPasswordApiClient.ts create mode 100644 lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/SignInApiClient.ts create mode 100644 lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/SignupApiClient.ts create mode 100644 lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/types/ApiErrorCodes.ts create mode 100644 lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/types/ApiErrorResponseTypes.ts create mode 100644 lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/types/ApiRequestTypes.ts create mode 100644 lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/types/ApiResponseTypes.ts create mode 100644 lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/types/ApiSuberrors.ts create mode 100644 lib/msal-browser/src/custom_auth/core/network_client/custom_auth_api/types/ApiTypesBase.ts create mode 100644 lib/msal-browser/src/custom_auth/core/network_client/http_client/FetchHttpClient.ts create mode 100644 lib/msal-browser/src/custom_auth/core/network_client/http_client/IHttpClient.ts create mode 100644 lib/msal-browser/src/custom_auth/core/telemetry/PublicApiId.ts create mode 100644 lib/msal-browser/src/custom_auth/core/utils/ArgumentValidator.ts create mode 100644 lib/msal-browser/src/custom_auth/core/utils/UrlUtils.ts create mode 100644 lib/msal-browser/src/custom_auth/get_account/auth_flow/CustomAuthAccountData.ts create mode 100644 lib/msal-browser/src/custom_auth/get_account/auth_flow/error_type/GetAccountError.ts create mode 100644 lib/msal-browser/src/custom_auth/get_account/auth_flow/result/GetAccessTokenResult.ts create mode 100644 lib/msal-browser/src/custom_auth/get_account/auth_flow/result/GetAccountResult.ts create mode 100644 lib/msal-browser/src/custom_auth/get_account/auth_flow/result/SignOutResult.ts create mode 100644 lib/msal-browser/src/custom_auth/get_account/auth_flow/state/GetAccessTokenState.ts create mode 100644 lib/msal-browser/src/custom_auth/get_account/auth_flow/state/GetAccountState.ts create mode 100644 lib/msal-browser/src/custom_auth/get_account/auth_flow/state/SignOutState.ts create mode 100644 lib/msal-browser/src/custom_auth/get_account/interaction_client/CustomAuthSilentCacheClient.ts create mode 100644 lib/msal-browser/src/custom_auth/index.ts create mode 100644 lib/msal-browser/src/custom_auth/operating_context/CustomAuthOperatingContext.ts create mode 100644 lib/msal-browser/src/custom_auth/reset_password/auth_flow/error_type/ResetPasswordError.ts create mode 100644 lib/msal-browser/src/custom_auth/reset_password/auth_flow/result/ResetPasswordResendCodeResult.ts create mode 100644 lib/msal-browser/src/custom_auth/reset_password/auth_flow/result/ResetPasswordStartResult.ts create mode 100644 lib/msal-browser/src/custom_auth/reset_password/auth_flow/result/ResetPasswordSubmitCodeResult.ts create mode 100644 lib/msal-browser/src/custom_auth/reset_password/auth_flow/result/ResetPasswordSubmitPasswordResult.ts create mode 100644 lib/msal-browser/src/custom_auth/reset_password/auth_flow/state/ResetPasswordCodeRequiredState.ts create mode 100644 lib/msal-browser/src/custom_auth/reset_password/auth_flow/state/ResetPasswordCompletedState.ts create mode 100644 lib/msal-browser/src/custom_auth/reset_password/auth_flow/state/ResetPasswordFailedState.ts create mode 100644 lib/msal-browser/src/custom_auth/reset_password/auth_flow/state/ResetPasswordPasswordRequiredState.ts create mode 100644 lib/msal-browser/src/custom_auth/reset_password/auth_flow/state/ResetPasswordState.ts create mode 100644 lib/msal-browser/src/custom_auth/reset_password/auth_flow/state/ResetPasswordStateParameters.ts create mode 100644 lib/msal-browser/src/custom_auth/reset_password/interaction_client/ResetPasswordClient.ts create mode 100644 lib/msal-browser/src/custom_auth/reset_password/interaction_client/parameter/ResetPasswordParams.ts create mode 100644 lib/msal-browser/src/custom_auth/reset_password/interaction_client/result/ResetPasswordActionResult.ts create mode 100644 lib/msal-browser/src/custom_auth/sign_in/auth_flow/SignInScenario.ts create mode 100644 lib/msal-browser/src/custom_auth/sign_in/auth_flow/error_type/SignInError.ts create mode 100644 lib/msal-browser/src/custom_auth/sign_in/auth_flow/result/SignInResendCodeResult.ts create mode 100644 lib/msal-browser/src/custom_auth/sign_in/auth_flow/result/SignInResult.ts create mode 100644 lib/msal-browser/src/custom_auth/sign_in/auth_flow/result/SignInSubmitCodeResult.ts create mode 100644 lib/msal-browser/src/custom_auth/sign_in/auth_flow/result/SignInSubmitCredentialResult.ts create mode 100644 lib/msal-browser/src/custom_auth/sign_in/auth_flow/result/SignInSubmitPasswordResult.ts create mode 100644 lib/msal-browser/src/custom_auth/sign_in/auth_flow/state/SignInCodeRequiredState.ts create mode 100644 lib/msal-browser/src/custom_auth/sign_in/auth_flow/state/SignInCompletedState.ts create mode 100644 lib/msal-browser/src/custom_auth/sign_in/auth_flow/state/SignInContinuationState.ts create mode 100644 lib/msal-browser/src/custom_auth/sign_in/auth_flow/state/SignInFailedState.ts create mode 100644 lib/msal-browser/src/custom_auth/sign_in/auth_flow/state/SignInPasswordRequiredState.ts create mode 100644 lib/msal-browser/src/custom_auth/sign_in/auth_flow/state/SignInState.ts create mode 100644 lib/msal-browser/src/custom_auth/sign_in/auth_flow/state/SignInStateParameters.ts create mode 100644 lib/msal-browser/src/custom_auth/sign_in/interaction_client/SignInClient.ts create mode 100644 lib/msal-browser/src/custom_auth/sign_in/interaction_client/parameter/SignInParams.ts create mode 100644 lib/msal-browser/src/custom_auth/sign_in/interaction_client/result/SignInActionResult.ts create mode 100644 lib/msal-browser/src/custom_auth/sign_up/auth_flow/error_type/SignUpError.ts create mode 100644 lib/msal-browser/src/custom_auth/sign_up/auth_flow/result/SignUpResendCodeResult.ts create mode 100644 lib/msal-browser/src/custom_auth/sign_up/auth_flow/result/SignUpResult.ts create mode 100644 lib/msal-browser/src/custom_auth/sign_up/auth_flow/result/SignUpSubmitAttributesResult.ts create mode 100644 lib/msal-browser/src/custom_auth/sign_up/auth_flow/result/SignUpSubmitCodeResult.ts create mode 100644 lib/msal-browser/src/custom_auth/sign_up/auth_flow/result/SignUpSubmitPasswordResult.ts create mode 100644 lib/msal-browser/src/custom_auth/sign_up/auth_flow/state/SignUpAttributesRequiredState.ts create mode 100644 lib/msal-browser/src/custom_auth/sign_up/auth_flow/state/SignUpCodeRequiredState.ts create mode 100644 lib/msal-browser/src/custom_auth/sign_up/auth_flow/state/SignUpCompletedState.ts create mode 100644 lib/msal-browser/src/custom_auth/sign_up/auth_flow/state/SignUpFailedState.ts create mode 100644 lib/msal-browser/src/custom_auth/sign_up/auth_flow/state/SignUpPasswordRequiredState.ts create mode 100644 lib/msal-browser/src/custom_auth/sign_up/auth_flow/state/SignUpState.ts create mode 100644 lib/msal-browser/src/custom_auth/sign_up/auth_flow/state/SignUpStateParameters.ts create mode 100644 lib/msal-browser/src/custom_auth/sign_up/interaction_client/SignUpClient.ts create mode 100644 lib/msal-browser/src/custom_auth/sign_up/interaction_client/parameter/SignUpParams.ts create mode 100644 lib/msal-browser/src/custom_auth/sign_up/interaction_client/result/SignUpActionResult.ts create mode 100644 lib/msal-browser/test/custom_auth/CustomAuthPublicClientApplication.spec.ts create mode 100644 lib/msal-browser/test/custom_auth/controller/CustomAuthStandardController.spec.ts create mode 100644 lib/msal-browser/test/custom_auth/core/CustomAuthAuthority.spec.ts create mode 100644 lib/msal-browser/test/custom_auth/core/network_client/custom_auth_api/CustomAuthApiClient.spec.ts create mode 100644 lib/msal-browser/test/custom_auth/core/network_client/http_client/FetchClient.spec.ts create mode 100644 lib/msal-browser/test/custom_auth/core/utils/ArgumentValidator.spec.ts create mode 100644 lib/msal-browser/test/custom_auth/core/utils/UrlUtils.spec.ts create mode 100644 lib/msal-browser/test/custom_auth/get_account/auth_flow/CustomAuthAccountData.spec.ts create mode 100644 lib/msal-browser/test/custom_auth/get_account/auth_flow/error_type/GetAccountError.spec.ts create mode 100644 lib/msal-browser/test/custom_auth/get_account/interaction_client/CustomAuthSilentCacheClient.spec.ts create mode 100644 lib/msal-browser/test/custom_auth/integration_tests/GetAccount.spec.ts create mode 100644 lib/msal-browser/test/custom_auth/integration_tests/ResetPassword.spec.ts create mode 100644 lib/msal-browser/test/custom_auth/integration_tests/SignIn.spec.ts create mode 100644 lib/msal-browser/test/custom_auth/integration_tests/SignUp.spec.ts create mode 100644 lib/msal-browser/test/custom_auth/reset_password/auth_flow/error_type/ResetPasswordError.spec.ts create mode 100644 lib/msal-browser/test/custom_auth/reset_password/auth_flow/state/ResetPasswordCodeRequiredState.spec.ts create mode 100644 lib/msal-browser/test/custom_auth/reset_password/auth_flow/state/ResetPasswordPasswordRequiredState.spec.ts create mode 100644 lib/msal-browser/test/custom_auth/reset_password/interaction_client/ResetPasswordClient.spec.ts create mode 100644 lib/msal-browser/test/custom_auth/sign_in/auth_flow/error_type/SignInError.spec.ts create mode 100644 lib/msal-browser/test/custom_auth/sign_in/auth_flow/state/SignInCodeRequiredState.spec.ts create mode 100644 lib/msal-browser/test/custom_auth/sign_in/auth_flow/state/SignInContinuationState.spec.ts create mode 100644 lib/msal-browser/test/custom_auth/sign_in/auth_flow/state/SignInPasswordRequiredState.spec.ts create mode 100644 lib/msal-browser/test/custom_auth/sign_in/interation_client/SignInClient.spec.ts create mode 100644 lib/msal-browser/test/custom_auth/sign_up/auth_flow/error_type/SignUpError.spec.ts create mode 100644 lib/msal-browser/test/custom_auth/sign_up/auth_flow/state/SignUpAttributesRequiredState.spec.ts create mode 100644 lib/msal-browser/test/custom_auth/sign_up/auth_flow/state/SignUpCodeRequiredState.spec.ts create mode 100644 lib/msal-browser/test/custom_auth/sign_up/auth_flow/state/SignUpPasswordRequiredState.spec.ts create mode 100644 lib/msal-browser/test/custom_auth/sign_up/interaction_client/SignUpClient.spec.ts create mode 100644 lib/msal-browser/test/custom_auth/test_resources/CustomAuthConfig.ts create mode 100644 lib/msal-browser/test/custom_auth/test_resources/TestConstants.ts create mode 100644 lib/msal-browser/tsconfig.custom-auth.build.json 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/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..886460b568 --- /dev/null +++ b/lib/msal-browser/src/custom_auth/core/CustomAuthAuthority.ts @@ -0,0 +1,112 @@ +/* + * 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.auth.protocolMode, + OIDCOptions: config.auth.OIDCOptions, + knownAuthorities: config.auth.knownAuthorities, + cloudDiscoveryMetadata: config.auth.cloudDiscoveryMetadata, + authorityMetadata: config.auth.authorityMetadata, + skipAuthorityMetadataCache: config.auth.skipAuthorityMetadataCache, + }; + + 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..e7ae2cf763 --- /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, + AuthenticationScheme, + CommonSilentFlowRequest, + 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: 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..c5d347f4dc --- /dev/null +++ b/lib/msal-browser/src/custom_auth/get_account/interaction_client/CustomAuthSilentCacheClient.ts @@ -0,0 +1,209 @@ +/* + * 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, + }, + cacheOptions: { + claimsBasedCachingEnabled: + this.config.cache.claimsBasedCachingEnabled, + }, + 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..8ad6ef1158 --- /dev/null +++ b/lib/msal-browser/test/custom_auth/core/CustomAuthAuthority.spec.ts @@ -0,0 +1,183 @@ +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: { + protocolMode: "", + OIDCOptions: {}, + knownAuthorities: [], + cloudDiscoveryMetadata: "", + authorityMetadata: "", + skipAuthorityMetadataCache: false, + }, + } 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..ea4560575a --- /dev/null +++ b/lib/msal-browser/test/custom_auth/get_account/interaction_client/CustomAuthSilentCacheClient.spec.ts @@ -0,0 +1,448 @@ +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, + AuthenticationScheme, + CacheHelpers, + CommonSilentFlowRequest, + createInteractionRequiredAuthError, + ICrypto, + INetworkModule, + InteractionRequiredAuthErrorCodes, + Logger, + RefreshTokenEntity, + StubPerformanceClient, + TimeUtils, +} from "@azure/msal-common/browser"; +import { + TestTokenResponse, + TestAccounDetails, + 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"; + +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 decodedStr = JSON.stringify(TestIdTokenClaims); + mockCrypto = { + createNewGuid: jest.fn(), + base64Decode: jest.fn().mockReturnValue(decodedStr), + } 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); + + 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: TestAccounDetails, + forceRefresh: false, + storeInCache: { + idToken: true, + accessToken: true, + refreshToken: true, + }, + } as CommonSilentFlowRequest; + + beforeEach(() => { + accountEntityToCache = + AccountEntity.createFromAccountInfo(TestAccounDetails); + accessTokenEntityToCache = createAccessTokenEntity(mockCrypto); + refreshTokenEntityToCache = createRefreshTokenEntity(); + + jest.spyOn(AccountEntity, "generateHomeAccountId").mockReturnValue( + TestHomeAccountId + ); + }); + + 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, + TestAccounDetails.environment, + TestTokenResponse.ACCESS_TOKEN, + customAuthConfig.auth.clientId, + TestTenantId, + TestServerTokenResponse.scope, + expiresOn, + expiresOn + 0, + browserCrypto.base64Decode, + undefined, + TestServerTokenResponse.token_type as AuthenticationScheme + ); +} + +function createRefreshTokenEntity(): RefreshTokenEntity { + return CacheHelpers.createRefreshTokenEntity( + TestHomeAccountId, + TestAccounDetails.environment, + TestServerTokenResponse.refresh_token, + customAuthConfig.auth.clientId + ); +} 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..cf978c4695 --- /dev/null +++ b/lib/msal-browser/test/custom_auth/reset_password/interaction_client/ResetPasswordClient.spec.ts @@ -0,0 +1,393 @@ +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: { + protocolMode: "", + OIDCOptions: {}, + knownAuthorities: [], + cloudDiscoveryMetadata: "", + authorityMetadata: "", + skipAuthorityMetadataCache: false, + }, + } 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..42e5c8e7c3 --- /dev/null +++ b/lib/msal-browser/test/custom_auth/sign_in/interation_client/SignInClient.spec.ts @@ -0,0 +1,411 @@ +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: { + protocolMode: "", + OIDCOptions: {}, + knownAuthorities: [], + cloudDiscoveryMetadata: "", + authorityMetadata: "", + skipAuthorityMetadataCache: false, + }, + } 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..910d9076fd --- /dev/null +++ b/lib/msal-browser/test/custom_auth/sign_up/interaction_client/SignUpClient.spec.ts @@ -0,0 +1,691 @@ +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: { + protocolMode: "", + OIDCOptions: {}, + knownAuthorities: [], + cloudDiscoveryMetadata: "", + authorityMetadata: "", + skipAuthorityMetadataCache: false, + }, + } 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..e4f0b87279 --- /dev/null +++ b/lib/msal-browser/test/custom_auth/test_resources/CustomAuthConfig.ts @@ -0,0 +1,49 @@ +/* + * 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", + storeAuthStateInCookie: false, + }, + 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..7a4e1f71a8 --- /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 TestAccounDetails = { + 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"] +} From 7ddeb5760077baacf50ac0540672446f932afcdc Mon Sep 17 00:00:00 2001 From: Jian Shen Date: Sun, 22 Jun 2025 19:40:16 +0100 Subject: [PATCH 2/4] Fix --- .../custom_auth/core/CustomAuthAuthority.ts | 3 +- .../auth_flow/CustomAuthAccountData.ts | 4 +- .../CustomAuthSilentCacheClient.ts | 4 -- .../core/CustomAuthAuthority.spec.ts | 5 ++- .../CustomAuthSilentCacheClient.spec.ts | 43 +++++++++++-------- .../ResetPasswordClient.spec.ts | 5 ++- .../interation_client/SignInClient.spec.ts | 5 ++- .../interaction_client/SignUpClient.spec.ts | 5 ++- .../test_resources/CustomAuthConfig.ts | 1 - .../test_resources/TestConstants.ts | 2 +- 10 files changed, 41 insertions(+), 36 deletions(-) diff --git a/lib/msal-browser/src/custom_auth/core/CustomAuthAuthority.ts b/lib/msal-browser/src/custom_auth/core/CustomAuthAuthority.ts index 886460b568..ae1be3f7cd 100644 --- a/lib/msal-browser/src/custom_auth/core/CustomAuthAuthority.ts +++ b/lib/msal-browser/src/custom_auth/core/CustomAuthAuthority.ts @@ -39,12 +39,11 @@ export class CustomAuthAuthority extends Authority { CustomAuthAuthority.transformCIAMAuthority(authority); const authorityOptions: AuthorityOptions = { - protocolMode: config.auth.protocolMode, + protocolMode: config.system.protocolMode, OIDCOptions: config.auth.OIDCOptions, knownAuthorities: config.auth.knownAuthorities, cloudDiscoveryMetadata: config.auth.cloudDiscoveryMetadata, authorityMetadata: config.auth.authorityMetadata, - skipAuthorityMetadataCache: config.auth.skipAuthorityMetadataCache, }; super( 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 index e7ae2cf763..2238a8b1c5 100644 --- 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 @@ -12,8 +12,8 @@ import { DefaultScopes } from "../../CustomAuthConstants.js"; import { AccessTokenRetrievalInputs } from "../../CustomAuthActionInputs.js"; import { AccountInfo, - AuthenticationScheme, CommonSilentFlowRequest, + Constants, Logger, TokenClaims, } from "@azure/msal-common/browser"; @@ -175,7 +175,7 @@ export class CustomAuthAccountData { return { ...silentRequest, - authenticationScheme: AuthenticationScheme.BEARER, + authenticationScheme: Constants.AuthenticationScheme.BEARER, } as CommonSilentFlowRequest; } } 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 index c5d347f4dc..48ac2599b2 100644 --- 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 @@ -189,10 +189,6 @@ export class CustomAuthSilentCacheClient extends CustomAuthInteractionClientBase logLevel: logger.logLevel, correlationId: this.correlationId, }, - cacheOptions: { - claimsBasedCachingEnabled: - this.config.cache.claimsBasedCachingEnabled, - }, cryptoInterface: this.browserCrypto, networkInterface: this.networkClient, storageInterface: this.browserStorage, diff --git a/lib/msal-browser/test/custom_auth/core/CustomAuthAuthority.spec.ts b/lib/msal-browser/test/custom_auth/core/CustomAuthAuthority.spec.ts index 8ad6ef1158..4f9c80ea99 100644 --- a/lib/msal-browser/test/custom_auth/core/CustomAuthAuthority.spec.ts +++ b/lib/msal-browser/test/custom_auth/core/CustomAuthAuthority.spec.ts @@ -25,12 +25,13 @@ describe("CustomAuthAuthority", () => { const mockLogger = {} as unknown as jest.Mocked; const mockConfig = { auth: { - protocolMode: "", OIDCOptions: {}, knownAuthorities: [], cloudDiscoveryMetadata: "", authorityMetadata: "", - skipAuthorityMetadataCache: false, + }, + system: { + protocolMode: "", }, } as unknown as jest.Mocked; 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 index ea4560575a..bc03b661e3 100644 --- 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 @@ -4,9 +4,9 @@ import { CustomAuthAuthority } from "../../../../src/custom_auth/core/CustomAuth import { AccessTokenEntity, AccountEntity, - AuthenticationScheme, CacheHelpers, CommonSilentFlowRequest, + Constants, createInteractionRequiredAuthError, ICrypto, INetworkModule, @@ -18,7 +18,7 @@ import { } from "@azure/msal-common/browser"; import { TestTokenResponse, - TestAccounDetails, + TestAccountDetails, TestServerTokenResponse, TestHomeAccountId, TestTenantId, @@ -30,6 +30,7 @@ import { BrowserCacheManager } from "../../../../src/cache/BrowserCacheManager.j 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"); @@ -92,12 +93,6 @@ describe("CustomAuthSilentCacheClient", () => { telemetry: {}, } as unknown as jest.Mocked; - const decodedStr = JSON.stringify(TestIdTokenClaims); - mockCrypto = { - createNewGuid: jest.fn(), - base64Decode: jest.fn().mockReturnValue(decodedStr), - } as unknown as jest.Mocked; - const mockEventHandler = {} as unknown as jest.Mocked; const mockPerformanceClient = new StubPerformanceClient(); const mockedApiClient = {} as unknown as jest.Mocked; @@ -116,6 +111,7 @@ describe("CustomAuthSilentCacheClient", () => { } as unknown as jest.Mocked; mockLogger.clone.mockReturnValue(mockLogger); + mockCrypto = new CryptoOps(mockLogger); mockCacheManager = new BrowserCacheManager( customAuthConfig.auth.clientId, @@ -162,7 +158,7 @@ describe("CustomAuthSilentCacheClient", () => { authority: customAuthConfig.auth.authority, correlationId: "test-correlation-id", scopes: defaultScopes, - account: TestAccounDetails, + account: TestAccountDetails, forceRefresh: false, storeInCache: { idToken: true, @@ -172,14 +168,9 @@ describe("CustomAuthSilentCacheClient", () => { } as CommonSilentFlowRequest; beforeEach(() => { - accountEntityToCache = - AccountEntity.createFromAccountInfo(TestAccounDetails); + accountEntityToCache = createAccountEntityFromAccountInfo(); accessTokenEntityToCache = createAccessTokenEntity(mockCrypto); refreshTokenEntityToCache = createRefreshTokenEntity(); - - jest.spyOn(AccountEntity, "generateHomeAccountId").mockReturnValue( - TestHomeAccountId - ); }); afterEach(() => { @@ -425,7 +416,7 @@ function createAccessTokenEntity(browserCrypto: ICrypto): AccessTokenEntity { return CacheHelpers.createAccessTokenEntity( TestHomeAccountId, - TestAccounDetails.environment, + TestAccountDetails.environment, TestTokenResponse.ACCESS_TOKEN, customAuthConfig.auth.clientId, TestTenantId, @@ -434,15 +425,31 @@ function createAccessTokenEntity(browserCrypto: ICrypto): AccessTokenEntity { expiresOn + 0, browserCrypto.base64Decode, undefined, - TestServerTokenResponse.token_type as AuthenticationScheme + TestServerTokenResponse.token_type as Constants.AuthenticationScheme ); } function createRefreshTokenEntity(): RefreshTokenEntity { return CacheHelpers.createRefreshTokenEntity( TestHomeAccountId, - TestAccounDetails.environment, + 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/reset_password/interaction_client/ResetPasswordClient.spec.ts b/lib/msal-browser/test/custom_auth/reset_password/interaction_client/ResetPasswordClient.spec.ts index cf978c4695..64eab53bef 100644 --- 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 @@ -83,12 +83,13 @@ describe("ResetPasswordClient", () => { ); const mockConfig = { auth: { - protocolMode: "", OIDCOptions: {}, knownAuthorities: [], cloudDiscoveryMetadata: "", authorityMetadata: "", - skipAuthorityMetadataCache: false, + }, + system: { + protocolMode: "", }, } as unknown as jest.Mocked; 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 index 42e5c8e7c3..c00383c369 100644 --- 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 @@ -118,12 +118,13 @@ describe("SignInClient", () => { const mockConfig = { auth: { - protocolMode: "", OIDCOptions: {}, knownAuthorities: [], cloudDiscoveryMetadata: "", authorityMetadata: "", - skipAuthorityMetadataCache: false, + }, + system: { + protocolMode: "", }, } as unknown as jest.Mocked; 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 index 910d9076fd..1414b1b2c1 100644 --- 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 @@ -119,12 +119,13 @@ describe("SignUpClient", () => { const mockConfig = { auth: { - protocolMode: "", OIDCOptions: {}, knownAuthorities: [], cloudDiscoveryMetadata: "", authorityMetadata: "", - skipAuthorityMetadataCache: false, + }, + system: { + protocolMode: "", }, } as unknown as jest.Mocked; diff --git a/lib/msal-browser/test/custom_auth/test_resources/CustomAuthConfig.ts b/lib/msal-browser/test/custom_auth/test_resources/CustomAuthConfig.ts index e4f0b87279..335559e7f8 100644 --- a/lib/msal-browser/test/custom_auth/test_resources/CustomAuthConfig.ts +++ b/lib/msal-browser/test/custom_auth/test_resources/CustomAuthConfig.ts @@ -19,7 +19,6 @@ export const customAuthConfig: CustomAuthConfiguration = { }, cache: { cacheLocation: "sessionStorage", - storeAuthStateInCookie: false, }, system: { loggerOptions: { diff --git a/lib/msal-browser/test/custom_auth/test_resources/TestConstants.ts b/lib/msal-browser/test/custom_auth/test_resources/TestConstants.ts index 7a4e1f71a8..e5ed9e9928 100644 --- a/lib/msal-browser/test/custom_auth/test_resources/TestConstants.ts +++ b/lib/msal-browser/test/custom_auth/test_resources/TestConstants.ts @@ -18,7 +18,7 @@ export const TestHomeAccountId = export const TestTenantId = "d37e5645-4170-4ce0-a618-1b9008b14e58"; // fake tenantId export const TestUsername = "abc@test.com"; // fake username -export const TestAccounDetails = { +export const TestAccountDetails = { homeAccountId: TestHomeAccountId, environment: "spasamples.ciamlogin.com", tenantId: TestTenantId, From 69df842502fe466519e7f04dc9b034f9b0bf76a0 Mon Sep 17 00:00:00 2001 From: Jian Shen Date: Wed, 25 Jun 2025 11:30:53 +0100 Subject: [PATCH 3/4] Fix the lint error --- docs/errors.md | 510 ++++++++++++++++++++++++++++++++++++------------- 1 file changed, 381 insertions(+), 129 deletions(-) diff --git a/docs/errors.md b/docs/errors.md index 646b222fb3..a5fcd29494 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -3,15 +3,18 @@ ## Auth errors ### `unexpected_error` -- Unexpected error in authentication. + +- Unexpected error in authentication. ### `post_request_failed` -- Post request failed from the network, could be a 4xx/5xx or a network unavailability. Please check the exact error code for details. + +- Post request failed from the network, could be a 4xx/5xx or a network unavailability. Please check the exact error code for details. ## Cache errors ### `cache_quota_exceeded` -- Exceeded cache storage capacity. + +- Exceeded cache storage capacity. This error occurs when MSAL.js surpasses the allotted storage limit when attempting to save token information in the [configured cache storage](https://github.com/AzureAD/microsoft-authentication-library-for-js/tree/dev/lib/msal-browser/docs/caching.md#cache-storage). See [here](https://developer.mozilla.org/en-US/docs/Web/API/Storage_API/Storage_quotas_and_eviction_criteria#web_storage) for web storage limits. @@ -20,260 +23,340 @@ This error occurs when MSAL.js surpasses the allotted storage limit when attempt 1. Make sure the configured cache storage has enough capacity to allow MSAL.js to persist token payload. The amount of cache storage required depends on the number of [cached artifacts](./caching.md#cached-artifacts). ### `cache_error_unknown` -- An unknown error occurred while accessing the browser cache. + +- An unknown error occurred while accessing the browser cache. ## Client auth errors ### `client_info_decoding_error` -- The client info could not be parsed/decoded correctly. + +- The client info could not be parsed/decoded correctly. ### `client_info_empty_error` -- The client info was empty. + +- The client info was empty. ### `token_parsing_error` -- Token cannot be parsed. + +- Token cannot be parsed. ### `null_or_empty_token` -- The token is null or empty. + +- The token is null or empty. ### `endpoints_resolution_error` -- Could not resolve endpoints. Please check network and try again. + +- Could not resolve endpoints. Please check network and try again. ### `network_error` -- Network request failed. Please check network and try again. + +- Network request failed. Please check network and try again. ### `openid_config_error` -- Could not retrieve endpoints. Check your authority and verify the .well-known/openid-configuration endpoint returns the required endpoints. + +- Could not retrieve endpoints. Check your authority and verify the .well-known/openid-configuration endpoint returns the required endpoints. ### `hash_not_deserialized` -- The hash parameters could not be deserialized. + +- The hash parameters could not be deserialized. ### `invalid_state` -- State was not the expected format. + +- State was not the expected format. ### `state_mismatch` -- State mismatch error. + +- State mismatch error. ### `state_not_found` -- State not found. + +- State not found. ### `nonce_mismatch` -- Nonce mismatch error. + +- Nonce mismatch error. ### `auth_time_not_found` -- Max Age was requested and the ID token is missing the auth_time variable. auth_time is an optional claim and is not enabled by default - it must be enabled. See https://aka.ms/msaljs/optional-claims for more information. + +- Max Age was requested and the ID token is missing the auth_time variable. auth_time is an optional claim and is not enabled by default - it must be enabled. See https://aka.ms/msaljs/optional-claims for more information. ### `max_age_transpired` -- Max Age is set to 0, or too much time has elapsed since the last end-user authentication. + +- Max Age is set to 0, or too much time has elapsed since the last end-user authentication. ### `multiple_matching_tokens` -- The cache contains multiple tokens satisfying the requirements. Call AcquireToken again providing more requirements such as authority or account. + +- The cache contains multiple tokens satisfying the requirements. Call AcquireToken again providing more requirements such as authority or account. ### `multiple_matching_appMetadata` -- The cache contains multiple appMetadata satisfying the given parameters. Please pass more info to obtain the correct appMetadata. + +- The cache contains multiple appMetadata satisfying the given parameters. Please pass more info to obtain the correct appMetadata. ### `request_cannot_be_made` -- Token request cannot be made without authorization code or refresh token. + +- Token request cannot be made without authorization code or refresh token. ### `cannot_remove_empty_scope` -- Cannot remove null or empty scope from ScopeSet. + +- Cannot remove null or empty scope from ScopeSet. ### `cannot_append_scopeset` -- Cannot append ScopeSet. + +- Cannot append ScopeSet. ### `empty_input_scopeset` -- Empty input ScopeSet cannot be processed. + +- Empty input ScopeSet cannot be processed. ### `no_account_in_silent_request` -- Please pass an account object, silent flow is not supported without account information. + +- Please pass an account object, silent flow is not supported without account information. ### `invalid_cache_record` -- Cache record object was null or undefined. + +- Cache record object was null or undefined. ### `invalid_cache_environment` -- Invalid environment when attempting to create cache entry. + +- Invalid environment when attempting to create cache entry. ### `no_account_found` -- No account found in cache for given key. + +- No account found in cache for given key. ### `no_crypto_object` -- No crypto object detected. + +- No crypto object detected. ### `unexpected_credential_type` -- Unexpected credential type. + +- Unexpected credential type. ### `token_refresh_required` -- Cannot return token from cache because it must be refreshed. This may be due to one of the following reasons: forceRefresh parameter is set to true, claims have been requested, there is no cached access token or it is expired. + +- Cannot return token from cache because it must be refreshed. This may be due to one of the following reasons: forceRefresh parameter is set to true, claims have been requested, there is no cached access token or it is expired. ### `token_claims_cnf_required_for_signedjwt` -- Cannot generate a POP jwt if the token_claims are not populated. + +- Cannot generate a POP jwt if the token_claims are not populated. ### `authorization_code_missing_from_server_response` -- Server response does not contain an authorization code to proceed. + +- Server response does not contain an authorization code to proceed. ### `binding_key_not_removed` -- Could not remove the credential's binding key from storage. + +- Could not remove the credential's binding key from storage. ### `end_session_endpoint_not_supported` -- The provided authority does not support logout. + +- The provided authority does not support logout. ### `key_id_missing` -- A keyId value is missing from the requested bound token's cache record and is required to match the token to its stored binding key. + +- A keyId value is missing from the requested bound token's cache record and is required to match the token to its stored binding key. ### `no_network_connectivity` -- No network connectivity. Check your internet connection. + +- No network connectivity. Check your internet connection. ### `user_canceled` -- User cancelled the flow. + +- User cancelled the flow. ### `method_not_implemented` -- This method has not been implemented. + +- This method has not been implemented. ### `nested_app_auth_bridge_disabled` -- The nested app auth bridge is disabled. + +- The nested app auth bridge is disabled. ## Client configuration errors ### `redirect_uri_empty` -- A redirect URI is required for all calls and none has been set. + +- A redirect URI is required for all calls and none has been set. ### `claims_request_parsing_error` -- Could not parse the given claims request object. + +- Could not parse the given claims request object. ### `authority_uri_insecure` -- Authority URIs must use https. Please see here for valid authority configuration options: https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-js-initializing-client-applications#configuration-options. + +- Authority URIs must use https. Please see here for valid authority configuration options: https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-js-initializing-client-applications#configuration-options. ### `url_parse_error` -- URL could not be parsed into appropriate segments. + +- URL could not be parsed into appropriate segments. ### `empty_url_error` -- URL was empty or null. + +- URL was empty or null. ### `empty_input_scopes_error` -- Scopes cannot be passed as null, undefined or empty array because they are required to obtain an access token. + +- Scopes cannot be passed as null, undefined or empty array because they are required to obtain an access token. ### `invalid_prompt_value` -- Invalid prompt value. Please see here for valid configuration options: https://azuread.github.io/microsoft-authentication-library-for-js/ref/modules/_azure_msal_common.html#commonauthorizationurlrequest + +- Invalid prompt value. Please see here for valid configuration options: https://azuread.github.io/microsoft-authentication-library-for-js/ref/modules/_azure_msal_common.html#commonauthorizationurlrequest ### `invalid_claims` -- Given claims parameter must be a stringified JSON object. + +- Given claims parameter must be a stringified JSON object. ### `token_request_empty` -- Token request was empty and not found in cache. + +- Token request was empty and not found in cache. ### `logout_request_empty` -- The logout request was null or undefined. + +- The logout request was null or undefined. ### `invalid_code_challenge_method` -- code_challenge_method passed is invalid. Valid values are "plain" and "S256". + +- code_challenge_method passed is invalid. Valid values are "plain" and "S256". ### `pkce_params_missing` -- Both params: code_challenge and code_challenge_method are to be passed if to be sent in the request. + +- Both params: code_challenge and code_challenge_method are to be passed if to be sent in the request. ### `invalid_cloud_discovery_metadata` -- Invalid cloudDiscoveryMetadata provided. Must be a stringified JSON object containing tenant_discovery_endpoint and metadata fields. + +- Invalid cloudDiscoveryMetadata provided. Must be a stringified JSON object containing tenant_discovery_endpoint and metadata fields. ### `invalid_authority_metadata` -- Invalid authorityMetadata provided. Must by a stringified JSON object containing authorization_endpoint, token_endpoint, and issuer fields. + +- Invalid authorityMetadata provided. Must by a stringified JSON object containing authorization_endpoint, token_endpoint, and issuer fields. ### `untrusted_authority` -- The provided authority is not a trusted authority. Please include this authority in the knownAuthorities config parameter. + +- The provided authority is not a trusted authority. Please include this authority in the knownAuthorities config parameter. ### `missing_ssh_jwk` -- Missing sshJwk in SSH certificate request. A stringified JSON Web Key is required when using the SSH authentication scheme. + +- Missing sshJwk in SSH certificate request. A stringified JSON Web Key is required when using the SSH authentication scheme. ### `missing_ssh_kid` -- Missing sshKid in SSH certificate request. A string that uniquely identifies the public SSH key is required when using the SSH authentication scheme. + +- Missing sshKid in SSH certificate request. A string that uniquely identifies the public SSH key is required when using the SSH authentication scheme. ### `missing_nonce_authentication_header` -- Unable to find an authentication header containing server nonce. Either the Authentication-Info or WWW-Authenticate headers must be present in order to obtain a server nonce. + +- Unable to find an authentication header containing server nonce. Either the Authentication-Info or WWW-Authenticate headers must be present in order to obtain a server nonce. ### `invalid_authentication_header` -- Invalid authentication header provided. + +- Invalid authentication header provided. ### `cannot_set_OIDCOptions` -- Cannot set OIDCOptions parameter. Please change the protocol mode to OIDC or use a non-Microsoft authority. + +- Cannot set OIDCOptions parameter. Please change the protocol mode to OIDC or use a non-Microsoft authority. ### `cannot_allow_platform_broker` -- Cannot set allowPlatformBroker parameter to true when not in AAD protocol mode. + +- Cannot set allowPlatformBroker parameter to true when not in AAD protocol mode. ### `authority_mismatch` -- Authority mismatch error. Authority provided in login request or PublicClientApplication config does not match the environment of the provided account. Please use a matching account or make an interactive request to login to this authority. + +- Authority mismatch error. Authority provided in login request or PublicClientApplication config does not match the environment of the provided account. Please use a matching account or make an interactive request to login to this authority. ## Interaction required errors ### `no_tokens_found` -- No refresh token found in the cache. Please sign-in. + +- No refresh token found in the cache. Please sign-in. ### `native_account_unavailable` -- The requested account is not available in the native broker. It may have been deleted or logged out. Please sign-in again using an interactive API. + +- The requested account is not available in the native broker. It may have been deleted or logged out. Please sign-in again using an interactive API. ### `refresh_token_expired` -- Refresh token has expired. + +- Refresh token has expired. ### `interaction_required` -- User interaction is required. + +- User interaction is required. ### `consent_required` -- User consent is required. + +- User consent is required. ### `login_required` -- User login is required. + +- User login is required. ### `bad_token` -- Identity provider returned bad_token due to an expired or invalid refresh token. Please invoke an interactive API to resolve. + +- Identity provider returned bad_token due to an expired or invalid refresh token. Please invoke an interactive API to resolve. ### `ux_not_allowed` -- `canShowUI` flag in Edge was set to false. User interaction required on web page. Please invoke an interactive API to resolve. + +- `canShowUI` flag in Edge was set to false. User interaction required on web page. Please invoke an interactive API to resolve. ## JOSE header errors ### `missing_kid_error` -- The JOSE Header for the requested JWT, JWS or JWK object requires a keyId to be configured as the 'kid' header claim. No 'kid' value was provided. + +- The JOSE Header for the requested JWT, JWS or JWK object requires a keyId to be configured as the 'kid' header claim. No 'kid' value was provided. ### `missing_alg_error` -- The JOSE Header for the requested JWT, JWS or JWK object requires an algorithm to be specified as the 'alg' header claim. No 'alg' value was provided. + +- The JOSE Header for the requested JWT, JWS or JWK object requires an algorithm to be specified as the 'alg' header claim. No 'alg' value was provided. ## Browser auth errors ### `pkce_not_created` -- The PKCE code challenge and verifier could not be generated. + +- The PKCE code challenge and verifier could not be generated. ### `ear_jwk_empty` -- No EAR encryption key provided. This is unexpected. + +- No EAR encryption key provided. This is unexpected. ### `ear_jwe_empty` -- Server response does not contain ear_jwe property. This is unexpected. + +- Server response does not contain ear_jwe property. This is unexpected. ### `crypto_nonexistent` -- The crypto object or function is not available. + +- The crypto object or function is not available. ### `empty_navigate_uri` -- Navigation URI is empty. Please check stack trace for more info. + +- Navigation URI is empty. Please check stack trace for more info. ### `hash_empty_error` -- Hash value cannot be processed because it is empty. Please verify that your redirectUri is not clearing the hash. + +- Hash value cannot be processed because it is empty. Please verify that your redirectUri is not clearing the hash. This error occurs when the page you use as your redirectUri is removing the hash, or auto-redirecting to another page. This most commonly happens when the application implements a router which navigates to another route, dropping the hash. To resolve this error we recommend using a dedicated redirectUri page which is not subject to the router. For silent and popup calls it's best to use a blank page. If this is not possible please make sure the router does not navigate while MSAL token acquisition is in progress. You can do this by detecting if your application is loaded in an iframe for silent calls, in a popup for popup calls or by awaiting `handleRedirectPromise` for redirect calls. ### `no_state_in_hash` -- Hash does not contain state. Please verify that the request originated from MSAL. + +- Hash does not contain state. Please verify that the request originated from MSAL. ### `hash_does_not_contain_known_properties` -- Hash does not contain known properties. Please verify that your redirectUri is not changing the hash. + +- Hash does not contain known properties. Please verify that your redirectUri is not changing the hash. Please see explanation for [hash_empty_error](#hash_empty_error) above. The root cause for this error is similar, the difference being the hash has been changed, rather than dropped. ### `unable_to_parse_state` -- Unable to parse state. Please verify that the request originated from MSAL. + +- Unable to parse state. Please verify that the request originated from MSAL. ### `state_interaction_type_mismatch` -- Hash contains state but the interaction type does not match the caller. + +- Hash contains state but the interaction type does not match the caller. ### `interaction_in_progress` -- Interaction is currently in progress. Please ensure that this interaction has been completed before calling an interactive API. + +- Interaction is currently in progress. Please ensure that this interaction has been completed before calling an interactive API. This error is thrown when an interactive API (`loginPopup`, `loginRedirect`, `acquireTokenPopup`, `acquireTokenRedirect`) is invoked while another interactive API is still in progress. The login and acquireToken APIs are async so you will need to ensure that the resulting promises have resolved before invoking another one. @@ -473,19 +556,24 @@ If you are unable to figure out why this error is being thrown please [open an i - Open your application in a new tab. Does the error go away? ### `popup_window_error` -- Error opening popup window. This can happen if you are using IE or if popups are blocked in the browser. + +- Error opening popup window. This can happen if you are using IE or if popups are blocked in the browser. ### `empty_window_error` -- window.open returned null or undefined window object. + +- window.open returned null or undefined window object. ### `user_cancelled` -- User cancelled the flow. + +- User cancelled the flow. ### `monitor_popup_timeout` -- Token acquisition in popup failed due to timeout. + +- Token acquisition in popup failed due to timeout. ### `monitor_window_timeout` -- Token acquisition in iframe failed due to timeout. + +- Token acquisition in iframe failed due to timeout. **Error Messages**: @@ -569,10 +657,12 @@ const msalConfig = { > Please consult the [Troubleshooting Single-Sign On](https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/FAQ.md#troubleshooting-single-sign-on) section of the MSAL Browser FAQ if you are having trouble with the `ssoSilent` API. ### `redirect_in_iframe` -- Redirects are not supported for iframed or brokered applications. Please ensure you are using MSAL.js in a top frame of the window if using the redirect APIs, or use the popup APIs. + +- Redirects are not supported for iframed or brokered applications. Please ensure you are using MSAL.js in a top frame of the window if using the redirect APIs, or use the popup APIs. ### `block_iframe_reload` -- Request was blocked inside an iframe because MSAL detected an authentication response. + +- Request was blocked inside an iframe because MSAL detected an authentication response. This error is thrown when calling `ssoSilent` or `acquireTokenSilent` and the page used as your `redirectUri` is attempting to invoke a login or acquireToken function. Our recommended mitigation for this is to set your `redirectUri` to a blank page that does not implement MSAL when invoking silent APIs. This will also have the added benefit of improving performance as the hidden iframe doesn't need to render your page. @@ -591,81 +681,106 @@ Remember that you will need to register this new `redirectUri` on your App Regis If you do not want to use a dedicated `redirectUri` for this purpose, you should instead ensure that your `redirectUri` is not attempting to call MSAL APIs when rendered inside the hidden iframe used by the silent APIs. ### `block_nested_popups` -- Request was blocked inside a popup because MSAL detected it was running in a popup. + +- Request was blocked inside a popup because MSAL detected it was running in a popup. ### `iframe_closed_prematurely` -- The iframe being monitored was closed prematurely. + +- The iframe being monitored was closed prematurely. ### `silent_logout_unsupported` -- Silent logout not supported. Please call logoutRedirect or logoutPopup instead. + +- Silent logout not supported. Please call logoutRedirect or logoutPopup instead. ### `no_account_error` -- No account object provided to acquireTokenSilent and no active account has been set. Please call setActiveAccount or provide an account on the request. + +- No account object provided to acquireTokenSilent and no active account has been set. Please call setActiveAccount or provide an account on the request. ### `silent_prompt_value_error` -- The value given for the prompt value is not valid for silent requests - must be set to 'none' or 'no_session'. + +- The value given for the prompt value is not valid for silent requests - must be set to 'none' or 'no_session'. ### `no_token_request_cache_error` -- No token request found in cache. + +- No token request found in cache. ### `unable_to_parse_token_request_cache_error` -- The cached token request could not be parsed. + +- The cached token request could not be parsed. ### `auth_request_not_set_error` -- Auth Request not set. Please ensure initiateAuthRequest was called from the InteractionHandler. + +- Auth Request not set. Please ensure initiateAuthRequest was called from the InteractionHandler. ### `invalid_cache_type` -- Invalid cache type. + +- Invalid cache type. ### `non_browser_environment` -- Login and token requests are not supported in non-browser environments. + +- Login and token requests are not supported in non-browser environments. ### `database_not_open` -- Database is not open. + +- Database is not open. ### `no_network_connectivity` -- No network connectivity. Check your internet connection. + +- No network connectivity. Check your internet connection. ### `post_request_failed` -- Network request failed: If the browser threw a CORS error, check that the redirectUri is registered in the Azure App Portal as type 'SPA'. + +- Network request failed: If the browser threw a CORS error, check that the redirectUri is registered in the Azure App Portal as type 'SPA'. ### `get_request_failed` -- Network request failed. Please check the network trace to determine root cause. + +- Network request failed. Please check the network trace to determine root cause. ### `failed_to_parse_response` -- Failed to parse network response. Check network trace. + +- Failed to parse network response. Check network trace. ### `unable_to_load_token` -- Error loading token to cache. + +- Error loading token to cache. ### `crypto_key_not_found` -- Cryptographic Key or Keypair not found in browser storage. + +- Cryptographic Key or Keypair not found in browser storage. ### `auth_code_required` -- An authorization code must be provided (as the `code` property on the request) to this flow. + +- An authorization code must be provided (as the `code` property on the request) to this flow. ### `auth_code_or_nativeAccountId_required` -- An authorization code or nativeAccountId must be provided to this flow. + +- An authorization code or nativeAccountId must be provided to this flow. ### `spa_code_and_nativeAccountId_present` -- Request cannot contain both spa code and native account id. + +- Request cannot contain both spa code and native account id. ### `database_unavailable` -- IndexedDB, which is required for persistent cryptographic key storage, is unavailable. This may be caused by browser privacy features which block persistent storage in third-party contexts. + +- IndexedDB, which is required for persistent cryptographic key storage, is unavailable. This may be caused by browser privacy features which block persistent storage in third-party contexts. ### `unable_to_acquire_token_from_native_platform` -- Unable to acquire token from native platform. + +- Unable to acquire token from native platform. This error is thrown when calling the `acquireTokenByCode` API with the `nativeAccountId` instead of `code` and the app is running in an environment which does not acquire tokens from the native broker. For a list of pre-requisites please review the doc on [device bound tokens](https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/device-bound-tokens.md). ### `native_handshake_timeout` -- Timed out while attempting to establish connection to browser extension. + +- Timed out while attempting to establish connection to browser extension. ### `native_extension_not_installed` -- Native extension is not installed. If you think this is a mistake call the initialize function. + +- Native extension is not installed. If you think this is a mistake call the initialize function. ### `native_connection_not_established` -- Connection to native platform has not been established. Please install a compatible browser extension and run initialize(). + +- Connection to native platform has not been established. Please install a compatible browser extension and run initialize(). This error is thrown when the user signed in with the native broker but no connection to the native broker currently exists. This can happen for the following reasons: @@ -673,7 +788,8 @@ This error is thrown when the user signed in with the native broker but no conne - The `initialize` API has not been called or was not awaited before invoking another MSAL API ### `uninitialized_public_client_application` -- You must call and await the initialize function before attempting to call any other MSAL API. + +- You must call and await the initialize function before attempting to call any other MSAL API. This error is thrown when a `login`, `acquireToken` or `handleRedirectPromise` API is invoked before the `initialize` API has been called. The `initialize` API must be called and awaited before attempting to acquire tokens. @@ -711,45 +827,181 @@ msalInstance.acquireTokenSilent(); // This will also no longer throw this error ``` ### `native_prompt_not_supported` -- The provided prompt is not supported by the native platform. This request should be routed to the web based flow. + +- The provided prompt is not supported by the native platform. This request should be routed to the web based flow. ### `invalid_base64_string` -- Invalid base64 encoded string. + +- Invalid base64 encoded string. ### `invalid_pop_token_request` -- Invalid PoP token request. The request should not have both a popKid value and signPopToken set to true. + +- Invalid PoP token request. The request should not have both a popKid value and signPopToken set to true. ### `failed_to_build_headers` -- Failed to build request headers object. + +- Failed to build request headers object. ### `failed_to_parse_headers` -- Failed to parse response headers. + +- Failed to parse response headers. ### `failed_to_decrypt_ear_response` -- Failed to decrypt ear response. + +- Failed to decrypt ear response. ## Browser configuration errors ### `storage_not_supported` -- Given storage configuration option was not supported. + +- Given storage configuration option was not supported. ### `stubbed_public_client_application_called` -- Stub instance of Public Client Application was called. If using msal-react, please ensure context is not used without a provider. -- See [msal-react errors](https://github.com/AzureAD/microsoft-authentication-library-for-js/tree/dev/lib/msal-react/docs/errors.md). + +- Stub instance of Public Client Application was called. If using msal-react, please ensure context is not used without a provider. +- See [msal-react errors](https://github.com/AzureAD/microsoft-authentication-library-for-js/tree/dev/lib/msal-react/docs/errors.md). ### `in_mem_redirect_unavailable` -- Redirect cannot be supported. In-memory storage was selected and storeAuthStateInCookie=false, which would cause the library to be unable to handle the incoming hash. If you would like to use the redirect API, please use session/localStorage or set storeAuthStateInCookie=true. + +- Redirect cannot be supported. In-memory storage was selected and storeAuthStateInCookie=false, which would cause the library to be unable to handle the incoming hash. If you would like to use the redirect API, please use session/localStorage or set storeAuthStateInCookie=true. ## Native auth errors ### `ContentError` -- Native broker content error. + +- Native broker content error. ### `user_switch` -- User attempted to switch accounts in the native broker, which is not allowed. All new accounts must sign-in through the standard web flow first, please try again. + +- User attempted to switch accounts in the native broker, which is not allowed. All new accounts must sign-in through the standard web flow first, please try again. ### `unsupported_method` -- This method is not supported in nested app environment. + +- 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 From 94ecde9a702b876963d6c6567c0fcc0a528372c6 Mon Sep 17 00:00:00 2001 From: Jian Shen Date: Thu, 26 Jun 2025 11:59:34 +0100 Subject: [PATCH 4/4] Reformat the errors doc --- docs/errors.md | 470 +++++++++++++++++-------------------------------- 1 file changed, 157 insertions(+), 313 deletions(-) diff --git a/docs/errors.md b/docs/errors.md index a5fcd29494..1a5d838e67 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -3,18 +3,15 @@ ## Auth errors ### `unexpected_error` - -- Unexpected error in authentication. +- Unexpected error in authentication. ### `post_request_failed` - -- Post request failed from the network, could be a 4xx/5xx or a network unavailability. Please check the exact error code for details. +- Post request failed from the network, could be a 4xx/5xx or a network unavailability. Please check the exact error code for details. ## Cache errors ### `cache_quota_exceeded` - -- Exceeded cache storage capacity. +- Exceeded cache storage capacity. This error occurs when MSAL.js surpasses the allotted storage limit when attempting to save token information in the [configured cache storage](https://github.com/AzureAD/microsoft-authentication-library-for-js/tree/dev/lib/msal-browser/docs/caching.md#cache-storage). See [here](https://developer.mozilla.org/en-US/docs/Web/API/Storage_API/Storage_quotas_and_eviction_criteria#web_storage) for web storage limits. @@ -23,340 +20,260 @@ This error occurs when MSAL.js surpasses the allotted storage limit when attempt 1. Make sure the configured cache storage has enough capacity to allow MSAL.js to persist token payload. The amount of cache storage required depends on the number of [cached artifacts](./caching.md#cached-artifacts). ### `cache_error_unknown` - -- An unknown error occurred while accessing the browser cache. +- An unknown error occurred while accessing the browser cache. ## Client auth errors ### `client_info_decoding_error` - -- The client info could not be parsed/decoded correctly. +- The client info could not be parsed/decoded correctly. ### `client_info_empty_error` - -- The client info was empty. +- The client info was empty. ### `token_parsing_error` - -- Token cannot be parsed. +- Token cannot be parsed. ### `null_or_empty_token` - -- The token is null or empty. +- The token is null or empty. ### `endpoints_resolution_error` - -- Could not resolve endpoints. Please check network and try again. +- Could not resolve endpoints. Please check network and try again. ### `network_error` - -- Network request failed. Please check network and try again. +- Network request failed. Please check network and try again. ### `openid_config_error` - -- Could not retrieve endpoints. Check your authority and verify the .well-known/openid-configuration endpoint returns the required endpoints. +- Could not retrieve endpoints. Check your authority and verify the .well-known/openid-configuration endpoint returns the required endpoints. ### `hash_not_deserialized` - -- The hash parameters could not be deserialized. +- The hash parameters could not be deserialized. ### `invalid_state` - -- State was not the expected format. +- State was not the expected format. ### `state_mismatch` - -- State mismatch error. +- State mismatch error. ### `state_not_found` - -- State not found. +- State not found. ### `nonce_mismatch` - -- Nonce mismatch error. +- Nonce mismatch error. ### `auth_time_not_found` - -- Max Age was requested and the ID token is missing the auth_time variable. auth_time is an optional claim and is not enabled by default - it must be enabled. See https://aka.ms/msaljs/optional-claims for more information. +- Max Age was requested and the ID token is missing the auth_time variable. auth_time is an optional claim and is not enabled by default - it must be enabled. See https://aka.ms/msaljs/optional-claims for more information. ### `max_age_transpired` - -- Max Age is set to 0, or too much time has elapsed since the last end-user authentication. +- Max Age is set to 0, or too much time has elapsed since the last end-user authentication. ### `multiple_matching_tokens` - -- The cache contains multiple tokens satisfying the requirements. Call AcquireToken again providing more requirements such as authority or account. +- The cache contains multiple tokens satisfying the requirements. Call AcquireToken again providing more requirements such as authority or account. ### `multiple_matching_appMetadata` - -- The cache contains multiple appMetadata satisfying the given parameters. Please pass more info to obtain the correct appMetadata. +- The cache contains multiple appMetadata satisfying the given parameters. Please pass more info to obtain the correct appMetadata. ### `request_cannot_be_made` - -- Token request cannot be made without authorization code or refresh token. +- Token request cannot be made without authorization code or refresh token. ### `cannot_remove_empty_scope` - -- Cannot remove null or empty scope from ScopeSet. +- Cannot remove null or empty scope from ScopeSet. ### `cannot_append_scopeset` - -- Cannot append ScopeSet. +- Cannot append ScopeSet. ### `empty_input_scopeset` - -- Empty input ScopeSet cannot be processed. +- Empty input ScopeSet cannot be processed. ### `no_account_in_silent_request` - -- Please pass an account object, silent flow is not supported without account information. +- Please pass an account object, silent flow is not supported without account information. ### `invalid_cache_record` - -- Cache record object was null or undefined. +- Cache record object was null or undefined. ### `invalid_cache_environment` - -- Invalid environment when attempting to create cache entry. +- Invalid environment when attempting to create cache entry. ### `no_account_found` - -- No account found in cache for given key. +- No account found in cache for given key. ### `no_crypto_object` - -- No crypto object detected. +- No crypto object detected. ### `unexpected_credential_type` - -- Unexpected credential type. +- Unexpected credential type. ### `token_refresh_required` - -- Cannot return token from cache because it must be refreshed. This may be due to one of the following reasons: forceRefresh parameter is set to true, claims have been requested, there is no cached access token or it is expired. +- Cannot return token from cache because it must be refreshed. This may be due to one of the following reasons: forceRefresh parameter is set to true, claims have been requested, there is no cached access token or it is expired. ### `token_claims_cnf_required_for_signedjwt` - -- Cannot generate a POP jwt if the token_claims are not populated. +- Cannot generate a POP jwt if the token_claims are not populated. ### `authorization_code_missing_from_server_response` - -- Server response does not contain an authorization code to proceed. +- Server response does not contain an authorization code to proceed. ### `binding_key_not_removed` - -- Could not remove the credential's binding key from storage. +- Could not remove the credential's binding key from storage. ### `end_session_endpoint_not_supported` - -- The provided authority does not support logout. +- The provided authority does not support logout. ### `key_id_missing` - -- A keyId value is missing from the requested bound token's cache record and is required to match the token to its stored binding key. +- A keyId value is missing from the requested bound token's cache record and is required to match the token to its stored binding key. ### `no_network_connectivity` - -- No network connectivity. Check your internet connection. +- No network connectivity. Check your internet connection. ### `user_canceled` - -- User cancelled the flow. +- User cancelled the flow. ### `method_not_implemented` - -- This method has not been implemented. +- This method has not been implemented. ### `nested_app_auth_bridge_disabled` - -- The nested app auth bridge is disabled. +- The nested app auth bridge is disabled. ## Client configuration errors ### `redirect_uri_empty` - -- A redirect URI is required for all calls and none has been set. +- A redirect URI is required for all calls and none has been set. ### `claims_request_parsing_error` - -- Could not parse the given claims request object. +- Could not parse the given claims request object. ### `authority_uri_insecure` - -- Authority URIs must use https. Please see here for valid authority configuration options: https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-js-initializing-client-applications#configuration-options. +- Authority URIs must use https. Please see here for valid authority configuration options: https://docs.microsoft.com/en-us/azure/active-directory/develop/msal-js-initializing-client-applications#configuration-options. ### `url_parse_error` - -- URL could not be parsed into appropriate segments. +- URL could not be parsed into appropriate segments. ### `empty_url_error` - -- URL was empty or null. +- URL was empty or null. ### `empty_input_scopes_error` - -- Scopes cannot be passed as null, undefined or empty array because they are required to obtain an access token. +- Scopes cannot be passed as null, undefined or empty array because they are required to obtain an access token. ### `invalid_prompt_value` - -- Invalid prompt value. Please see here for valid configuration options: https://azuread.github.io/microsoft-authentication-library-for-js/ref/modules/_azure_msal_common.html#commonauthorizationurlrequest +- Invalid prompt value. Please see here for valid configuration options: https://azuread.github.io/microsoft-authentication-library-for-js/ref/modules/_azure_msal_common.html#commonauthorizationurlrequest ### `invalid_claims` - -- Given claims parameter must be a stringified JSON object. +- Given claims parameter must be a stringified JSON object. ### `token_request_empty` - -- Token request was empty and not found in cache. +- Token request was empty and not found in cache. ### `logout_request_empty` - -- The logout request was null or undefined. +- The logout request was null or undefined. ### `invalid_code_challenge_method` - -- code_challenge_method passed is invalid. Valid values are "plain" and "S256". +- code_challenge_method passed is invalid. Valid values are "plain" and "S256". ### `pkce_params_missing` - -- Both params: code_challenge and code_challenge_method are to be passed if to be sent in the request. +- Both params: code_challenge and code_challenge_method are to be passed if to be sent in the request. ### `invalid_cloud_discovery_metadata` - -- Invalid cloudDiscoveryMetadata provided. Must be a stringified JSON object containing tenant_discovery_endpoint and metadata fields. +- Invalid cloudDiscoveryMetadata provided. Must be a stringified JSON object containing tenant_discovery_endpoint and metadata fields. ### `invalid_authority_metadata` - -- Invalid authorityMetadata provided. Must by a stringified JSON object containing authorization_endpoint, token_endpoint, and issuer fields. +- Invalid authorityMetadata provided. Must by a stringified JSON object containing authorization_endpoint, token_endpoint, and issuer fields. ### `untrusted_authority` - -- The provided authority is not a trusted authority. Please include this authority in the knownAuthorities config parameter. +- The provided authority is not a trusted authority. Please include this authority in the knownAuthorities config parameter. ### `missing_ssh_jwk` - -- Missing sshJwk in SSH certificate request. A stringified JSON Web Key is required when using the SSH authentication scheme. +- Missing sshJwk in SSH certificate request. A stringified JSON Web Key is required when using the SSH authentication scheme. ### `missing_ssh_kid` - -- Missing sshKid in SSH certificate request. A string that uniquely identifies the public SSH key is required when using the SSH authentication scheme. +- Missing sshKid in SSH certificate request. A string that uniquely identifies the public SSH key is required when using the SSH authentication scheme. ### `missing_nonce_authentication_header` - -- Unable to find an authentication header containing server nonce. Either the Authentication-Info or WWW-Authenticate headers must be present in order to obtain a server nonce. +- Unable to find an authentication header containing server nonce. Either the Authentication-Info or WWW-Authenticate headers must be present in order to obtain a server nonce. ### `invalid_authentication_header` - -- Invalid authentication header provided. +- Invalid authentication header provided. ### `cannot_set_OIDCOptions` - -- Cannot set OIDCOptions parameter. Please change the protocol mode to OIDC or use a non-Microsoft authority. +- Cannot set OIDCOptions parameter. Please change the protocol mode to OIDC or use a non-Microsoft authority. ### `cannot_allow_platform_broker` - -- Cannot set allowPlatformBroker parameter to true when not in AAD protocol mode. +- Cannot set allowPlatformBroker parameter to true when not in AAD protocol mode. ### `authority_mismatch` - -- Authority mismatch error. Authority provided in login request or PublicClientApplication config does not match the environment of the provided account. Please use a matching account or make an interactive request to login to this authority. +- Authority mismatch error. Authority provided in login request or PublicClientApplication config does not match the environment of the provided account. Please use a matching account or make an interactive request to login to this authority. ## Interaction required errors ### `no_tokens_found` - -- No refresh token found in the cache. Please sign-in. +- No refresh token found in the cache. Please sign-in. ### `native_account_unavailable` - -- The requested account is not available in the native broker. It may have been deleted or logged out. Please sign-in again using an interactive API. +- The requested account is not available in the native broker. It may have been deleted or logged out. Please sign-in again using an interactive API. ### `refresh_token_expired` - -- Refresh token has expired. +- Refresh token has expired. ### `interaction_required` - -- User interaction is required. +- User interaction is required. ### `consent_required` - -- User consent is required. +- User consent is required. ### `login_required` - -- User login is required. +- User login is required. ### `bad_token` - -- Identity provider returned bad_token due to an expired or invalid refresh token. Please invoke an interactive API to resolve. +- Identity provider returned bad_token due to an expired or invalid refresh token. Please invoke an interactive API to resolve. ### `ux_not_allowed` - -- `canShowUI` flag in Edge was set to false. User interaction required on web page. Please invoke an interactive API to resolve. +- `canShowUI` flag in Edge was set to false. User interaction required on web page. Please invoke an interactive API to resolve. ## JOSE header errors ### `missing_kid_error` - -- The JOSE Header for the requested JWT, JWS or JWK object requires a keyId to be configured as the 'kid' header claim. No 'kid' value was provided. +- The JOSE Header for the requested JWT, JWS or JWK object requires a keyId to be configured as the 'kid' header claim. No 'kid' value was provided. ### `missing_alg_error` - -- The JOSE Header for the requested JWT, JWS or JWK object requires an algorithm to be specified as the 'alg' header claim. No 'alg' value was provided. +- The JOSE Header for the requested JWT, JWS or JWK object requires an algorithm to be specified as the 'alg' header claim. No 'alg' value was provided. ## Browser auth errors ### `pkce_not_created` - -- The PKCE code challenge and verifier could not be generated. +- The PKCE code challenge and verifier could not be generated. ### `ear_jwk_empty` - -- No EAR encryption key provided. This is unexpected. +- No EAR encryption key provided. This is unexpected. ### `ear_jwe_empty` - -- Server response does not contain ear_jwe property. This is unexpected. +- Server response does not contain ear_jwe property. This is unexpected. ### `crypto_nonexistent` - -- The crypto object or function is not available. +- The crypto object or function is not available. ### `empty_navigate_uri` - -- Navigation URI is empty. Please check stack trace for more info. +- Navigation URI is empty. Please check stack trace for more info. ### `hash_empty_error` - -- Hash value cannot be processed because it is empty. Please verify that your redirectUri is not clearing the hash. +- Hash value cannot be processed because it is empty. Please verify that your redirectUri is not clearing the hash. This error occurs when the page you use as your redirectUri is removing the hash, or auto-redirecting to another page. This most commonly happens when the application implements a router which navigates to another route, dropping the hash. To resolve this error we recommend using a dedicated redirectUri page which is not subject to the router. For silent and popup calls it's best to use a blank page. If this is not possible please make sure the router does not navigate while MSAL token acquisition is in progress. You can do this by detecting if your application is loaded in an iframe for silent calls, in a popup for popup calls or by awaiting `handleRedirectPromise` for redirect calls. ### `no_state_in_hash` - -- Hash does not contain state. Please verify that the request originated from MSAL. +- Hash does not contain state. Please verify that the request originated from MSAL. ### `hash_does_not_contain_known_properties` - -- Hash does not contain known properties. Please verify that your redirectUri is not changing the hash. +- Hash does not contain known properties. Please verify that your redirectUri is not changing the hash. Please see explanation for [hash_empty_error](#hash_empty_error) above. The root cause for this error is similar, the difference being the hash has been changed, rather than dropped. ### `unable_to_parse_state` - -- Unable to parse state. Please verify that the request originated from MSAL. +- Unable to parse state. Please verify that the request originated from MSAL. ### `state_interaction_type_mismatch` - -- Hash contains state but the interaction type does not match the caller. +- Hash contains state but the interaction type does not match the caller. ### `interaction_in_progress` - -- Interaction is currently in progress. Please ensure that this interaction has been completed before calling an interactive API. +- Interaction is currently in progress. Please ensure that this interaction has been completed before calling an interactive API. This error is thrown when an interactive API (`loginPopup`, `loginRedirect`, `acquireTokenPopup`, `acquireTokenRedirect`) is invoked while another interactive API is still in progress. The login and acquireToken APIs are async so you will need to ensure that the resulting promises have resolved before invoking another one. @@ -556,24 +473,19 @@ If you are unable to figure out why this error is being thrown please [open an i - Open your application in a new tab. Does the error go away? ### `popup_window_error` - -- Error opening popup window. This can happen if you are using IE or if popups are blocked in the browser. +- Error opening popup window. This can happen if you are using IE or if popups are blocked in the browser. ### `empty_window_error` - -- window.open returned null or undefined window object. +- window.open returned null or undefined window object. ### `user_cancelled` - -- User cancelled the flow. +- User cancelled the flow. ### `monitor_popup_timeout` - -- Token acquisition in popup failed due to timeout. +- Token acquisition in popup failed due to timeout. ### `monitor_window_timeout` - -- Token acquisition in iframe failed due to timeout. +- Token acquisition in iframe failed due to timeout. **Error Messages**: @@ -657,12 +569,10 @@ const msalConfig = { > Please consult the [Troubleshooting Single-Sign On](https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/FAQ.md#troubleshooting-single-sign-on) section of the MSAL Browser FAQ if you are having trouble with the `ssoSilent` API. ### `redirect_in_iframe` - -- Redirects are not supported for iframed or brokered applications. Please ensure you are using MSAL.js in a top frame of the window if using the redirect APIs, or use the popup APIs. +- Redirects are not supported for iframed or brokered applications. Please ensure you are using MSAL.js in a top frame of the window if using the redirect APIs, or use the popup APIs. ### `block_iframe_reload` - -- Request was blocked inside an iframe because MSAL detected an authentication response. +- Request was blocked inside an iframe because MSAL detected an authentication response. This error is thrown when calling `ssoSilent` or `acquireTokenSilent` and the page used as your `redirectUri` is attempting to invoke a login or acquireToken function. Our recommended mitigation for this is to set your `redirectUri` to a blank page that does not implement MSAL when invoking silent APIs. This will also have the added benefit of improving performance as the hidden iframe doesn't need to render your page. @@ -681,106 +591,81 @@ Remember that you will need to register this new `redirectUri` on your App Regis If you do not want to use a dedicated `redirectUri` for this purpose, you should instead ensure that your `redirectUri` is not attempting to call MSAL APIs when rendered inside the hidden iframe used by the silent APIs. ### `block_nested_popups` - -- Request was blocked inside a popup because MSAL detected it was running in a popup. +- Request was blocked inside a popup because MSAL detected it was running in a popup. ### `iframe_closed_prematurely` - -- The iframe being monitored was closed prematurely. +- The iframe being monitored was closed prematurely. ### `silent_logout_unsupported` - -- Silent logout not supported. Please call logoutRedirect or logoutPopup instead. +- Silent logout not supported. Please call logoutRedirect or logoutPopup instead. ### `no_account_error` - -- No account object provided to acquireTokenSilent and no active account has been set. Please call setActiveAccount or provide an account on the request. +- No account object provided to acquireTokenSilent and no active account has been set. Please call setActiveAccount or provide an account on the request. ### `silent_prompt_value_error` - -- The value given for the prompt value is not valid for silent requests - must be set to 'none' or 'no_session'. +- The value given for the prompt value is not valid for silent requests - must be set to 'none' or 'no_session'. ### `no_token_request_cache_error` - -- No token request found in cache. +- No token request found in cache. ### `unable_to_parse_token_request_cache_error` - -- The cached token request could not be parsed. +- The cached token request could not be parsed. ### `auth_request_not_set_error` - -- Auth Request not set. Please ensure initiateAuthRequest was called from the InteractionHandler. +- Auth Request not set. Please ensure initiateAuthRequest was called from the InteractionHandler. ### `invalid_cache_type` - -- Invalid cache type. +- Invalid cache type. ### `non_browser_environment` - -- Login and token requests are not supported in non-browser environments. +- Login and token requests are not supported in non-browser environments. ### `database_not_open` - -- Database is not open. +- Database is not open. ### `no_network_connectivity` - -- No network connectivity. Check your internet connection. +- No network connectivity. Check your internet connection. ### `post_request_failed` - -- Network request failed: If the browser threw a CORS error, check that the redirectUri is registered in the Azure App Portal as type 'SPA'. +- Network request failed: If the browser threw a CORS error, check that the redirectUri is registered in the Azure App Portal as type 'SPA'. ### `get_request_failed` - -- Network request failed. Please check the network trace to determine root cause. +- Network request failed. Please check the network trace to determine root cause. ### `failed_to_parse_response` - -- Failed to parse network response. Check network trace. +- Failed to parse network response. Check network trace. ### `unable_to_load_token` - -- Error loading token to cache. +- Error loading token to cache. ### `crypto_key_not_found` - -- Cryptographic Key or Keypair not found in browser storage. +- Cryptographic Key or Keypair not found in browser storage. ### `auth_code_required` - -- An authorization code must be provided (as the `code` property on the request) to this flow. +- An authorization code must be provided (as the `code` property on the request) to this flow. ### `auth_code_or_nativeAccountId_required` - -- An authorization code or nativeAccountId must be provided to this flow. +- An authorization code or nativeAccountId must be provided to this flow. ### `spa_code_and_nativeAccountId_present` - -- Request cannot contain both spa code and native account id. +- Request cannot contain both spa code and native account id. ### `database_unavailable` - -- IndexedDB, which is required for persistent cryptographic key storage, is unavailable. This may be caused by browser privacy features which block persistent storage in third-party contexts. +- IndexedDB, which is required for persistent cryptographic key storage, is unavailable. This may be caused by browser privacy features which block persistent storage in third-party contexts. ### `unable_to_acquire_token_from_native_platform` - -- Unable to acquire token from native platform. +- Unable to acquire token from native platform. This error is thrown when calling the `acquireTokenByCode` API with the `nativeAccountId` instead of `code` and the app is running in an environment which does not acquire tokens from the native broker. For a list of pre-requisites please review the doc on [device bound tokens](https://github.com/AzureAD/microsoft-authentication-library-for-js/blob/dev/lib/msal-browser/docs/device-bound-tokens.md). ### `native_handshake_timeout` - -- Timed out while attempting to establish connection to browser extension. +- Timed out while attempting to establish connection to browser extension. ### `native_extension_not_installed` - -- Native extension is not installed. If you think this is a mistake call the initialize function. +- Native extension is not installed. If you think this is a mistake call the initialize function. ### `native_connection_not_established` - -- Connection to native platform has not been established. Please install a compatible browser extension and run initialize(). +- Connection to native platform has not been established. Please install a compatible browser extension and run initialize(). This error is thrown when the user signed in with the native broker but no connection to the native broker currently exists. This can happen for the following reasons: @@ -788,8 +673,7 @@ This error is thrown when the user signed in with the native broker but no conne - The `initialize` API has not been called or was not awaited before invoking another MSAL API ### `uninitialized_public_client_application` - -- You must call and await the initialize function before attempting to call any other MSAL API. +- You must call and await the initialize function before attempting to call any other MSAL API. This error is thrown when a `login`, `acquireToken` or `handleRedirectPromise` API is invoked before the `initialize` API has been called. The `initialize` API must be called and awaited before attempting to acquire tokens. @@ -827,181 +711,141 @@ msalInstance.acquireTokenSilent(); // This will also no longer throw this error ``` ### `native_prompt_not_supported` - -- The provided prompt is not supported by the native platform. This request should be routed to the web based flow. +- The provided prompt is not supported by the native platform. This request should be routed to the web based flow. ### `invalid_base64_string` - -- Invalid base64 encoded string. +- Invalid base64 encoded string. ### `invalid_pop_token_request` - -- Invalid PoP token request. The request should not have both a popKid value and signPopToken set to true. +- Invalid PoP token request. The request should not have both a popKid value and signPopToken set to true. ### `failed_to_build_headers` - -- Failed to build request headers object. +- Failed to build request headers object. ### `failed_to_parse_headers` - -- Failed to parse response headers. +- Failed to parse response headers. ### `failed_to_decrypt_ear_response` - -- Failed to decrypt ear response. +- Failed to decrypt ear response. ## Browser configuration errors ### `storage_not_supported` - -- Given storage configuration option was not supported. +- Given storage configuration option was not supported. ### `stubbed_public_client_application_called` - -- Stub instance of Public Client Application was called. If using msal-react, please ensure context is not used without a provider. -- See [msal-react errors](https://github.com/AzureAD/microsoft-authentication-library-for-js/tree/dev/lib/msal-react/docs/errors.md). +- Stub instance of Public Client Application was called. If using msal-react, please ensure context is not used without a provider. +- See [msal-react errors](https://github.com/AzureAD/microsoft-authentication-library-for-js/tree/dev/lib/msal-react/docs/errors.md). ### `in_mem_redirect_unavailable` - -- Redirect cannot be supported. In-memory storage was selected and storeAuthStateInCookie=false, which would cause the library to be unable to handle the incoming hash. If you would like to use the redirect API, please use session/localStorage or set storeAuthStateInCookie=true. +- Redirect cannot be supported. In-memory storage was selected and storeAuthStateInCookie=false, which would cause the library to be unable to handle the incoming hash. If you would like to use the redirect API, please use session/localStorage or set storeAuthStateInCookie=true. ## Native auth errors ### `ContentError` - -- Native broker content error. +- Native broker content error. ### `user_switch` - -- User attempted to switch accounts in the native broker, which is not allowed. All new accounts must sign-in through the standard web flow first, please try again. +- User attempted to switch accounts in the native broker, which is not allowed. All new accounts must sign-in through the standard web flow first, please try again. ### `unsupported_method` - -- This method is not supported in nested app environment. +- This method is not supported in nested app environment. ## Custom Authentication errors ### HTTP errors #### `no_network_connectivity` - -- No network connectivity. Check your internet connection. +- No network connectivity. Check your internet connection. #### `failed_send_request` - -- Failed to send HTTP request to the server. +- Failed to send HTTP request to the server. ### Configuration errors #### `missing_configuration` - -- Required configuration is missing for the custom authentication flow. +- Required configuration is missing for the custom authentication flow. #### `invalid_authority` - -- The provided authority URL is invalid or not supported for custom authentication. +- 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. +- 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. +- 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. +- 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. +- 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. +- The response body from the authentication server is invalid or malformed. #### `empty_response` - -- The server returned an empty response when data was expected. +- The server returned an empty response when data was expected. #### `unsupported_challenge_type` - -- The challenge type provided is not supported. +- The challenge type provided is not supported. #### `access_token_missing` - -- The access token is missing from the authentication response. +- The access token is missing from the authentication response. #### `id_token_missing` - -- The ID token is missing from the authentication response. +- The ID token is missing from the authentication response. #### `refresh_token_missing` - -- The refresh token is missing from the authentication response. +- The refresh token is missing from the authentication response. #### `invalid_expires_in` - -- The token expiration time (expires_in) value is invalid. +- The token expiration time (expires_in) value is invalid. #### `invalid_token_type` - -- The token type returned by the server is not supported. +- The token type returned by the server is not supported. #### `http_request_failed` - -- The HTTP request to the authentication server failed. +- The HTTP request to the authentication server failed. #### `invalid_request` - -- The authentication request is malformed or contains invalid parameters. +- The authentication request is malformed or contains invalid parameters. #### `user_not_found` - -- The specified user could not be found. +- The specified user could not be found. #### `invalid_grant` - -- The authorization grant provided is invalid, expired, or revoked. +- The authorization grant provided is invalid, expired, or revoked. #### `credential_required` - -- User credentials are required to complete the authentication flow. +- User credentials are required to complete the authentication flow. #### `attributes_required` - -- Additional user attributes are required to complete the authentication flow. +- Additional user attributes are required to complete the authentication flow. #### `user_already_exists` - -- A user with the specified identifier already exists. +- A user with the specified identifier already exists. #### `invalid_poll_status` - -- The polling status returned by the server is invalid. +- The polling status returned by the server is invalid. #### `password_change_failed` - -- The password change operation failed. +- The password change operation failed. #### `password_reset_timeout` - -- The password reset operation timed out. +- The password reset operation timed out. #### `client_info_missing` - -- Client information is missing from the authentication response. +- Client information is missing from the authentication response. #### `expired_token` - -- The provided token has expired and cannot be used. +- The provided token has expired and cannot be used. ## Other