diff --git a/change/@azure-msal-browser-cd230614-74a1-4aca-9ca2-696980d417ef.json b/change/@azure-msal-browser-cd230614-74a1-4aca-9ca2-696980d417ef.json new file mode 100644 index 0000000000..d2e1016842 --- /dev/null +++ b/change/@azure-msal-browser-cd230614-74a1-4aca-9ca2-696980d417ef.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "[Native Auth] Enable the MFA and JIT (SMS) in the public interfaces #8069", + "packageName": "@azure/msal-browser", + "email": "shen.jian@live.com", + "dependentChangeType": "patch" +} diff --git a/lib/msal-browser/src/custom_auth/controller/CustomAuthStandardController.ts b/lib/msal-browser/src/custom_auth/controller/CustomAuthStandardController.ts index d206e4f599..a6e3e8d1dd 100644 --- a/lib/msal-browser/src/custom_auth/controller/CustomAuthStandardController.ts +++ b/lib/msal-browser/src/custom_auth/controller/CustomAuthStandardController.ts @@ -31,6 +31,7 @@ import { SIGN_IN_PASSWORD_REQUIRED_RESULT_TYPE, SIGN_IN_COMPLETED_RESULT_TYPE, SIGN_IN_JIT_REQUIRED_RESULT_TYPE, + SIGN_IN_MFA_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"; @@ -43,6 +44,7 @@ import { CustomAuthApiClient } from "../core/network_client/custom_auth_api/Cust import { FetchHttpClient } from "../core/network_client/http_client/FetchHttpClient.js"; import { ResetPasswordClient } from "../reset_password/interaction_client/ResetPasswordClient.js"; import { JitClient } from "../core/interaction_client/jit/JitClient.js"; +import { MfaClient } from "../core/interaction_client/mfa/MfaClient.js"; import { NoCachedAccountFoundError } from "../core/error/NoCachedAccountFoundError.js"; import * as ArgumentValidator from "../core/utils/ArgumentValidator.js"; import { UserAlreadySignedInError } from "../core/error/UserAlreadySignedInError.js"; @@ -52,6 +54,7 @@ import { SignInCodeRequiredState } from "../sign_in/auth_flow/state/SignInCodeRe import { SignInPasswordRequiredState } from "../sign_in/auth_flow/state/SignInPasswordRequiredState.js"; import { SignInCompletedState } from "../sign_in/auth_flow/state/SignInCompletedState.js"; import { AuthMethodRegistrationRequiredState } from "../core/auth_flow/jit/state/AuthMethodRegistrationState.js"; +import { MfaAwaitingState } from "../core/auth_flow/mfa/state/MfaState.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"; @@ -68,6 +71,7 @@ export class CustomAuthStandardController private readonly signUpClient: SignUpClient; private readonly resetPasswordClient: ResetPasswordClient; private readonly jitClient: JitClient; + private readonly mfaClient: MfaClient; private readonly cacheClient: CustomAuthSilentCacheClient; private readonly customAuthConfig: CustomAuthBrowserConfiguration; private readonly authority: CustomAuthAuthority; @@ -129,6 +133,7 @@ export class CustomAuthStandardController this.resetPasswordClient = interactionClientFactory.create(ResetPasswordClient); this.jitClient = interactionClientFactory.create(JitClient); + this.mfaClient = interactionClientFactory.create(MfaClient); this.cacheClient = interactionClientFactory.create( CustomAuthSilentCacheClient ); @@ -242,6 +247,7 @@ export class CustomAuthStandardController signInClient: this.signInClient, cacheClient: this.cacheClient, jitClient: this.jitClient, + mfaClient: this.mfaClient, username: signInInputs.username, codeLength: startResult.codeLength, scopes: signInInputs.scopes ?? [], @@ -272,6 +278,7 @@ export class CustomAuthStandardController signInClient: this.signInClient, cacheClient: this.cacheClient, jitClient: this.jitClient, + mfaClient: this.mfaClient, username: signInInputs.username, scopes: signInInputs.scopes ?? [], claims: signInInputs.claims, @@ -344,6 +351,29 @@ export class CustomAuthStandardController claims: signInInputs.claims, }) ); + } else if ( + submitPasswordResult.type === + SIGN_IN_MFA_REQUIRED_RESULT_TYPE + ) { + // MFA is required - create MfaAwaitingState + this.logger.verbose( + "MFA required for sign-in.", + correlationId + ); + + return new SignInResult( + new MfaAwaitingState({ + correlationId: correlationId, + continuationToken: + submitPasswordResult.continuationToken, + logger: this.logger, + config: this.customAuthConfig, + mfaClient: this.mfaClient, + cacheClient: this.cacheClient, + scopes: signInInputs.scopes ?? [], + authMethods: submitPasswordResult.authMethods ?? [], + }) + ); } else { // Unexpected result type const result = submitPasswordResult as { type: string }; @@ -437,6 +467,7 @@ export class CustomAuthStandardController signUpClient: this.signUpClient, cacheClient: this.cacheClient, jitClient: this.jitClient, + mfaClient: this.mfaClient, username: signUpInputs.username, codeLength: startResult.codeLength, codeResendInterval: startResult.interval, @@ -461,6 +492,7 @@ export class CustomAuthStandardController signUpClient: this.signUpClient, cacheClient: this.cacheClient, jitClient: this.jitClient, + mfaClient: this.mfaClient, username: signUpInputs.username, }) ); @@ -531,6 +563,7 @@ export class CustomAuthStandardController resetPasswordClient: this.resetPasswordClient, cacheClient: this.cacheClient, jitClient: this.jitClient, + mfaClient: this.mfaClient, username: resetPasswordInputs.username, codeLength: startResult.codeLength, }) diff --git a/lib/msal-browser/src/custom_auth/core/auth_flow/mfa/error_type/MfaError.ts b/lib/msal-browser/src/custom_auth/core/auth_flow/mfa/error_type/MfaError.ts index 50ba9a2233..f2fb175dff 100644 --- a/lib/msal-browser/src/custom_auth/core/auth_flow/mfa/error_type/MfaError.ts +++ b/lib/msal-browser/src/custom_auth/core/auth_flow/mfa/error_type/MfaError.ts @@ -16,6 +16,14 @@ export class MfaRequestChallengeError extends AuthActionErrorBase { isInvalidInput(): boolean { return this.isInvalidInputError(); } + + /** + * Checks if the error is due to the verification contact (e.g., phone number or email) being blocked. Consider contacting customer support for assistance. + * @returns true if the error is due to the verification contact being blocked, false otherwise. + */ + isVerificationContactBlocked(): boolean { + return this.isVerificationContactBlockedError(); + } } /** 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 index 7a81dd33a7..e9cae3953d 100644 --- 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 @@ -47,7 +47,8 @@ export class CustomAuthApiClient implements ICustomAuthApiClient { this.registerApi = new RegisterApiClient( customAuthApiBaseUrl, clientId, - httpClient + httpClient, + customAuthApiQueryParams ); } } 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 32310451b5..0b6306387d 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 @@ -37,12 +37,14 @@ export class CustomAuthSilentCacheClient extends CustomAuthInteractionClientBase override async acquireToken( silentRequest: CommonSilentFlowRequest ): Promise { + const correlationId = silentRequest.correlationId || this.correlationId; const telemetryManager = this.initializeServerTelemetryManager( PublicApiId.ACCOUNT_GET_ACCESS_TOKEN ); const clientConfig = this.getCustomAuthClientConfiguration( telemetryManager, - this.customAuthAuthority + this.customAuthAuthority, + correlationId ); const silentFlowClient = new SilentFlowClient( clientConfig, @@ -52,7 +54,7 @@ export class CustomAuthSilentCacheClient extends CustomAuthInteractionClientBase try { this.logger.verbose( "Starting silent flow to acquire token from cache", - this.correlationId + correlationId ); const result = await silentFlowClient.acquireCachedToken( @@ -61,7 +63,7 @@ export class CustomAuthSilentCacheClient extends CustomAuthInteractionClientBase this.logger.verbose( "Silent flow to acquire token from cache is completed and token is found", - this.correlationId + correlationId ); return result[0] as AuthenticationResult; @@ -72,7 +74,7 @@ export class CustomAuthSilentCacheClient extends CustomAuthInteractionClientBase ) { this.logger.verbose( "Token refresh is required to acquire token silently", - this.correlationId + correlationId ); const refreshTokenClient = new RefreshTokenClient( @@ -82,7 +84,7 @@ export class CustomAuthSilentCacheClient extends CustomAuthInteractionClientBase this.logger.verbose( "Starting refresh flow to refresh token", - this.correlationId + correlationId ); const refreshTokenResult = @@ -92,7 +94,7 @@ export class CustomAuthSilentCacheClient extends CustomAuthInteractionClientBase this.logger.verbose( "Refresh flow to refresh token is completed", - this.correlationId + correlationId ); return refreshTokenResult as AuthenticationResult; @@ -103,18 +105,17 @@ export class CustomAuthSilentCacheClient extends CustomAuthInteractionClientBase } override async logout(logoutRequest?: ClearCacheRequest): Promise { + const correlationId = + logoutRequest?.correlationId || this.correlationId; const validLogoutRequest = this.initializeLogoutRequest(logoutRequest); // Clear the cache - this.logger.verbose( - "Start to clear the cache", - logoutRequest?.correlationId - ); + this.logger.verbose("Start to clear the cache", correlationId); await this.clearCacheOnLogout( - validLogoutRequest.correlationId, + correlationId, validLogoutRequest?.account ); - this.logger.verbose("Cache cleared", logoutRequest?.correlationId); + this.logger.verbose("Cache cleared", correlationId); const postLogoutRedirectUri = this.config.auth.postLogoutRedirectUri; @@ -126,7 +127,7 @@ export class CustomAuthSilentCacheClient extends CustomAuthInteractionClientBase this.logger.verbose( "Post logout redirect uri is set, redirecting to uri", - logoutRequest?.correlationId + correlationId ); // Redirect to post logout redirect uri @@ -173,7 +174,8 @@ export class CustomAuthSilentCacheClient extends CustomAuthInteractionClientBase private getCustomAuthClientConfiguration( serverTelemetryManager: ServerTelemetryManager, - customAuthAuthority: CustomAuthAuthority + customAuthAuthority: CustomAuthAuthority, + correlationId: string ): ClientConfiguration { const logger = this.config.system.loggerOptions; @@ -193,7 +195,7 @@ export class CustomAuthSilentCacheClient extends CustomAuthInteractionClientBase loggerCallback: logger.loggerCallback, piiLoggingEnabled: logger.piiLoggingEnabled, logLevel: logger.logLevel, - correlationId: this.correlationId, + correlationId: correlationId, }, cacheOptions: { claimsBasedCachingEnabled: diff --git a/lib/msal-browser/src/custom_auth/index.ts b/lib/msal-browser/src/custom_auth/index.ts index 15d0c08712..89420487fe 100644 --- a/lib/msal-browser/src/custom_auth/index.ts +++ b/lib/msal-browser/src/custom_auth/index.ts @@ -212,5 +212,27 @@ export { // Auth Method Registration Types export { AuthMethodDetails } from "./core/auth_flow/jit/AuthMethodDetails.js"; +// MFA State +export { MfaAwaitingState } from "./core/auth_flow/mfa/state/MfaState.js"; +export { MfaVerificationRequiredState } from "./core/auth_flow/mfa/state/MfaState.js"; +export { MfaCompletedState } from "./core/auth_flow/mfa/state/MfaCompletedState.js"; +export { MfaFailedState } from "./core/auth_flow/mfa/state/MfaFailedState.js"; + +// MFA Results +export { + MfaRequestChallengeResult, + MfaRequestChallengeResultState, +} from "./core/auth_flow/mfa/result/MfaRequestChallengeResult.js"; +export { + MfaSubmitChallengeResult, + MfaSubmitChallengeResultState, +} from "./core/auth_flow/mfa/result/MfaSubmitChallengeResult.js"; + +// MFA Errors +export { + MfaRequestChallengeError, + MfaSubmitChallengeError, +} from "./core/auth_flow/mfa/error_type/MfaError.js"; + // Components from msal_browser export { LogLevel } from "@azure/msal-common/browser"; 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 index 519267880f..4b8da113f2 100644 --- 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 @@ -56,6 +56,7 @@ export class ResetPasswordCodeRequiredState extends ResetPasswordState { expect(result.error).toBeDefined(); expect(result.error).toBeInstanceOf(SignInError); }); + + it("should handle invalid client configuration error", async () => { + const configError = new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_REQUEST, + "Invalid client configuration", + "correlation-id" + ); + signInApiClient.initiate.mockRejectedValue(configError); + + const signInInputs: SignInInputs = { + correlationId: "correlation-id", + username: "test@test.com", + }; + + const result = await controller.signIn(signInInputs); + + expect(result).toBeInstanceOf(SignInResult); + expect(result.error).toBeDefined(); + expect(result.isFailed()).toBe(true); + }); + + it("should handle null signInInputs gracefully", async () => { + const result = await controller.signIn(null as any); + + expect(result).toBeInstanceOf(SignInResult); + expect(result.error).toBeDefined(); + expect(result.error).toBeInstanceOf(SignInError); + expect(result.isFailed()).toBe(true); + }); + + it("should handle undefined signInInputs gracefully", async () => { + const result = await controller.signIn(undefined as any); + + expect(result).toBeInstanceOf(SignInResult); + expect(result.error).toBeDefined(); + expect(result.error).toBeInstanceOf(SignInError); + expect(result.isFailed()).toBe(true); + }); + + it("should handle whitespace-only username", async () => { + const signInInputs: SignInInputs = { + correlationId: "correlation-id", + username: " ", + }; + + const result = await controller.signIn(signInInputs); + + expect(result).toBeInstanceOf(SignInResult); + expect(result.error).toBeDefined(); + expect(result.error).toBeInstanceOf(SignInError); + expect(result.isFailed()).toBe(true); + }); + + it("should handle sign-in with custom scopes", async () => { + signInApiClient.initiate.mockResolvedValue({ + challenge_type: ChallengeType.PASSWORD, + correlation_id: "corr123", + continuation_token: "continuation_token_1", + }); + + const signInInputs: SignInInputs = { + correlationId: "correlation-id", + username: "test@test.com", + scopes: ["custom.scope1", "custom.scope2", "custom.scope3"], + }; + + const result = await controller.signIn(signInInputs); + + expect(result).toBeInstanceOf(SignInResult); + expect(result.error).toBeUndefined(); + expect(result.isPasswordRequired()).toBe(true); + // Verify scopes are passed through + expect(result.state?.constructor.name).toBe( + "SignInPasswordRequiredState" + ); + }); + + it("should handle sign-in when user is already signed in", async () => { + // Mock that a user is already cached + jest.spyOn(controller, "getCurrentAccount").mockReturnValue({ + data: {} as any, + error: undefined, + } as any); + + const signInInputs: SignInInputs = { + correlationId: "correlation-id", + username: "test@test.com", + }; + + const result = await controller.signIn(signInInputs); + + expect(result).toBeInstanceOf(SignInResult); + expect(result.error).toBeDefined(); + expect(result.isFailed()).toBe(true); + }); + + it("should handle sign-in with unsupported challenge type", async () => { + // Setup the API to throw an error for unsupported challenge type + const unsupportedError = new Error("Unsupported challenge type"); + unsupportedError.name = "CustomAuthApiError"; + signInApiClient.initiate.mockRejectedValue(unsupportedError); + + const signInInputs: SignInInputs = { + correlationId: "correlation-id", + username: "test@test.com", + }; + + const result = await controller.signIn(signInInputs); + + expect(result).toBeInstanceOf(SignInResult); + expect(result.error).toBeDefined(); + expect(result.isFailed()).toBe(true); + }); + + it("should handle sign-in with API network error", async () => { + // Setup the API to throw a network error + const networkError = new Error("Network error during sign-in"); + networkError.name = "NetworkError"; + signInApiClient.initiate.mockRejectedValue(networkError); + + const signInInputs: SignInInputs = { + correlationId: "correlation-id", + username: "test@test.com", + }; + + const result = await controller.signIn(signInInputs); + + expect(result).toBeInstanceOf(SignInResult); + expect(result.error).toBeDefined(); + expect(result.isFailed()).toBe(true); + }); + + it("should handle sign-in with MFA required after password submission", 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.requestAuthMethods.mockResolvedValue({ + correlation_id: "corr123", + auth_methods: [ + { + id: "email_method", + type: "email", + channel: "email", + login_hint: "user@example.com", + }, + { + id: "sms_method", + type: "sms", + channel: "sms", + login_hint: "+1234567890", + }, + ], + }); + + // Mock MFA required error - the API should throw, not return + const mfaError = new CustomAuthApiError( + CustomAuthApiErrorCode.CREDENTIAL_REQUIRED, + "MFA required", + "corr123", + [55003], // MFA_REQUIRED suberror code + undefined, + undefined, + "mfa_continuation_token" + ); + mfaError.subError = "mfa_required"; // Set the MFA_REQUIRED suberror + signInApiClient.requestTokensWithPassword.mockRejectedValue( + mfaError + ); + + 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.isMfaRequired()).toBe(true); + expect(result.state?.constructor.name).toBe("MfaAwaitingState"); + }); + + it("should handle network failure during sign-up start", async () => { + const networkError = new Error("Network failure"); + networkError.name = "NetworkError"; + signUpApiClient.start.mockRejectedValue(networkError); + + 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).toBeInstanceOf(SignUpError); + expect(result.isFailed()).toBe(true); + }); + + it("should handle sign-up with custom attributes", 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", + attributes: { + firstName: "John", + lastName: "Doe", + company: "Test Corp", + }, + }; + + const result = await controller.signUp(signUpInputs); + + expect(result).toBeInstanceOf(SignUpResult); + expect(result.error).toBeUndefined(); + expect(result.isPasswordRequired()).toBe(true); + }); + + it("should handle sign-up with both password and attributes", 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", + password: "test-password", + attributes: { + firstName: "Jane", + lastName: "Smith", + }, + }; + + const result = await controller.signUp(signUpInputs); + + expect(result).toBeInstanceOf(SignUpResult); + expect(result.error).toBeUndefined(); + expect(result.isPasswordRequired()).toBe(true); + }); + + it("should handle sign-up with invalid attribute format", async () => { + const invalidAttributeError = new CustomAuthApiError( + CustomAuthApiErrorCode.INVALID_REQUEST, + "Invalid attribute format", + "correlation-id" + ); + signUpApiClient.start.mockRejectedValue(invalidAttributeError); + + const signUpInputs: SignUpInputs = { + correlationId: "correlation-id", + username: "test@test.com", + attributes: { + invalidAttribute: null, + } as any, + }; + + const result = await controller.signUp(signUpInputs); + + expect(result).toBeInstanceOf(SignUpResult); + expect(result.error).toBeDefined(); + expect(result.isFailed()).toBe(true); + }); + + it("should handle sign-up when user is already signed in", async () => { + // Mock that a user is already cached + jest.spyOn(controller, "getCurrentAccount").mockReturnValue({ + data: {} as any, + error: undefined, + } as any); + + 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.isFailed()).toBe(true); + }); + + it("should handle username already exists error", async () => { + const userExistsError = new CustomAuthApiError( + CustomAuthApiErrorCode.USER_ALREADY_EXISTS, + "Username already exists", + "correlation-id" + ); + signUpApiClient.start.mockRejectedValue(userExistsError); + + const signUpInputs: SignUpInputs = { + correlationId: "correlation-id", + username: "existing@test.com", + }; + + const result = await controller.signUp(signUpInputs); + + expect(result).toBeInstanceOf(SignUpResult); + expect(result.error).toBeDefined(); + expect(result.isFailed()).toBe(true); + }); + + it("should handle server internal error during sign-up", async () => { + const serverError = new CustomAuthApiError( + CustomAuthApiErrorCode.HTTP_REQUEST_FAILED, + "Internal server error", + "correlation-id" + ); + signUpApiClient.start.mockRejectedValue(serverError); + + 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.isFailed()).toBe(true); + }); + + it("should handle malformed challenge response during sign-up", async () => { + signUpApiClient.start.mockResolvedValue({ + continuation_token: "continuation_token_1", + }); + signUpApiClient.requestChallenge.mockResolvedValue({ + // Missing required challenge_type field + 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).toBeDefined(); + expect(result.isFailed()).toBe(true); + }); }); describe("signUp", () => { diff --git a/lib/msal-browser/test/custom_auth/core/auth_flow/mfa/error_type/MfaError.spec.ts b/lib/msal-browser/test/custom_auth/core/auth_flow/mfa/error_type/MfaError.spec.ts index 044911a004..0c9e6010d8 100644 --- a/lib/msal-browser/test/custom_auth/core/auth_flow/mfa/error_type/MfaError.spec.ts +++ b/lib/msal-browser/test/custom_auth/core/auth_flow/mfa/error_type/MfaError.spec.ts @@ -71,6 +71,58 @@ describe("MfaRequestChallengeError", () => { expect(mfaError.isInvalidInput()).toBe(false); }); }); + + describe("isVerificationContactBlocked", () => { + it("returns true when invalid_request with code 550024 and matching description substring", () => { + const apiError = new CustomAuthApiError( + "invalid_request", + "The multi-factor authentication method is blocked due to too many attempts.", + "correlation-id", + [550024] + ); + const mfaError = new MfaRequestChallengeError(apiError); + expect(mfaError.isVerificationContactBlocked()).toBe(true); + }); + + it("returns false when code 550024 present but description does not contain required phrase", () => { + const apiError = new CustomAuthApiError( + "invalid_request", + "MFA method temporarily disabled.", + "correlation-id", + [550024] + ); + const mfaError = new MfaRequestChallengeError(apiError); + expect(mfaError.isVerificationContactBlocked()).toBe(false); + }); + + it("returns false when description contains phrase but error code 550024 missing", () => { + const apiError = new CustomAuthApiError( + "invalid_request", + "The multi-factor authentication method is blocked at this time.", + "correlation-id", + [901001] + ); + const mfaError = new MfaRequestChallengeError(apiError); + expect(mfaError.isVerificationContactBlocked()).toBe(false); + }); + + it("returns false when different error type even if code and description match", () => { + const apiError = new CustomAuthApiError( + "server_error", + "The multi-factor authentication method is blocked by policy.", + "correlation-id", + [550024] + ); + const mfaError = new MfaRequestChallengeError(apiError); + expect(mfaError.isVerificationContactBlocked()).toBe(false); + }); + + it("returns false for non-API errors (InvalidArgumentError)", () => { + const invalidArg = new InvalidArgumentError("authMethodId"); + const mfaError = new MfaRequestChallengeError(invalidArg); + expect(mfaError.isVerificationContactBlocked()).toBe(false); + }); + }); }); describe("MfaSubmitChallengeError", () => { 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 index b20568ca5d..a8c38bbe79 100644 --- a/lib/msal-browser/test/custom_auth/integration_tests/ResetPassword.spec.ts +++ b/lib/msal-browser/test/custom_auth/integration_tests/ResetPassword.spec.ts @@ -19,6 +19,12 @@ import { AuthMethodRegistrationRequiredState } from "../../../src/custom_auth/co import { AuthMethodVerificationRequiredState } from "../../../src/custom_auth/core/auth_flow/jit/state/AuthMethodRegistrationState.js"; import { AuthMethodRegistrationChallengeMethodResult } from "../../../src/custom_auth/core/auth_flow/jit/result/AuthMethodRegistrationChallengeMethodResult.js"; import { AuthMethodRegistrationSubmitChallengeResult } from "../../../src/custom_auth/core/auth_flow/jit/result/AuthMethodRegistrationSubmitChallengeResult.js"; +import { + MfaAwaitingState, + MfaVerificationRequiredState, +} from "../../../src/custom_auth/core/auth_flow/mfa/state/MfaState.js"; +import { MfaRequestChallengeResult } from "../../../src/custom_auth/core/auth_flow/mfa/result/MfaRequestChallengeResult.js"; +import { MfaSubmitChallengeResult } from "../../../src/custom_auth/core/auth_flow/mfa/result/MfaSubmitChallengeResult.js"; describe("Reset password", () => { let app: CustomAuthPublicClientApplication; @@ -402,7 +408,7 @@ describe("Reset password", () => { expect(startResult.error?.isUserNotFound()).toBe(true); }); - it("should handle JIT registration required during reset password flow sign in", async () => { + it("should handle JIT registration required with email during reset password flow sign in", async () => { (fetch as jest.Mock) // Step 1: Mock /resetpassword/v1.0/start - successful start .mockResolvedValueOnce({ @@ -609,6 +615,213 @@ describe("Reset password", () => { ); }); + it("should handle JIT registration required with SMS during reset password flow sign in", async () => { + (fetch as jest.Mock) + // Step 1: Mock /resetpassword/v1.0/start - successful start + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-1", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Step 2: Mock /resetpassword/v1.0/challenge - successful challenge + .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, + }) + // Step 3: Mock /resetpassword/v1.0/continue - submit code successfully + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-3", + expires_in: 600, + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Step 4: Mock /resetpassword/v1.0/submit - successful submit password + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-4", + poll_interval: 1, + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Step 5: Mock /resetpassword/v1.0/poll_completion - poll and succeeded + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-5", + status: "succeeded", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Step 6: Mock /oauth/v2.0/token - returns registration_required error + .mockResolvedValueOnce({ + status: 400, + json: async () => { + return { + error: "invalid_grant", + error_description: + "AADSTS50076: Strong authentication is required.", + error_codes: [50076], + suberror: "registration_required", + timestamp: "yyyy-mm-dd 10:15:00Z", + trace_id: "test-trace-id", + correlation_id: correlationId, + continuation_token: "jit-continuation-token-1", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }) + // Step 7: Mock /register/v1.0/introspect - get available auth methods + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "jit-continuation-token-2", + methods: [ + { + id: "sms", + challenge_type: "oob", + challenge_channel: "sms", + login_hint: "000000000", + }, + ], + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Step 8: Mock /register/v1.0/challenge - challenge auth method + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "jit-continuation-token-3", + challenge_type: "oob", + binding_method: "prompt", + challenge_target: "000000000", + challenge_channel: "sms", + code_length: 6, + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Step 9: Mock /register/v1.0/continue - submit challenge + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "jit-continuation-token-4", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Step 10: Mock /oauth/v2.0/token - successful token acquisition + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return TestServerTokenResponse; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + const resetPasswordInputs = { + username: "test@test.com", + correlationId: correlationId, + }; + + // Complete reset password flow + const startResult = await app.resetPassword(resetPasswordInputs); + expect(startResult.isCodeRequired()).toBe(true); + + const submitCodeResult = await ( + startResult.state as ResetPasswordCodeRequiredState + ).submitCode("12345678"); + expect(submitCodeResult.isPasswordRequired()).toBe(true); + + const submitPasswordResult = await ( + submitCodeResult.state as ResetPasswordPasswordRequiredState + ).submitNewPassword("valid-password"); + expect(submitPasswordResult.isCompleted()).toBe(true); + + // Attempt sign in - should trigger JIT + const signInResult = await ( + submitPasswordResult.state as ResetPasswordCompletedState + ).signIn(); + + expect(signInResult).toBeInstanceOf(SignInResult); + expect(signInResult.error).toBeUndefined(); + expect(signInResult.isAuthMethodRegistrationRequired()).toBe(true); + + const jitState = + signInResult.state as AuthMethodRegistrationRequiredState; + expect(jitState.getAuthMethods()).toHaveLength(1); + expect(jitState.getAuthMethods()[0].id).toBe("sms"); + + // Challenge the authentication method + const challengeResult = await jitState.challengeAuthMethod({ + authMethodType: jitState.getAuthMethods()[0], + verificationContact: "000000000", + }); + + expect(challengeResult).toBeInstanceOf( + AuthMethodRegistrationChallengeMethodResult + ); + expect(challengeResult.error).toBeUndefined(); + expect(challengeResult.isVerificationRequired()).toBe(true); + + const verificationState = + challengeResult.state as AuthMethodVerificationRequiredState; + expect(verificationState.getCodeLength()).toBe(6); + expect(verificationState.getChannel()).toBe("sms"); + expect(verificationState.getSentTo()).toBe("000000000"); + + // Submit the verification challenge + const submitChallengeResult = await verificationState.submitChallenge( + "123456" + ); + + expect(submitChallengeResult).toBeInstanceOf( + AuthMethodRegistrationSubmitChallengeResult + ); + expect(submitChallengeResult.error).toBeUndefined(); + expect(submitChallengeResult.isCompleted()).toBe(true); + expect(submitChallengeResult.data).toBeDefined(); + expect(submitChallengeResult.data).toBeInstanceOf( + CustomAuthAccountData + ); + }); + it("should handle JIT fast-pass scenario during reset password flow sign in", async () => { (fetch as jest.Mock) // Reset password flow mocks (Steps 1-5) @@ -1491,4 +1704,400 @@ describe("Reset password", () => { expect(challengeResult.isCompleted()).toBe(true); expect(challengeResult.data).toBeInstanceOf(CustomAuthAccountData); }); + + it("should handle MFA required with Email during reset password flow sign in", async () => { + (fetch as jest.Mock) + // Step 1: Mock /resetpassword/v1.0/start - successful start + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-1", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Step 2: Mock /resetpassword/v1.0/challenge - successful challenge + .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, + }) + // Step 3: Mock /resetpassword/v1.0/continue - submit code successfully + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-3", + expires_in: 600, + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Step 4: Mock /resetpassword/v1.0/submit - successful submit password + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-4", + poll_interval: 1, + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Step 5: Mock /resetpassword/v1.0/poll_completion - poll and succeeded + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-5", + status: "succeeded", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + + // Step 6: Mock /oauth/v2.0/token - returns registration_required error + .mockResolvedValueOnce({ + status: 400, + json: async () => { + return { + error: "invalid_grant", + error_description: + "Multi-factor authentication is required.", + suberror: "mfa_required", + continuation_token: "mfa-continuation-token", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }) + // Step 7: Mock /oauth2/introspect - return available methods + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + continuation_token: "method-selection-token", + methods: [ + { + id: "email-method-id", + challenge_type: "oob", + challenge_channel: "email", + login_hint: "jo**@co***so.com", + }, + { + id: "sms-method-id", + challenge_type: "oob", + challenge_channel: "sms", + login_hint: "+1***5678", + }, + ], + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Step 8: Mock /oauth2/challenge - MFA challenge request + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + continuation_token: "mfa-challenge-token", + challenge_type: "oob", + challenge_channel: "email", + challenge_target_label: "jo**@co***so.com", + code_length: 6, + binding_method: "prompt", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Step 9: Mock /oauth2/token - successful MFA completion + .mockResolvedValueOnce({ + status: 200, + json: async () => TestServerTokenResponse, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + const resetPasswordInputs = { + username: "test@test.com", + correlationId: correlationId, + }; + + // Complete reset password flow + const startResult = await app.resetPassword(resetPasswordInputs); + expect(startResult.isCodeRequired()).toBe(true); + + const submitCodeResult = await ( + startResult.state as ResetPasswordCodeRequiredState + ).submitCode("12345678"); + expect(submitCodeResult.isPasswordRequired()).toBe(true); + + const submitPasswordResult = await ( + submitCodeResult.state as ResetPasswordPasswordRequiredState + ).submitNewPassword("valid-password"); + expect(submitPasswordResult.isCompleted()).toBe(true); + + // Attempt sign in - should trigger MFA + const signInResult = await ( + submitPasswordResult.state as ResetPasswordCompletedState + ).signIn(); + + // Verify MFA is required + expect(signInResult).toBeInstanceOf(SignInResult); + expect(signInResult.error).toBeUndefined(); + expect(signInResult.isMfaRequired()).toBe(true); + + const mfaState = signInResult.state as MfaAwaitingState; + + // Request MFA challenge + const requestChallengeResult = await mfaState.requestChallenge( + "email-method-id" + ); + + expect(requestChallengeResult).toBeInstanceOf( + MfaRequestChallengeResult + ); + expect(requestChallengeResult.error).toBeUndefined(); + expect(requestChallengeResult.isVerificationRequired()).toBe(true); + expect(requestChallengeResult.state).toBeInstanceOf( + MfaVerificationRequiredState + ); + + const verificationState = + requestChallengeResult.state as MfaVerificationRequiredState; + + // Verify MFA verification state properties + expect(verificationState.getChannel()).toBe("email"); + expect(verificationState.getSentTo()).toBe("jo**@co***so.com"); + expect(verificationState.getCodeLength()).toBe(6); + + // Submit MFA challenge + const submitChallengeResult = await verificationState.submitChallenge( + "123456" + ); + + expect(submitChallengeResult).toBeInstanceOf(MfaSubmitChallengeResult); + expect(submitChallengeResult.error).toBeUndefined(); + expect(submitChallengeResult.isCompleted()).toBe(true); + expect(submitChallengeResult.data).toBeInstanceOf( + CustomAuthAccountData + ); + }); + + it("should handle MFA required with SMS during reset password flow sign in", async () => { + (fetch as jest.Mock) + // Step 1: Mock /resetpassword/v1.0/start - successful start + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-1", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Step 2: Mock /resetpassword/v1.0/challenge - successful challenge + .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, + }) + // Step 3: Mock /resetpassword/v1.0/continue - submit code successfully + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-3", + expires_in: 600, + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Step 4: Mock /resetpassword/v1.0/submit - successful submit password + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-4", + poll_interval: 1, + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Step 5: Mock /resetpassword/v1.0/poll_completion - poll and succeeded + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + continuation_token: "test-continuation-token-5", + status: "succeeded", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + + // Step 6: Mock /oauth/v2.0/token - returns registration_required error + .mockResolvedValueOnce({ + status: 400, + json: async () => { + return { + error: "invalid_grant", + error_description: + "Multi-factor authentication is required.", + suberror: "mfa_required", + continuation_token: "mfa-continuation-token", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }) + // Step 7: Mock /oauth2/introspect - return available methods + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + continuation_token: "method-selection-token", + methods: [ + { + id: "email-method-id", + challenge_type: "oob", + challenge_channel: "email", + login_hint: "jo**@co***so.com", + }, + { + id: "sms-method-id", + challenge_type: "oob", + challenge_channel: "sms", + login_hint: "+1***5678", + }, + ], + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Step 8: Mock /oauth2/challenge - MFA challenge request + .mockResolvedValueOnce({ + status: 200, + json: async () => { + return { + correlation_id: correlationId, + continuation_token: "mfa-challenge-token", + challenge_type: "oob", + challenge_channel: "sms", + challenge_target_label: "000000000", + code_length: 6, + binding_method: "prompt", + }; + }, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }) + // Step 9: Mock /oauth2/token - successful MFA completion + .mockResolvedValueOnce({ + status: 200, + json: async () => TestServerTokenResponse, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + const resetPasswordInputs = { + username: "test@test.com", + correlationId: correlationId, + }; + + // Complete reset password flow + const startResult = await app.resetPassword(resetPasswordInputs); + expect(startResult.isCodeRequired()).toBe(true); + + const submitCodeResult = await ( + startResult.state as ResetPasswordCodeRequiredState + ).submitCode("12345678"); + expect(submitCodeResult.isPasswordRequired()).toBe(true); + + const submitPasswordResult = await ( + submitCodeResult.state as ResetPasswordPasswordRequiredState + ).submitNewPassword("valid-password"); + expect(submitPasswordResult.isCompleted()).toBe(true); + + // Attempt sign in - should trigger MFA + const signInResult = await ( + submitPasswordResult.state as ResetPasswordCompletedState + ).signIn(); + + // Verify MFA is required + expect(signInResult).toBeInstanceOf(SignInResult); + expect(signInResult.error).toBeUndefined(); + expect(signInResult.isMfaRequired()).toBe(true); + + const mfaState = signInResult.state as MfaAwaitingState; + + // Request MFA challenge + const requestChallengeResult = await mfaState.requestChallenge( + "sms-method-id" + ); + + expect(requestChallengeResult).toBeInstanceOf( + MfaRequestChallengeResult + ); + expect(requestChallengeResult.error).toBeUndefined(); + expect(requestChallengeResult.isVerificationRequired()).toBe(true); + expect(requestChallengeResult.state).toBeInstanceOf( + MfaVerificationRequiredState + ); + + const verificationState = + requestChallengeResult.state as MfaVerificationRequiredState; + + // Verify MFA verification state properties + expect(verificationState.getChannel()).toBe("sms"); + expect(verificationState.getSentTo()).toBe("000000000"); + expect(verificationState.getCodeLength()).toBe(6); + + // Submit MFA challenge + const submitChallengeResult = await verificationState.submitChallenge( + "123456" + ); + + expect(submitChallengeResult).toBeInstanceOf(MfaSubmitChallengeResult); + expect(submitChallengeResult.error).toBeUndefined(); + expect(submitChallengeResult.isCompleted()).toBe(true); + expect(submitChallengeResult.data).toBeInstanceOf( + CustomAuthAccountData + ); + }); }); 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 index fc7a37a5a1..0873fb5ef5 100644 --- a/lib/msal-browser/test/custom_auth/integration_tests/SignIn.spec.ts +++ b/lib/msal-browser/test/custom_auth/integration_tests/SignIn.spec.ts @@ -17,6 +17,12 @@ import { AuthMethodRegistrationRequiredState } from "../../../src/custom_auth/co import { AuthMethodVerificationRequiredState } from "../../../src/custom_auth/core/auth_flow/jit/state/AuthMethodRegistrationState.js"; import { AuthMethodRegistrationChallengeMethodResult } from "../../../src/custom_auth/core/auth_flow/jit/result/AuthMethodRegistrationChallengeMethodResult.js"; import { AuthMethodRegistrationSubmitChallengeResult } from "../../../src/custom_auth/core/auth_flow/jit/result/AuthMethodRegistrationSubmitChallengeResult.js"; +import { + MfaAwaitingState, + MfaVerificationRequiredState, +} from "../../../src/custom_auth/core/auth_flow/mfa/state/MfaState.js"; +import { MfaRequestChallengeResult } from "../../../src/custom_auth/core/auth_flow/mfa/result/MfaRequestChallengeResult.js"; +import { MfaSubmitChallengeResult } from "../../../src/custom_auth/core/auth_flow/mfa/result/MfaSubmitChallengeResult.js"; describe("Sign in", () => { let app: CustomAuthPublicClientApplication; @@ -599,7 +605,7 @@ describe("Sign in", () => { expect(submitResult.data).toBeInstanceOf(CustomAuthAccountData); }); - it("should handle JIT registration with fast-pass scenario (same email as sign-up)", async () => { + it("should handle JIT registration required after submitPassword() and complete flow with email verification", async () => { // Step 1: Mock /oauth2/initiate - successful initiate (fetch as jest.Mock).mockResolvedValueOnce({ status: 200, @@ -654,19 +660,22 @@ describe("Sign in", () => { ok: true, }); - // Step 5: Mock /register/v1.0/challenge - fast-pass (preverified) + // Step 5: Mock /register/v1.0/challenge - email challenge (fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: async () => ({ correlation_id: correlationId, continuation_token: "jit-challenge-token", - challenge_type: "preverified", + challenge_type: "oob", + challenge_channel: "email", + challenge_target: "us**@co***so.com", + code_length: 6, }), headers: new Headers({ "content-type": "application/json" }), ok: true, }); - // Step 6: Mock /register/v1.0/continue - fast-pass registration + // Step 6: Mock /register/v1.0/continue - verification successful (fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: async () => ({ @@ -676,7 +685,7 @@ describe("Sign in", () => { ok: true, }); - // Step 7: Mock /oauth2/token - successful completion after fast-pass JIT registration + // Step 7: Mock /oauth2/token - successful completion after JIT registration (fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: async () => TestServerTokenResponse, @@ -687,7 +696,6 @@ describe("Sign in", () => { // Start sign-in with password const signInInputs = { username: "test@test.com", - password: "password", correlationId: correlationId, }; @@ -696,26 +704,58 @@ describe("Sign in", () => { // Verify JIT registration is required expect(result).toBeInstanceOf(SignInResult); expect(result.error).toBeUndefined(); - expect(result.isAuthMethodRegistrationRequired()).toBe(true); + expect(result.isPasswordRequired()).toBe(true); - const jitState = result.state as AuthMethodRegistrationRequiredState; + const state = result.state as SignInPasswordRequiredState; + const submissionResult = await state.submitPassword("password"); - // Challenge the email authentication method (same as sign-up email) + expect(submissionResult.error).toBeUndefined(); + expect(submissionResult.isAuthMethodRegistrationRequired()).toBe(true); + + const jitState = + submissionResult.state as AuthMethodRegistrationRequiredState; + + // Verify available authentication methods + const authMethods = jitState.getAuthMethods(); + expect(authMethods).toHaveLength(1); + expect(authMethods[0].id).toBe("email"); + expect(authMethods[0].login_hint).toBe("user@contoso.com"); + + // Challenge the email authentication method const challengeResult = await jitState.challengeAuthMethod({ - authMethodType: jitState.getAuthMethods()[0], + authMethodType: authMethods[0], verificationContact: "user@contoso.com", }); - // Fast-pass should complete immediately expect(challengeResult).toBeInstanceOf( AuthMethodRegistrationChallengeMethodResult ); expect(challengeResult.error).toBeUndefined(); - expect(challengeResult.isCompleted()).toBe(true); - expect(challengeResult.data).toBeInstanceOf(CustomAuthAccountData); + expect(challengeResult.isVerificationRequired()).toBe(true); + expect(challengeResult.state).toBeInstanceOf( + AuthMethodVerificationRequiredState + ); + + const verificationState = + challengeResult.state as AuthMethodVerificationRequiredState; + + // Verify verification state properties + expect(verificationState.getChannel()).toBe("email"); + expect(verificationState.getSentTo()).toBe("us**@co***so.com"); + expect(verificationState.getCodeLength()).toBe(6); + + // Submit verification code + const submitResult = await verificationState.submitChallenge("123456"); + + expect(submitResult).toBeInstanceOf( + AuthMethodRegistrationSubmitChallengeResult + ); + expect(submitResult.error).toBeUndefined(); + expect(submitResult.isCompleted()).toBe(true); + expect(submitResult.data).toBeInstanceOf(CustomAuthAccountData); }); - it("should handle JIT registration error scenarios", async () => { + it("should handle JIT registration required after submitCode() and complete flow with email verification", async () => { // Step 1: Mock /oauth2/initiate - successful initiate (fetch as jest.Mock).mockResolvedValueOnce({ status: 200, @@ -728,13 +768,17 @@ describe("Sign in", () => { ok: true, }); - // Step 2: Mock /oauth2/challenge - password challenge + // Step 2: Mock /oauth2/challenge - oob challenge (fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: async () => ({ correlation_id: correlationId, continuation_token: "test-continuation-token-2", - challenge_type: "password", + challenge_type: "oob", + binding_method: "prompt", + challenge_channel: "", + challenge_target_label: "jo**@co***so.com", + code_length: 6, }), headers: new Headers({ "content-type": "application/json" }), ok: true, @@ -761,8 +805,8 @@ describe("Sign in", () => { continuation_token: "jit-introspect-token", methods: [ { - id: "email", - login_hint: "user@contoso.com", + id: "sms", + login_hint: "0000000000", }, ], }), @@ -770,72 +814,102 @@ describe("Sign in", () => { ok: true, }); - // Step 5: Mock /register/v1.0/challenge - email challenge + // Step 5: Mock /register/v1.0/challenge - sms challenge (fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: async () => ({ correlation_id: correlationId, continuation_token: "jit-challenge-token", challenge_type: "oob", - challenge_channel: "email", - challenge_target: "us**@co***so.com", + challenge_channel: "sms", + challenge_target: "0000000000", code_length: 6, }), headers: new Headers({ "content-type": "application/json" }), ok: true, }); - // Step 6: Mock /register/v1.0/continue - incorrect verification code + // Step 6: Mock /register/v1.0/continue - verification successful (fetch as jest.Mock).mockResolvedValueOnce({ - status: 400, + status: 200, json: async () => ({ - error: "invalid_grant", - error_description: "The verification code is incorrect.", - suberror: "incorrect_challenge", + continuation_token: "jit-verified-token", }), headers: new Headers({ "content-type": "application/json" }), - ok: false, + ok: true, + }); + + // Step 7: Mock /oauth2/token - successful completion after JIT registration + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => TestServerTokenResponse, + headers: new Headers({ "content-type": "application/json" }), + ok: true, }); // Start sign-in with password const signInInputs = { username: "test@test.com", - password: "password", correlationId: correlationId, }; const result = await app.signIn(signInInputs); // Verify JIT registration is required - expect(result.isAuthMethodRegistrationRequired()).toBe(true); + expect(result).toBeInstanceOf(SignInResult); + expect(result.error).toBeUndefined(); + expect(result.isCodeRequired()).toBe(true); - const jitState = result.state as AuthMethodRegistrationRequiredState; + const state = result.state as SignInCodeRequiredState; + const submissionResult = await state.submitCode("123456"); - // Challenge the email authentication method + expect(submissionResult.error).toBeUndefined(); + expect(submissionResult.isAuthMethodRegistrationRequired()).toBe(true); + + const jitState = + submissionResult.state as AuthMethodRegistrationRequiredState; + + // Verify available authentication methods + const authMethods = jitState.getAuthMethods(); + expect(authMethods).toHaveLength(1); + expect(authMethods[0].id).toBe("sms"); + expect(authMethods[0].login_hint).toBe("0000000000"); + + // Challenge the sms authentication method const challengeResult = await jitState.challengeAuthMethod({ - authMethodType: jitState.getAuthMethods()[0], - verificationContact: "user@contoso.com", + authMethodType: authMethods[0], + verificationContact: "0000000000", }); + expect(challengeResult).toBeInstanceOf( + AuthMethodRegistrationChallengeMethodResult + ); + expect(challengeResult.error).toBeUndefined(); expect(challengeResult.isVerificationRequired()).toBe(true); + expect(challengeResult.state).toBeInstanceOf( + AuthMethodVerificationRequiredState + ); const verificationState = challengeResult.state as AuthMethodVerificationRequiredState; - // Submit incorrect verification code - const submitResult = await verificationState.submitChallenge( - "wrong-code" - ); + // Verify verification state properties + expect(verificationState.getChannel()).toBe("sms"); + expect(verificationState.getSentTo()).toBe("0000000000"); + expect(verificationState.getCodeLength()).toBe(6); + + // Submit verification code + const submitResult = await verificationState.submitChallenge("123456"); expect(submitResult).toBeInstanceOf( AuthMethodRegistrationSubmitChallengeResult ); - expect(submitResult.error).toBeDefined(); - expect(submitResult.isFailed()).toBe(true); - expect(submitResult.error?.isIncorrectChallenge()).toBe(true); + expect(submitResult.error).toBeUndefined(); + expect(submitResult.isCompleted()).toBe(true); + expect(submitResult.data).toBeInstanceOf(CustomAuthAccountData); }); - it("should handle resending JIT verification code", async () => { + it("should handle JIT registration with fast-pass scenario (same email as sign-up)", async () => { // Step 1: Mock /oauth2/initiate - successful initiate (fetch as jest.Mock).mockResolvedValueOnce({ status: 200, @@ -890,27 +964,128 @@ describe("Sign in", () => { ok: true, }); - // Step 5: Mock /register/v1.0/challenge - initial email challenge + // Step 5: Mock /register/v1.0/challenge - fast-pass (preverified) (fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: async () => ({ correlation_id: correlationId, continuation_token: "jit-challenge-token", - challenge_type: "oob", - challenge_channel: "email", - challenge_target: "us**@co***so.com", - code_length: 6, + challenge_type: "preverified", }), headers: new Headers({ "content-type": "application/json" }), ok: true, }); - // Step 6: Mock /register/v1.0/challenge - resend challenge + // Step 6: Mock /register/v1.0/continue - fast-pass registration + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "jit-verified-token", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 7: Mock /oauth2/token - successful completion after fast-pass JIT registration + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => TestServerTokenResponse, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Start sign-in with password + const signInInputs = { + username: "test@test.com", + password: "password", + correlationId: correlationId, + }; + + const result = await app.signIn(signInInputs); + + // Verify JIT registration is required + expect(result).toBeInstanceOf(SignInResult); + expect(result.error).toBeUndefined(); + expect(result.isAuthMethodRegistrationRequired()).toBe(true); + + const jitState = result.state as AuthMethodRegistrationRequiredState; + + // Challenge the email authentication method (same as sign-up email) + const challengeResult = await jitState.challengeAuthMethod({ + authMethodType: jitState.getAuthMethods()[0], + verificationContact: "user@contoso.com", + }); + + // Fast-pass should complete immediately + expect(challengeResult).toBeInstanceOf( + AuthMethodRegistrationChallengeMethodResult + ); + expect(challengeResult.error).toBeUndefined(); + expect(challengeResult.isCompleted()).toBe(true); + expect(challengeResult.data).toBeInstanceOf(CustomAuthAccountData); + }); + + it("should handle JIT registration error scenarios", async () => { + // Step 1: Mock /oauth2/initiate - successful initiate (fetch as jest.Mock).mockResolvedValueOnce({ status: 200, json: async () => ({ correlation_id: correlationId, - continuation_token: "jit-resend-token", + continuation_token: "test-continuation-token-1", + challenge_type: "oob password redirect", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 2: Mock /oauth2/challenge - password challenge + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "test-continuation-token-2", + challenge_type: "password", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 3: Mock /oauth2/token - JIT registration required response + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 400, + json: async () => ({ + error: "invalid_grant", + error_description: + "Strong authentication method registration is required.", + suberror: "registration_required", + continuation_token: "jit-continuation-token", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }); + + // Step 4: Mock /register/v1.0/introspect - available authentication methods + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "jit-introspect-token", + methods: [ + { + id: "email", + login_hint: "user@contoso.com", + }, + ], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 5: Mock /register/v1.0/challenge - email challenge + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "jit-challenge-token", challenge_type: "oob", challenge_channel: "email", challenge_target: "us**@co***so.com", @@ -920,6 +1095,18 @@ describe("Sign in", () => { ok: true, }); + // Step 6: Mock /register/v1.0/continue - incorrect verification code + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 400, + json: async () => ({ + error: "invalid_grant", + error_description: "The verification code is incorrect.", + suberror: "incorrect_challenge", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }); + // Start sign-in with password const signInInputs = { username: "test@test.com", @@ -945,13 +1132,136 @@ describe("Sign in", () => { const verificationState = challengeResult.state as AuthMethodVerificationRequiredState; - // Resend the challenge (equivalent to calling challengeAuthMethod again) - const resendResult = await verificationState.challengeAuthMethod({ - authMethodType: jitState.getAuthMethods()[0], - verificationContact: "user@contoso.com", - }); + // Submit incorrect verification code + const submitResult = await verificationState.submitChallenge( + "wrong-code" + ); - expect(resendResult).toBeInstanceOf( + expect(submitResult).toBeInstanceOf( + AuthMethodRegistrationSubmitChallengeResult + ); + expect(submitResult.error).toBeDefined(); + expect(submitResult.isFailed()).toBe(true); + expect(submitResult.error?.isIncorrectChallenge()).toBe(true); + }); + + it("should handle resending JIT verification code", async () => { + // Step 1: Mock /oauth2/initiate - successful initiate + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "test-continuation-token-1", + challenge_type: "oob password redirect", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 2: Mock /oauth2/challenge - password challenge + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "test-continuation-token-2", + challenge_type: "password", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 3: Mock /oauth2/token - JIT registration required response + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 400, + json: async () => ({ + error: "invalid_grant", + error_description: + "Strong authentication method registration is required.", + suberror: "registration_required", + continuation_token: "jit-continuation-token", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }); + + // Step 4: Mock /register/v1.0/introspect - available authentication methods + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "jit-introspect-token", + methods: [ + { + id: "email", + login_hint: "user@contoso.com", + }, + ], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 5: Mock /register/v1.0/challenge - initial email challenge + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "jit-challenge-token", + challenge_type: "oob", + challenge_channel: "email", + challenge_target: "us**@co***so.com", + code_length: 6, + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 6: Mock /register/v1.0/challenge - resend challenge + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "jit-resend-token", + challenge_type: "oob", + challenge_channel: "email", + challenge_target: "us**@co***so.com", + code_length: 6, + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Start sign-in with password + const signInInputs = { + username: "test@test.com", + password: "password", + correlationId: correlationId, + }; + + const result = await app.signIn(signInInputs); + + // Verify JIT registration is required + expect(result.isAuthMethodRegistrationRequired()).toBe(true); + + const jitState = result.state as AuthMethodRegistrationRequiredState; + + // Challenge the email authentication method + const challengeResult = await jitState.challengeAuthMethod({ + authMethodType: jitState.getAuthMethods()[0], + verificationContact: "user@contoso.com", + }); + + expect(challengeResult.isVerificationRequired()).toBe(true); + + const verificationState = + challengeResult.state as AuthMethodVerificationRequiredState; + + // Resend the challenge (equivalent to calling challengeAuthMethod again) + const resendResult = await verificationState.challengeAuthMethod({ + authMethodType: jitState.getAuthMethods()[0], + verificationContact: "user@contoso.com", + }); + + expect(resendResult).toBeInstanceOf( AuthMethodRegistrationChallengeMethodResult ); expect(resendResult.error).toBeUndefined(); @@ -966,4 +1276,819 @@ describe("Sign in", () => { expect(newVerificationState.getSentTo()).toBe("us**@co***so.com"); expect(newVerificationState.getCodeLength()).toBe(6); }); + + it("should handle MFA required after signIn() and complete MFA flow", async () => { + // Step 1: Mock /oauth2/initiate - successful initiate + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "test-continuation-token-1", + challenge_type: "oob password redirect", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 2: Mock /oauth2/challenge - password challenge + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "test-continuation-token-2", + challenge_type: "password", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 3: Mock /oauth2/token - MFA required response + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 400, + json: async () => ({ + error: "invalid_grant", + error_description: "Multi-factor authentication is required.", + suberror: "mfa_required", + continuation_token: "mfa-continuation-token", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }); + + // Step 4: Mock /oauth2/introspect - return available methods + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "method-selection-token", + methods: [ + { + id: "email-method-id", + challenge_type: "oob", + challenge_channel: "email", + login_hint: "jo**@co***so.com", + }, + { + id: "sms-method-id", + challenge_type: "oob", + challenge_channel: "sms", + login_hint: "+1***5678", + }, + ], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 5: Mock /oauth2/challenge - MFA challenge request + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "mfa-challenge-token", + challenge_type: "oob", + challenge_channel: "email", + challenge_target_label: "jo**@co***so.com", + code_length: 6, + binding_method: "prompt", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 6: Mock /oauth2/token - successful MFA completion + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => TestServerTokenResponse, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Start sign-in with password + const signInInputs = { + username: "test@test.com", + password: "password", + correlationId: correlationId, + }; + + const result = await app.signIn(signInInputs); + + // Verify MFA is required + expect(result).toBeInstanceOf(SignInResult); + expect(result.error).toBeUndefined(); + expect(result.isMfaRequired()).toBe(true); + expect(result.state).toBeInstanceOf(MfaAwaitingState); + + const mfaState = result.state as MfaAwaitingState; + + // Request MFA challenge + const requestChallengeResult = await mfaState.requestChallenge( + "email-method-id" + ); + + expect(requestChallengeResult).toBeInstanceOf( + MfaRequestChallengeResult + ); + expect(requestChallengeResult.error).toBeUndefined(); + expect(requestChallengeResult.isVerificationRequired()).toBe(true); + expect(requestChallengeResult.state).toBeInstanceOf( + MfaVerificationRequiredState + ); + + const verificationState = + requestChallengeResult.state as MfaVerificationRequiredState; + + // Verify MFA verification state properties + expect(verificationState.getChannel()).toBe("email"); + expect(verificationState.getSentTo()).toBe("jo**@co***so.com"); + expect(verificationState.getCodeLength()).toBe(6); + + // Submit MFA challenge + const submitChallengeResult = await verificationState.submitChallenge( + "123456" + ); + + expect(submitChallengeResult).toBeInstanceOf(MfaSubmitChallengeResult); + expect(submitChallengeResult.error).toBeUndefined(); + expect(submitChallengeResult.isCompleted()).toBe(true); + expect(submitChallengeResult.data).toBeInstanceOf( + CustomAuthAccountData + ); + }); + + it("should handle MFA required after submitCode() and complete flow", async () => { + // Step 1: Mock /oauth2/initiate - successful initiate + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "test-continuation-token-1", + challenge_type: "oob password redirect", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 2: Mock /oauth2/challenge - oob challenge + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "test-continuation-token-2", + challenge_type: "oob", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 3: Mock /oauth2/token - MFA required response + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 400, + json: async () => ({ + error: "invalid_grant", + error_description: "Multi-factor authentication is required.", + suberror: "mfa_required", + continuation_token: "mfa-continuation-token", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }); + + // Step 4: Mock /oauth2/introspect - return available methods + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "method-selection-token", + methods: [ + { + id: "01488-13...", + challenge_type: "oob", + challenge_channel: "email", + login_hint: "jo**@co***so.com", + }, + { + id: "01489-14...", + challenge_type: "oob", + challenge_channel: "sms", + login_hint: "+1***5678", + }, + ], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 5: Mock /oauth2/challenge - MFA challenge request + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "mfa-challenge-token", + challenge_type: "oob", + challenge_channel: "sms", + challenge_target_label: "000000***0000", + code_length: 6, + binding_method: "prompt", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 6: Mock /oauth2/token - successful MFA completion + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => TestServerTokenResponse, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Start sign-in without password + const signInInputs = { + username: "test@test.com", + correlationId: correlationId, + }; + + const signInResult = await app.signIn(signInInputs); + + // Verify password is required + expect(signInResult).toBeInstanceOf(SignInResult); + expect(signInResult.error).toBeUndefined(); + expect(signInResult.isCodeRequired()).toBe(true); + + const codeState = signInResult.state as SignInCodeRequiredState; + + // Submit code - should trigger MFA + const submitCodeResult = await codeState.submitCode("123456"); + + // Verify MFA is required after code submission + expect(submitCodeResult).toBeInstanceOf(SignInSubmitCodeResult); + expect(submitCodeResult.error).toBeUndefined(); + expect(submitCodeResult.isMfaRequired()).toBe(true); + expect(submitCodeResult.state).toBeInstanceOf(MfaAwaitingState); + + const mfaState = submitCodeResult.state as MfaAwaitingState; + + // Request MFA challenge + const requestChallengeResult = await mfaState.requestChallenge( + "sms-method-id" + ); + + expect(requestChallengeResult).toBeInstanceOf( + MfaRequestChallengeResult + ); + expect(requestChallengeResult.error).toBeUndefined(); + expect(requestChallengeResult.isVerificationRequired()).toBe(true); + + const verificationState = + requestChallengeResult.state as MfaVerificationRequiredState; + + // Submit MFA challenge + const submitChallengeResult = await verificationState.submitChallenge( + "123456" + ); + + expect(submitChallengeResult).toBeInstanceOf(MfaSubmitChallengeResult); + expect(submitChallengeResult.error).toBeUndefined(); + expect(submitChallengeResult.isCompleted()).toBe(true); + expect(submitChallengeResult.data).toBeInstanceOf( + CustomAuthAccountData + ); + }); + + it("should handle MFA required after submitPassword() and complete flow", async () => { + // Step 1: Mock /oauth2/initiate - successful initiate + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "test-continuation-token-1", + challenge_type: "oob password redirect", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 2: Mock /oauth2/challenge - password challenge + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "test-continuation-token-2", + challenge_type: "password", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 3: Mock /oauth2/token - MFA required response + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 400, + json: async () => ({ + error: "invalid_grant", + error_description: "Multi-factor authentication is required.", + suberror: "mfa_required", + continuation_token: "mfa-continuation-token", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }); + + // Step 4: Mock /oauth2/introspect - return available methods + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "method-selection-token", + methods: [ + { + id: "01488-13...", + challenge_type: "oob", + challenge_channel: "email", + login_hint: "jo**@co***so.com", + }, + { + id: "01489-14...", + challenge_type: "oob", + challenge_channel: "sms", + login_hint: "+1***5678", + }, + ], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 5: Mock /oauth2/challenge - MFA challenge request + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "mfa-challenge-token", + challenge_type: "oob", + challenge_channel: "email", + challenge_target_label: "jo**@co***so.com", + code_length: 6, + binding_method: "prompt", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 6: Mock /oauth2/token - successful MFA completion + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => TestServerTokenResponse, + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Start sign-in without password + const signInInputs = { + username: "test@test.com", + correlationId: correlationId, + }; + + const signInResult = await app.signIn(signInInputs); + + // Verify password is required + expect(signInResult).toBeInstanceOf(SignInResult); + expect(signInResult.error).toBeUndefined(); + expect(signInResult.isPasswordRequired()).toBe(true); + + const passwordState = signInResult.state as SignInPasswordRequiredState; + + // Submit password - should trigger MFA + const submitPasswordResult = await passwordState.submitPassword( + "password" + ); + + // Verify MFA is required after password submission + expect(submitPasswordResult).toBeInstanceOf(SignInSubmitPasswordResult); + expect(submitPasswordResult.error).toBeUndefined(); + expect(submitPasswordResult.isMfaRequired()).toBe(true); + expect(submitPasswordResult.state).toBeInstanceOf(MfaAwaitingState); + + const mfaState = submitPasswordResult.state as MfaAwaitingState; + + // Request MFA challenge + const requestChallengeResult = await mfaState.requestChallenge( + "email-method-id" + ); + + expect(requestChallengeResult).toBeInstanceOf( + MfaRequestChallengeResult + ); + expect(requestChallengeResult.error).toBeUndefined(); + expect(requestChallengeResult.isVerificationRequired()).toBe(true); + + const verificationState = + requestChallengeResult.state as MfaVerificationRequiredState; + + // Submit MFA challenge + const submitChallengeResult = await verificationState.submitChallenge( + "123456" + ); + + expect(submitChallengeResult).toBeInstanceOf(MfaSubmitChallengeResult); + expect(submitChallengeResult.error).toBeUndefined(); + expect(submitChallengeResult.isCompleted()).toBe(true); + expect(submitChallengeResult.data).toBeInstanceOf( + CustomAuthAccountData + ); + + // Clean up + submitChallengeResult.data?.signOut(); + }); + + it("should handle MFA errors - invalid MFA code", async () => { + // Step 1: Mock /oauth2/initiate - successful initiate + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "test-continuation-token-1", + challenge_type: "oob password redirect", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 2: Mock /oauth2/challenge - password challenge + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "test-continuation-token-2", + challenge_type: "password", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 3: Mock /oauth2/token - MFA required response + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 400, + json: async () => ({ + error: "invalid_grant", + error_description: "Multi-factor authentication is required.", + suberror: "mfa_required", + continuation_token: "mfa-continuation-token", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }); + + // Step 4: Mock /oauth2/introspect - return available methods + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "method-selection-token", + methods: [ + { + id: "email-method-id", + challenge_type: "oob", + challenge_channel: "email", + login_hint: "jo**@co***so.com", + }, + { + id: "sms-method-id", + challenge_type: "oob", + challenge_channel: "sms", + login_hint: "+1***5678", + }, + ], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 5: Mock /oauth2/challenge - MFA challenge request + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "mfa-challenge-token", + challenge_type: "oob", + challenge_channel: "email", + challenge_target_label: "jo**@co***so.com", + code_length: 6, + binding_method: "prompt", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 6: Mock /oauth2/token - invalid MFA code response + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 400, + json: async () => ({ + error: "invalid_grant", + error_description: "The verification code is incorrect.", + suberror: "invalid_oob_value", + error_codes: [50125], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }); + + // Start sign-in with password + const signInInputs = { + username: "test@test.com", + password: "password", + correlationId: correlationId, + }; + + const result = await app.signIn(signInInputs); + + // Verify MFA is required + expect(result).toBeInstanceOf(SignInResult); + expect(result.error).toBeUndefined(); + expect(result.isMfaRequired()).toBe(true); + expect(result.state).toBeInstanceOf(MfaAwaitingState); + + const mfaState = result.state as MfaAwaitingState; + + // Request MFA challenge + const requestChallengeResult = await mfaState.requestChallenge( + "email-method-id" + ); + + expect(requestChallengeResult).toBeInstanceOf( + MfaRequestChallengeResult + ); + expect(requestChallengeResult.error).toBeUndefined(); + expect(requestChallengeResult.isVerificationRequired()).toBe(true); + expect(requestChallengeResult.state).toBeInstanceOf( + MfaVerificationRequiredState + ); + + const verificationState = + requestChallengeResult.state as MfaVerificationRequiredState; + + // Verify MFA verification state properties + expect(verificationState.getChannel()).toBe("email"); + expect(verificationState.getSentTo()).toBe("jo**@co***so.com"); + expect(verificationState.getCodeLength()).toBe(6); + + // Submit MFA challenge + const submitChallengeResult = await verificationState.submitChallenge( + "000000" + ); + + expect(submitChallengeResult).toBeInstanceOf(MfaSubmitChallengeResult); + expect(submitChallengeResult.error).toBeDefined(); + expect(submitChallengeResult.isFailed()).toBe(true); + expect(submitChallengeResult.error?.isIncorrectChallenge()).toBe(true); + }); + + it("should handle MFA errors - challenge request failure", async () => { + // Step 1: Mock /oauth2/initiate - successful initiate + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "test-continuation-token-1", + challenge_type: "oob password redirect", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 2: Mock /oauth2/challenge - password challenge + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "test-continuation-token-2", + challenge_type: "password", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 3: Mock /oauth2/token - MFA required response + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 400, + json: async () => ({ + error: "invalid_grant", + error_description: "Multi-factor authentication is required.", + suberror: "mfa_required", + continuation_token: "mfa-continuation-token", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }); + + // Step 4: Mock /oauth2/introspect - return available methods + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "method-selection-token", + methods: [ + { + id: "email-method-id", + challenge_type: "oob", + challenge_channel: "email", + login_hint: "jo**@co***so.com", + }, + { + id: "sms-method-id", + challenge_type: "oob", + challenge_channel: "sms", + login_hint: "+1***5678", + }, + ], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 5: Mock /oauth2/challenge - challenge request failure + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 400, + json: async () => ({ + error: "invalid_request", + error_description: "Failed to send challenge.", + error_codes: [90210], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }); + + // Start sign-in with password + const signInInputs = { + username: "test@test.com", + password: "password", + correlationId: correlationId, + }; + + const result = await app.signIn(signInInputs); + + // Verify MFA is required + expect(result).toBeInstanceOf(SignInResult); + expect(result.error).toBeUndefined(); + expect(result.isMfaRequired()).toBe(true); + expect(result.state).toBeInstanceOf(MfaAwaitingState); + + const mfaState = result.state as MfaAwaitingState; + + // Request MFA challenge + const requestChallengeResult = await mfaState.requestChallenge( + "email-method-id" + ); + + expect(requestChallengeResult).toBeInstanceOf( + MfaRequestChallengeResult + ); + expect(requestChallengeResult.error).toBeDefined(); + expect(requestChallengeResult.isFailed()).toBe(true); + expect(requestChallengeResult.error?.errorData?.error).toBe( + "invalid_request" + ); + }); + + it("should handle resend challenge in MfaVerificationRequiredState", async () => { + // Step 1: Mock /oauth2/initiate - successful initiate + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "test-continuation-token-1", + challenge_type: "oob password redirect", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 2: Mock /oauth2/challenge - password challenge + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "test-continuation-token-2", + challenge_type: "password", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 3: Mock /oauth2/token - MFA required response + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 400, + json: async () => ({ + error: "invalid_grant", + error_description: "Multi-factor authentication is required.", + suberror: "mfa_required", + continuation_token: "mfa-continuation-token", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }); + + // Step 4: Mock /oauth2/introspect - return available methods + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "method-selection-token", + methods: [ + { + id: "email-method-id", + challenge_type: "oob", + challenge_channel: "email", + login_hint: "jo**@co***so.com", + }, + { + id: "sms-method-id", + challenge_type: "oob", + challenge_channel: "sms", + login_hint: "+1***5678", + }, + ], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 5: Mock /oauth2/challenge - initial MFA challenge request + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "mfa-challenge-token", + challenge_type: "oob", + challenge_channel: "email", + challenge_target_label: "jo**@co***so.com", + code_length: 6, + binding_method: "prompt", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 6: Mock /oauth2/challenge - resend challenge request + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "mfa-resend-token", + challenge_type: "oob", + challenge_channel: "email", + challenge_target_label: "jo**@co***so.com", + code_length: 6, + binding_method: "prompt", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Start sign-in with password + const signInInputs = { + username: "test@test.com", + password: "password", + correlationId: correlationId, + }; + + const result = await app.signIn(signInInputs); + + // Verify MFA is required + expect(result).toBeInstanceOf(SignInResult); + expect(result.error).toBeUndefined(); + expect(result.isMfaRequired()).toBe(true); + + const mfaState = result.state as MfaAwaitingState; + + // Request MFA challenge + const requestChallengeResult = await mfaState.requestChallenge( + "email-method-id" + ); + + expect(requestChallengeResult).toBeInstanceOf( + MfaRequestChallengeResult + ); + expect(requestChallengeResult.error).toBeUndefined(); + expect(requestChallengeResult.isVerificationRequired()).toBe(true); + + const verificationState = + requestChallengeResult.state as MfaVerificationRequiredState; + + // Resend challenge (equivalent to calling requestChallenge again) + const resendResult = await verificationState.requestChallenge( + "email-method-id" + ); + + expect(resendResult).toBeInstanceOf(MfaRequestChallengeResult); + expect(resendResult.error).toBeUndefined(); + expect(resendResult.isVerificationRequired()).toBe(true); + expect(resendResult.state).toBeInstanceOf(MfaVerificationRequiredState); + + const newVerificationState = + resendResult.state as MfaVerificationRequiredState; + expect(newVerificationState.getChannel()).toBe("email"); + expect(newVerificationState.getSentTo()).toBe("jo**@co***so.com"); + expect(newVerificationState.getCodeLength()).toBe(6); + }); }); 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 index 7e34cd6c2b..8d924166a7 100644 --- a/lib/msal-browser/test/custom_auth/integration_tests/SignUp.spec.ts +++ b/lib/msal-browser/test/custom_auth/integration_tests/SignUp.spec.ts @@ -23,6 +23,12 @@ import { AuthMethodRegistrationRequiredState } from "../../../src/custom_auth/co import { AuthMethodVerificationRequiredState } from "../../../src/custom_auth/core/auth_flow/jit/state/AuthMethodRegistrationState.js"; import { AuthMethodRegistrationChallengeMethodResult } from "../../../src/custom_auth/core/auth_flow/jit/result/AuthMethodRegistrationChallengeMethodResult.js"; import { AuthMethodRegistrationSubmitChallengeResult } from "../../../src/custom_auth/core/auth_flow/jit/result/AuthMethodRegistrationSubmitChallengeResult.js"; +import { + MfaAwaitingState, + MfaVerificationRequiredState, +} from "../../../src/custom_auth/core/auth_flow/mfa/state/MfaState.js"; +import { MfaRequestChallengeResult } from "../../../src/custom_auth/core/auth_flow/mfa/result/MfaRequestChallengeResult.js"; +import { MfaSubmitChallengeResult } from "../../../src/custom_auth/core/auth_flow/mfa/result/MfaSubmitChallengeResult.js"; describe("Sign up", () => { let app: CustomAuthPublicClientApplication; @@ -977,6 +983,209 @@ describe("Sign up", () => { ); }); + it("should handle JIT registration required after signUp() completion and complete flow with SMS verification", async () => { + // Step 1: Mock /signup/v1.0/start - successful start + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-1", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 2: Mock /signup/v1.0/challenge - email challenge + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-2", + challenge_type: "oob", + binding_method: "prompt", + challenge_channel: "email", + challenge_target_label: "te**@te**.com", + code_length: 8, + interval: 300, + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 3: Mock /signup/v1.0/continue - code submission (requires password) + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 400, + json: async () => ({ + continuation_token: "test-continuation-token-3", + error: "credential_required", + error_description: "Credential required.", + error_codes: [55103], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }); + + // Step 4: Mock /signup/v1.0/challenge - password requirement + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-4", + challenge_type: "password", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 5: Mock /signup/v1.0/continue - password submission (signup complete) + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "signup-completion-token", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 6: Mock /oauth2/token - JIT registration required during signIn after signup + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 400, + json: async () => ({ + error: "invalid_grant", + error_description: + "Strong authentication method registration is required.", + suberror: "registration_required", + continuation_token: "jit-continuation-token", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }); + + // Step 7: Mock /register/v1.0/introspect - available authentication methods + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "jit-introspect-token", + methods: [ + { + id: "sms", + login_hint: "0000000000", + }, + ], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 8: Mock /register/v1.0/challenge - email challenge + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "jit-challenge-token", + challenge_type: "oob", + challenge_channel: "sms", + challenge_target: "0000000000", + code_length: 6, + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 9: Mock /register/v1.0/continue - challenge submission + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "jit-verified-token", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 10: Mock /oauth2/token - successful completion after JIT registration + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => TestServerTokenResponse, + 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, + }; + + // Start SignUp flow + const startResult = await app.signUp(signUpInputs); + expect(startResult).toBeInstanceOf(SignUpResult); + expect(startResult.error).toBeUndefined(); + expect(startResult.isCodeRequired()).toBe(true); + + // Submit code + const submitCodeResult = await ( + startResult.state as SignUpCodeRequiredState + ).submitCode("12345678"); + expect(submitCodeResult).toBeInstanceOf(SignUpSubmitCodeResult); + expect(submitCodeResult.error).toBeUndefined(); + expect(submitCodeResult.isPasswordRequired()).toBe(true); + + // Submit password + const submitPasswordResult = await ( + submitCodeResult.state as SignUpPasswordRequiredState + ).submitPassword("valid-password"); + expect(submitPasswordResult).toBeInstanceOf(SignUpSubmitPasswordResult); + expect(submitPasswordResult.error).toBeUndefined(); + expect(submitPasswordResult.isCompleted()).toBe(true); + + // SignIn after signup completion - should trigger JIT + const signInResult = await ( + submitPasswordResult.state as SignUpCompletedState + ).signIn(); + + // Verify JIT registration is required + expect(signInResult).toBeInstanceOf(SignInResult); + expect(signInResult.error).toBeUndefined(); + expect(signInResult.isAuthMethodRegistrationRequired()).toBe(true); + + const jitState = + signInResult.state as AuthMethodRegistrationRequiredState; + expect(jitState.getAuthMethods()).toHaveLength(1); + expect(jitState.getAuthMethods()[0].id).toBe("sms"); + + // Challenge the sms authentication method + const challengeResult = await jitState.challengeAuthMethod({ + authMethodType: jitState.getAuthMethods()[0], + verificationContact: "0000000000", + }); + + expect(challengeResult).toBeInstanceOf( + AuthMethodRegistrationChallengeMethodResult + ); + expect(challengeResult.error).toBeUndefined(); + expect(challengeResult.isVerificationRequired()).toBe(true); + + const verificationState = + challengeResult.state as AuthMethodVerificationRequiredState; + expect(verificationState.getChannel()).toBe("sms"); + expect(verificationState.getSentTo()).toBe("0000000000"); + expect(verificationState.getCodeLength()).toBe(6); + + // Submit verification code + const submitResult = await verificationState.submitChallenge("123456"); + + expect(submitResult).toBeInstanceOf( + AuthMethodRegistrationSubmitChallengeResult + ); + expect(submitResult.error).toBeUndefined(); + expect(submitResult.isCompleted()).toBe(true); + expect(submitResult.data).toBeInstanceOf(CustomAuthAccountData); + expect(submitResult.data?.getAccount()?.idToken).toStrictEqual( + TestServerTokenResponse.id_token + ); + }); + it("should handle JIT registration with fast-pass scenario (same email as sign-up)", async () => { // Setup basic signup flow (fetch as jest.Mock) @@ -1727,4 +1936,408 @@ describe("Sign up", () => { expect(challengeResult.isFailed()).toBe(true); expect(challengeResult.error?.isInvalidInput()).toBe(true); }); + + it("should handle MFA required after signUp() completion and complete flow with email verification", async () => { + // Step 1: Mock /signup/v1.0/start - successful start + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-1", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 2: Mock /signup/v1.0/challenge - email challenge + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-2", + challenge_type: "oob", + binding_method: "prompt", + challenge_channel: "email", + challenge_target_label: "te**@te**.com", + code_length: 8, + interval: 300, + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 3: Mock /signup/v1.0/continue - code submission (requires password) + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 400, + json: async () => ({ + continuation_token: "test-continuation-token-3", + error: "credential_required", + error_description: "Credential required.", + error_codes: [55103], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }); + + // Step 4: Mock /signup/v1.0/challenge - password requirement + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-4", + challenge_type: "password", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 5: Mock /signup/v1.0/continue - password submission (signup complete) + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "signup-completion-token", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 6: Mock /oauth2/token - MFA required during signIn after signup + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 400, + json: async () => ({ + error: "invalid_grant", + error_description: "Multi-factor authentication is required.", + suberror: "mfa_required", + continuation_token: "mfa-continuation-token", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }); + + // Step 7: Mock /oauth2/introspect - return available methods + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "method-selection-token", + methods: [ + { + id: "email-method-id", + challenge_type: "oob", + challenge_channel: "email", + login_hint: "jo**@co***so.com", + }, + { + id: "sms-method-id", + challenge_type: "oob", + challenge_channel: "sms", + login_hint: "+1***5678", + }, + ], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 8: Mock /oauth2/challenge - MFA challenge request + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "mfa-challenge-token", + challenge_type: "oob", + challenge_channel: "email", + challenge_target_label: "jo**@co***so.com", + code_length: 6, + binding_method: "prompt", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 9: Mock /oauth2/token - successful MFA completion + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => TestServerTokenResponse, + 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, + }; + + // Start SignUp flow + const startResult = await app.signUp(signUpInputs); + expect(startResult).toBeInstanceOf(SignUpResult); + expect(startResult.error).toBeUndefined(); + expect(startResult.isCodeRequired()).toBe(true); + + // Submit code + const submitCodeResult = await ( + startResult.state as SignUpCodeRequiredState + ).submitCode("12345678"); + expect(submitCodeResult).toBeInstanceOf(SignUpSubmitCodeResult); + expect(submitCodeResult.error).toBeUndefined(); + expect(submitCodeResult.isPasswordRequired()).toBe(true); + + // Submit password + const submitPasswordResult = await ( + submitCodeResult.state as SignUpPasswordRequiredState + ).submitPassword("valid-password"); + expect(submitPasswordResult).toBeInstanceOf(SignUpSubmitPasswordResult); + expect(submitPasswordResult.error).toBeUndefined(); + expect(submitPasswordResult.isCompleted()).toBe(true); + + // SignIn after signup completion - should trigger MFA + const signInResult = await ( + submitPasswordResult.state as SignUpCompletedState + ).signIn(); + + // Verify MFA is required + expect(signInResult).toBeInstanceOf(SignInResult); + expect(signInResult.error).toBeUndefined(); + expect(signInResult.isMfaRequired()).toBe(true); + + const mfaState = signInResult.state as MfaAwaitingState; + + // Request MFA challenge + const requestChallengeResult = await mfaState.requestChallenge( + "email-method-id" + ); + + expect(requestChallengeResult).toBeInstanceOf( + MfaRequestChallengeResult + ); + expect(requestChallengeResult.error).toBeUndefined(); + expect(requestChallengeResult.isVerificationRequired()).toBe(true); + expect(requestChallengeResult.state).toBeInstanceOf( + MfaVerificationRequiredState + ); + + const verificationState = + requestChallengeResult.state as MfaVerificationRequiredState; + + // Verify MFA verification state properties + expect(verificationState.getChannel()).toBe("email"); + expect(verificationState.getSentTo()).toBe("jo**@co***so.com"); + expect(verificationState.getCodeLength()).toBe(6); + + // Submit MFA challenge + const submitChallengeResult = await verificationState.submitChallenge( + "123456" + ); + + expect(submitChallengeResult).toBeInstanceOf(MfaSubmitChallengeResult); + expect(submitChallengeResult.error).toBeUndefined(); + expect(submitChallengeResult.isCompleted()).toBe(true); + expect(submitChallengeResult.data).toBeInstanceOf( + CustomAuthAccountData + ); + }); + + it("should handle MFA required after signUp() completion and complete flow with SMS verification", async () => { + // Step 1: Mock /signup/v1.0/start - successful start + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-1", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 2: Mock /signup/v1.0/challenge - email challenge + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-2", + challenge_type: "oob", + binding_method: "prompt", + challenge_channel: "email", + challenge_target_label: "te**@te**.com", + code_length: 8, + interval: 300, + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 3: Mock /signup/v1.0/continue - code submission (requires password) + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 400, + json: async () => ({ + continuation_token: "test-continuation-token-3", + error: "credential_required", + error_description: "Credential required.", + error_codes: [55103], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }); + + // Step 4: Mock /signup/v1.0/challenge - password requirement + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "test-continuation-token-4", + challenge_type: "password", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 5: Mock /signup/v1.0/continue - password submission (signup complete) + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + continuation_token: "signup-completion-token", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 6: Mock /oauth2/token - MFA required during signIn after signup + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 400, + json: async () => ({ + error: "invalid_grant", + error_description: "Multi-factor authentication is required.", + suberror: "mfa_required", + continuation_token: "mfa-continuation-token", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: false, + }); + + // Step 7: Mock /oauth2/introspect - return available methods + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "method-selection-token", + methods: [ + { + id: "email-method-id", + challenge_type: "oob", + challenge_channel: "email", + login_hint: "jo**@co***so.com", + }, + { + id: "sms-method-id", + challenge_type: "oob", + challenge_channel: "sms", + login_hint: "+1***5678", + }, + ], + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 8: Mock /oauth2/challenge - MFA challenge request + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => ({ + correlation_id: correlationId, + continuation_token: "mfa-challenge-token", + challenge_type: "oob", + challenge_channel: "sms", + challenge_target_label: "0000000000", + code_length: 6, + binding_method: "prompt", + }), + headers: new Headers({ "content-type": "application/json" }), + ok: true, + }); + + // Step 9: Mock /oauth2/token - successful MFA completion + (fetch as jest.Mock).mockResolvedValueOnce({ + status: 200, + json: async () => TestServerTokenResponse, + 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, + }; + + // Start SignUp flow + const startResult = await app.signUp(signUpInputs); + expect(startResult).toBeInstanceOf(SignUpResult); + expect(startResult.error).toBeUndefined(); + expect(startResult.isCodeRequired()).toBe(true); + + // Submit code + const submitCodeResult = await ( + startResult.state as SignUpCodeRequiredState + ).submitCode("12345678"); + expect(submitCodeResult).toBeInstanceOf(SignUpSubmitCodeResult); + expect(submitCodeResult.error).toBeUndefined(); + expect(submitCodeResult.isPasswordRequired()).toBe(true); + + // Submit password + const submitPasswordResult = await ( + submitCodeResult.state as SignUpPasswordRequiredState + ).submitPassword("valid-password"); + expect(submitPasswordResult).toBeInstanceOf(SignUpSubmitPasswordResult); + expect(submitPasswordResult.error).toBeUndefined(); + expect(submitPasswordResult.isCompleted()).toBe(true); + + // SignIn after signup completion - should trigger MFA + const signInResult = await ( + submitPasswordResult.state as SignUpCompletedState + ).signIn(); + + // Verify MFA is required + expect(signInResult).toBeInstanceOf(SignInResult); + expect(signInResult.error).toBeUndefined(); + expect(signInResult.isMfaRequired()).toBe(true); + + const mfaState = signInResult.state as MfaAwaitingState; + + // Request MFA challenge + const requestChallengeResult = await mfaState.requestChallenge( + "sms-method-id" + ); + + expect(requestChallengeResult).toBeInstanceOf( + MfaRequestChallengeResult + ); + expect(requestChallengeResult.error).toBeUndefined(); + expect(requestChallengeResult.isVerificationRequired()).toBe(true); + expect(requestChallengeResult.state).toBeInstanceOf( + MfaVerificationRequiredState + ); + + const verificationState = + requestChallengeResult.state as MfaVerificationRequiredState; + + // Verify MFA verification state properties + expect(verificationState.getChannel()).toBe("sms"); + expect(verificationState.getSentTo()).toBe("0000000000"); + expect(verificationState.getCodeLength()).toBe(6); + + // Submit MFA challenge + const submitChallengeResult = await verificationState.submitChallenge( + "123456" + ); + + expect(submitChallengeResult).toBeInstanceOf(MfaSubmitChallengeResult); + expect(submitChallengeResult.error).toBeUndefined(); + expect(submitChallengeResult.isCompleted()).toBe(true); + expect(submitChallengeResult.data).toBeInstanceOf( + CustomAuthAccountData + ); + }); }); 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 index 64298d5bcc..38d2a6bc97 100644 --- 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 @@ -8,6 +8,7 @@ import { ResetPasswordClient } from "../../../../../src/custom_auth/reset_passwo 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 { JitClient } from "../../../../../src/custom_auth/core/interaction_client/jit/JitClient.js"; +import { MfaClient } from "../../../../../src/custom_auth/core/interaction_client/mfa/MfaClient.js"; import { getDefaultLogger } from "../../../test_resources/TestModules.js"; describe("ResetPasswordCodeRequiredState", () => { @@ -29,6 +30,11 @@ describe("ResetPasswordCodeRequiredState", () => { requestChallenge: jest.fn(), continueChallenge: jest.fn(), } as unknown as jest.Mocked; + const mockMfaClient = { + requestChallenge: jest.fn(), + submitChallenge: jest.fn(), + getAuthMethods: jest.fn(), + } as unknown as jest.Mocked; const username = "testuser"; const correlationId = "test-correlation-id"; @@ -45,6 +51,7 @@ describe("ResetPasswordCodeRequiredState", () => { resetPasswordClient: mockResetPasswordClient, signInClient: mockSignInClient, jitClient: mockJitClient, + mfaClient: mockMfaClient, cacheClient: {} as unknown as jest.Mocked, username: username, 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 index 4f15e29a3a..5f0c7f2aa3 100644 --- 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 @@ -8,6 +8,7 @@ import { CustomAuthSilentCacheClient } from "../../../../../src/custom_auth/get_ import { ResetPasswordPasswordRequiredState } from "../../../../../src/custom_auth/reset_password/auth_flow/state/ResetPasswordPasswordRequiredState.js"; import { CustomAuthApiError } from "../../../../../src/custom_auth/core/error/CustomAuthApiError.js"; import { JitClient } from "../../../../../src/custom_auth/core/interaction_client/jit/JitClient.js"; +import { MfaClient } from "../../../../../src/custom_auth/core/interaction_client/mfa/MfaClient.js"; import { getDefaultLogger } from "../../../test_resources/TestModules.js"; describe("ResetPasswordPasswordRequiredState", () => { @@ -28,6 +29,11 @@ describe("ResetPasswordPasswordRequiredState", () => { requestChallenge: jest.fn(), continueChallenge: jest.fn(), } as unknown as jest.Mocked; + const mockMfaClient = { + requestChallenge: jest.fn(), + submitChallenge: jest.fn(), + getAuthMethods: jest.fn(), + } as unknown as jest.Mocked; const username = "testuser"; const correlationId = "test-correlation-id"; @@ -44,6 +50,7 @@ describe("ResetPasswordPasswordRequiredState", () => { resetPasswordClient: mockResetPasswordClient, signInClient: mockSignInClient, jitClient: mockJitClient, + mfaClient: mockMfaClient, cacheClient: {} as unknown as jest.Mocked, username: username, 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 index 6058836cba..27a518ad9b 100644 --- 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 @@ -16,6 +16,7 @@ import { CustomAuthSilentCacheClient } from "../../../../../src/custom_auth/get_ import { SignInCodeRequiredState } from "../../../../../src/custom_auth/sign_in/auth_flow/state/SignInCodeRequiredState.js"; import { DefaultCustomAuthApiCodeLength } from "../../../../../src/custom_auth/CustomAuthConstants.js"; import { JitClient } from "../../../../../src/custom_auth/core/interaction_client/jit/JitClient.js"; +import { MfaClient } from "../../../../../src/custom_auth/core/interaction_client/mfa/MfaClient.js"; import { getDefaultLogger } from "../../../test_resources/TestModules.js"; describe("SignInCodeRequiredState", () => { @@ -37,6 +38,11 @@ describe("SignInCodeRequiredState", () => { requestChallenge: jest.fn(), continueChallenge: jest.fn(), } as unknown as jest.Mocked; + const mockMfaClient = { + requestChallenge: jest.fn(), + submitChallenge: jest.fn(), + getAuthMethods: jest.fn(), + } as unknown as jest.Mocked; const username = "testuser"; const correlationId = "test-correlation-id"; @@ -50,6 +56,7 @@ describe("SignInCodeRequiredState", () => { signInClient: mockSignInClient, cacheClient: mockCacheClient, jitClient: mockJitClient, + mfaClient: mockMfaClient, correlationId: correlationId, logger: getDefaultLogger(), continuationToken: continuationToken, @@ -76,7 +83,15 @@ describe("SignInCodeRequiredState", () => { expect(result.error?.errorData?.errorDescription).toContain("code"); }); - it("should successfully submit a code and return a result", async () => { + it("should return an error result if code length is invalid", async () => { + let result = await state.submitCode("123"); // Too short + + expect(result.isFailed()).toBeTruthy(); + expect(result.error).toBeInstanceOf(SignInSubmitCodeError); + expect(result.error?.isInvalidCode()).toBe(true); + }); + + it("should successfully submit a code and return a completed result", async () => { mockSignInClient.submitCode.mockResolvedValue( createSignInCompleteResult({ correlationId: correlationId, @@ -108,6 +123,7 @@ describe("SignInCodeRequiredState", () => { expect(result).toBeDefined(); expect(result).toBeInstanceOf(SignInSubmitCodeResult); + expect(result.isCompleted()).toBe(true); expect(result.data).toBeInstanceOf(CustomAuthAccountData); expect(mockSignInClient.submitCode).toHaveBeenCalledWith({ clientId: "test-client-id", @@ -117,14 +133,159 @@ describe("SignInCodeRequiredState", () => { continuationToken: continuationToken, code: "12345678", username: username, + claims: undefined, // No claims by default + }); + }); + + it("should include claims parameter when provided in state", async () => { + const stateWithClaims = new SignInCodeRequiredState({ + username: username, + signInClient: mockSignInClient, + cacheClient: mockCacheClient, + jitClient: mockJitClient, + mfaClient: mockMfaClient, + correlationId: correlationId, + logger: getDefaultLogger(), + continuationToken: continuationToken, + config: mockConfig, + codeLength: 8, + scopes: ["scope1", "scope2"], + claims: "test-claims", + }); + + 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 stateWithClaims.submitCode("12345678"); + + expect(result.isCompleted()).toBe(true); + expect(mockSignInClient.submitCode).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: ["code"], + scopes: ["scope1", "scope2"], + continuationToken: continuationToken, + code: "12345678", + username: username, + claims: "test-claims", }); }); + it("should handle JIT required scenario and return AuthMethodRegistrationRequiredState", async () => { + const jitContinuationToken = "jit-continuation-token"; + const mockAuthMethods = [ + { + id: "email_method", + challenge_type: "email", + challenge_channel: "email", + login_hint: "test@test.com", + }, + { + id: "sms_method", + challenge_type: "sms", + challenge_channel: "phone_number", + login_hint: "+1234567890", + }, + ]; + + mockSignInClient.submitCode.mockResolvedValue({ + type: "SignInJitRequiredResult", + correlationId: correlationId, + continuationToken: jitContinuationToken, + authMethods: mockAuthMethods, + } as any); + + const result = await state.submitCode("12345678"); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignInSubmitCodeResult); + expect(result.isFailed()).toBe(false); + expect(result.isAuthMethodRegistrationRequired()).toBe(true); + expect(result.error).toBeUndefined(); + expect(result.state).toBeDefined(); + expect(result.state?.constructor.name).toBe( + "AuthMethodRegistrationRequiredState" + ); + }); + + it("should handle MFA required scenario and return MfaAwaitingState", async () => { + const mfaContinuationToken = "mfa-continuation-token"; + const mockAuthMethods = [ + { + id: "email_method", + challenge_type: "email", + challenge_channel: "email", + login_hint: "test@test.com", + }, + { + id: "phone_method", + challenge_type: "phone", + challenge_channel: "phone_number", + login_hint: "+1234567890", + }, + ]; + + mockSignInClient.submitCode.mockResolvedValue({ + type: "SignInMfaRequiredResult", + correlationId: correlationId, + continuationToken: mfaContinuationToken, + authMethods: mockAuthMethods, + } as any); + + const result = await state.submitCode("12345678"); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignInSubmitCodeResult); + expect(result.isFailed()).toBe(false); + expect(result.isMfaRequired()).toBe(true); + expect(result.error).toBeUndefined(); + expect(result.state).toBeDefined(); + expect(result.state?.constructor.name).toBe("MfaAwaitingState"); + }); + + it("should handle unknown state from handleSignInResult and return error", async () => { + mockSignInClient.submitCode.mockResolvedValue({ + type: "UnknownResultType", + correlationId: correlationId, + } as any); + + const result = await state.submitCode("12345678"); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignInSubmitCodeResult); + expect(result.isFailed()).toBe(true); + expect(result.error).toBeDefined(); + }); + 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"); + const result = await state.submitCode("12345678"); expect(result).toBeDefined(); expect(result).toBeInstanceOf(SignInSubmitCodeResult); @@ -132,6 +293,17 @@ describe("SignInCodeRequiredState", () => { expect(result.error).toBeInstanceOf(SignInSubmitCodeError); }); + it("should handle network errors during submitCode", async () => { + const networkError = new Error("Network error"); + networkError.name = "NetworkError"; + mockSignInClient.submitCode.mockRejectedValue(networkError); + + const result = await state.submitCode("12345678"); + + expect(result.isFailed()).toBe(true); + 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({ @@ -166,6 +338,122 @@ describe("SignInCodeRequiredState", () => { expect(result.isCompleted()).toBeTruthy(); expect(result.error).toBeUndefined(); }); + + it("should handle empty scopes array", async () => { + const stateWithEmptyScopes = new SignInCodeRequiredState({ + username: username, + signInClient: mockSignInClient, + cacheClient: mockCacheClient, + jitClient: mockJitClient, + mfaClient: mockMfaClient, + correlationId: correlationId, + logger: getDefaultLogger(), + continuationToken: continuationToken, + config: mockConfig, + codeLength: 8, + scopes: [], // Empty scopes + }); + + 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 stateWithEmptyScopes.submitCode("12345678"); + + expect(result.isCompleted()).toBe(true); + expect(mockSignInClient.submitCode).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: ["code"], + scopes: [], + continuationToken: continuationToken, + code: "12345678", + username: username, + claims: undefined, + }); + }); + + it("should handle undefined scopes", async () => { + const stateWithUndefinedScopes = new SignInCodeRequiredState({ + username: username, + signInClient: mockSignInClient, + cacheClient: mockCacheClient, + jitClient: mockJitClient, + mfaClient: mockMfaClient, + correlationId: correlationId, + logger: getDefaultLogger(), + continuationToken: continuationToken, + config: mockConfig, + codeLength: 8, + scopes: undefined, // Undefined scopes + }); + + 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 stateWithUndefinedScopes.submitCode( + "12345678" + ); + + expect(result.isCompleted()).toBe(true); + expect(mockSignInClient.submitCode).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: ["code"], + scopes: [], + continuationToken: continuationToken, + code: "12345678", + username: username, + claims: undefined, + }); + }); }); describe("resendCode", () => { @@ -187,6 +475,96 @@ describe("SignInCodeRequiredState", () => { expect(result).toBeInstanceOf(SignInResendCodeResult); expect(result.data).toBeUndefined(); expect(result.isCodeRequired()).toBeTruthy(); + expect(result.state).toBeInstanceOf(SignInCodeRequiredState); + + // Verify the new state has updated continuation token and code length + const newState = result.state as SignInCodeRequiredState; + expect(newState.getCodeLength()).toBe(6); + }); + + it("should preserve scopes in the new state after resend", 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.isCodeRequired()).toBe(true); + const newState = result.state as SignInCodeRequiredState; + expect(newState.getScopes()).toEqual(["scope1", "scope2"]); + }); + + it("should call resendCode with correct parameters", async () => { + mockSignInClient.resendCode.mockResolvedValue( + createSignInCodeSendResult({ + correlationId: correlationId, + continuationToken: "new-continuation-token", + challengeChannel: "code", + challengeTargetLabel: "email", + codeLength: 6, + bindingMethod: "email-otp", + }) + ); + + await state.resendCode(); + + expect(mockSignInClient.resendCode).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: ["code"], + continuationToken: continuationToken, + username: username, + }); + }); + + it("should handle missing challengeTypes in resendCode", async () => { + const configWithoutChallengeTypes = { + auth: { clientId: "test-client-id" }, + customAuth: {}, // No challengeTypes + } as unknown as jest.Mocked; + + const stateWithoutChallengeTypes = new SignInCodeRequiredState({ + username: username, + signInClient: mockSignInClient, + cacheClient: mockCacheClient, + jitClient: mockJitClient, + mfaClient: mockMfaClient, + correlationId: correlationId, + logger: getDefaultLogger(), + continuationToken: continuationToken, + config: configWithoutChallengeTypes, + codeLength: 8, + scopes: ["scope1", "scope2"], + }); + + mockSignInClient.resendCode.mockResolvedValue( + createSignInCodeSendResult({ + correlationId: correlationId, + continuationToken: "new-continuation-token", + challengeChannel: "code", + challengeTargetLabel: "email", + codeLength: 6, + bindingMethod: "email-otp", + }) + ); + + const result = await stateWithoutChallengeTypes.resendCode(); + + expect(result.isCodeRequired()).toBe(true); + expect(mockSignInClient.resendCode).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: [], + continuationToken: continuationToken, + username: username, + }); }); it("should return an error result if resendCode throws an error", async () => { @@ -199,6 +577,97 @@ describe("SignInCodeRequiredState", () => { expect(result).toBeInstanceOf(SignInResendCodeResult); expect(result.error).toBeDefined(); expect(result.error).toBeInstanceOf(SignInResendCodeError); + expect(result.isFailed()).toBe(true); + }); + + it("should handle network errors during resendCode", async () => { + const networkError = new Error("Network error"); + networkError.name = "NetworkError"; + mockSignInClient.resendCode.mockRejectedValue(networkError); + + const result = await state.resendCode(); + + expect(result.isFailed()).toBe(true); + expect(result.error).toBeInstanceOf(SignInResendCodeError); + }); + + it("should handle timeout errors during resendCode", async () => { + const timeoutError = new Error("Request timeout"); + timeoutError.name = "TimeoutError"; + mockSignInClient.resendCode.mockRejectedValue(timeoutError); + + const result = await state.resendCode(); + + expect(result.isFailed()).toBe(true); + expect(result.error).toBeInstanceOf(SignInResendCodeError); + }); + }); + + describe("getCodeLength", () => { + it("should return the correct code length", () => { + expect(state.getCodeLength()).toBe(8); + }); + + it("should return default code length when not specified", () => { + const stateWithDefaultLength = new SignInCodeRequiredState({ + username: username, + signInClient: mockSignInClient, + cacheClient: mockCacheClient, + jitClient: mockJitClient, + mfaClient: mockMfaClient, + correlationId: correlationId, + logger: getDefaultLogger(), + continuationToken: continuationToken, + config: mockConfig, + codeLength: DefaultCustomAuthApiCodeLength, + scopes: ["scope1", "scope2"], + }); + + expect(stateWithDefaultLength.getCodeLength()).toBe( + DefaultCustomAuthApiCodeLength + ); + }); + }); + + describe("getScopes", () => { + it("should return the correct scopes", () => { + expect(state.getScopes()).toEqual(["scope1", "scope2"]); + }); + + it("should return undefined when scopes are not set", () => { + const stateWithoutScopes = new SignInCodeRequiredState({ + username: username, + signInClient: mockSignInClient, + cacheClient: mockCacheClient, + jitClient: mockJitClient, + mfaClient: mockMfaClient, + correlationId: correlationId, + logger: getDefaultLogger(), + continuationToken: continuationToken, + config: mockConfig, + codeLength: 8, + scopes: undefined, + }); + + expect(stateWithoutScopes.getScopes()).toBeUndefined(); + }); + + it("should return empty array when scopes are empty", () => { + const stateWithEmptyScopes = new SignInCodeRequiredState({ + username: username, + signInClient: mockSignInClient, + cacheClient: mockCacheClient, + jitClient: mockJitClient, + mfaClient: mockMfaClient, + correlationId: correlationId, + logger: getDefaultLogger(), + continuationToken: continuationToken, + config: mockConfig, + codeLength: 8, + scopes: [], + }); + + expect(stateWithEmptyScopes.getScopes()).toEqual([]); }); }); }); 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 index 82f5dd67ac..f1a019f1ca 100644 --- 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 @@ -3,11 +3,15 @@ import { CustomAuthBrowserConfiguration } from "../../../../../src/custom_auth/c 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 { + createSignInCompleteResult, + createSignInMfaRequiredResult, +} 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"; import { JitClient } from "../../../../../src/custom_auth/core/interaction_client/jit/JitClient.js"; +import { MfaClient } from "../../../../../src/custom_auth/core/interaction_client/mfa/MfaClient.js"; import { getDefaultLogger } from "../../../test_resources/TestModules.js"; describe("SignInContinuationState", () => { @@ -28,6 +32,11 @@ describe("SignInContinuationState", () => { requestChallenge: jest.fn(), continueChallenge: jest.fn(), } as unknown as jest.Mocked; + const mockMfaClient = { + requestChallenge: jest.fn(), + submitChallenge: jest.fn(), + getAuthMethods: jest.fn(), + } as unknown as jest.Mocked; const username = "testuser"; const correlationId = "test-correlation-id"; @@ -41,6 +50,7 @@ describe("SignInContinuationState", () => { signInClient: mockSignInClient, cacheClient: mockCacheClient, jitClient: mockJitClient, + mfaClient: mockMfaClient, correlationId: correlationId, logger: getDefaultLogger(), continuationToken: continuationToken, @@ -148,4 +158,97 @@ describe("SignInContinuationState", () => { expect(result.error).toBeDefined(); expect(result.error).toBeInstanceOf(SignInError); }); + + it("should handle MFA required scenario during continuation token sign-in", async () => { + const mfaContinuationToken = "mfa-continuation-token"; + const authMethods = [ + { + id: "email", + challenge_type: "otp", + challenge_channel: "email", + login_hint: "user@example.com", + }, + { + id: "sms", + challenge_type: "otp", + challenge_channel: "sms", + login_hint: "+1234567890", + }, + ]; + + mockSignInClient.signInWithContinuationToken.mockResolvedValue( + createSignInMfaRequiredResult({ + correlationId: correlationId, + continuationToken: mfaContinuationToken, + authMethods: authMethods, + }) + ); + + const result = await state.signIn({ scopes: ["scope1", "scope2"] }); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignInResult); + expect(result.isMfaRequired()).toBe(true); + expect(result.error).toBeUndefined(); + expect(result.state).toBeDefined(); + expect(result.state?.constructor.name).toBe("MfaAwaitingState"); + expect( + mockSignInClient.signInWithContinuationToken + ).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: ["code", "password", "redirect"], + scopes: ["scope1", "scope2"], + continuationToken: continuationToken, + username: username, + signInScenario: SignInScenario.SignInAfterSignUp, + claims: undefined, + }); + }); + + it("should handle MFA required scenario with claims parameter", async () => { + const mfaContinuationToken = "mfa-continuation-token"; + const authMethods = [ + { + id: "email", + challenge_type: "otp", + challenge_channel: "email", + login_hint: "user@example.com", + }, + ]; + const claims = + '{"access_token":{"acr":{"essential":true,"value":"c1"}}}'; + + mockSignInClient.signInWithContinuationToken.mockResolvedValue( + createSignInMfaRequiredResult({ + correlationId: correlationId, + continuationToken: mfaContinuationToken, + authMethods: authMethods, + }) + ); + + const result = await state.signIn({ + scopes: ["scope1"], + claims: claims, + }); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignInResult); + expect(result.isMfaRequired()).toBe(true); + expect(result.error).toBeUndefined(); + expect(result.state).toBeDefined(); + expect(result.state?.constructor.name).toBe("MfaAwaitingState"); + expect( + mockSignInClient.signInWithContinuationToken + ).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: ["code", "password", "redirect"], + scopes: ["scope1"], + continuationToken: continuationToken, + username: username, + signInScenario: SignInScenario.SignInAfterSignUp, + claims: claims, + }); + }); }); 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 index 83670ce29b..b98dc966de 100644 --- 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 @@ -8,6 +8,7 @@ import { createSignInCompleteResult } from "../../../../../src/custom_auth/sign_ 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 { JitClient } from "../../../../../src/custom_auth/core/interaction_client/jit/JitClient.js"; +import { MfaClient } from "../../../../../src/custom_auth/core/interaction_client/mfa/MfaClient.js"; import { getDefaultLogger } from "../../../test_resources/TestModules.js"; describe("SignInPasswordRequiredState", () => { @@ -28,6 +29,11 @@ describe("SignInPasswordRequiredState", () => { requestChallenge: jest.fn(), continueChallenge: jest.fn(), } as unknown as jest.Mocked; + const mockMfaClient = { + requestChallenge: jest.fn(), + submitChallenge: jest.fn(), + getAuthMethods: jest.fn(), + } as unknown as jest.Mocked; const username = "testuser"; const correlationId = "test-correlation-id"; @@ -41,6 +47,7 @@ describe("SignInPasswordRequiredState", () => { signInClient: mockSignInClient, cacheClient: mockCacheClient, jitClient: mockJitClient, + mfaClient: mockMfaClient, correlationId: correlationId, logger: getDefaultLogger(), continuationToken: continuationToken, @@ -104,6 +111,334 @@ describe("SignInPasswordRequiredState", () => { continuationToken: continuationToken, password: "valid-password", username: username, + claims: undefined, // No claims by default + }); + }); + + it("should include claims parameter when provided in state", async () => { + const stateWithClaims = new SignInPasswordRequiredState({ + username: username, + signInClient: mockSignInClient, + cacheClient: mockCacheClient, + jitClient: mockJitClient, + mfaClient: mockMfaClient, + correlationId: correlationId, + logger: getDefaultLogger(), + continuationToken: continuationToken, + config: mockConfig, + scopes: ["scope1", "scope2"], + claims: "test-claims", + }); + + 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 stateWithClaims.submitPassword("valid-password"); + + expect(result.isCompleted()).toBe(true); + expect(mockSignInClient.submitPassword).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: ["password"], + scopes: ["scope1", "scope2"], + continuationToken: continuationToken, + password: "valid-password", + username: username, + claims: "test-claims", + }); + }); + + it("should handle MFA required scenario after password submission", async () => { + const mfaContinuationToken = "mfa-continuation-token"; + const mockAuthMethods = [ + { + id: "email_method", + challenge_type: "email", + challenge_channel: "email", + login_hint: "test@test.com", + }, + { + id: "phone_method", + challenge_type: "phone", + challenge_channel: "phone_number", + login_hint: "+1234567890", + }, + ]; + + mockSignInClient.submitPassword.mockResolvedValue({ + type: "SignInMfaRequiredResult", + correlationId: correlationId, + continuationToken: mfaContinuationToken, + authMethods: mockAuthMethods, + } as any); + + const result = await state.submitPassword("valid-password"); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignInSubmitPasswordResult); + expect(result.isFailed()).toBe(false); + expect(result.isMfaRequired()).toBe(true); + expect(result.error).toBeUndefined(); + expect(result.state).toBeDefined(); + expect(result.state?.constructor.name).toBe("MfaAwaitingState"); + }); + + it("should handle missing challengeTypes configuration", async () => { + const configWithoutChallengeTypes = { + auth: { clientId: "test-client-id" }, + customAuth: {}, // No challengeTypes + } as unknown as jest.Mocked; + + const stateWithoutChallengeTypes = new SignInPasswordRequiredState({ + username: username, + signInClient: mockSignInClient, + cacheClient: mockCacheClient, + jitClient: mockJitClient, + mfaClient: mockMfaClient, + correlationId: correlationId, + logger: getDefaultLogger(), + continuationToken: continuationToken, + config: configWithoutChallengeTypes, + scopes: ["scope1", "scope2"], + }); + + 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 stateWithoutChallengeTypes.submitPassword( + "valid-password" + ); + + expect(result.isCompleted()).toBe(true); + expect(mockSignInClient.submitPassword).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: [], + scopes: ["scope1", "scope2"], + continuationToken: continuationToken, + password: "valid-password", + username: username, + claims: undefined, + }); + }); + + it("should handle empty scopes array", async () => { + const stateWithEmptyScopes = new SignInPasswordRequiredState({ + username: username, + signInClient: mockSignInClient, + cacheClient: mockCacheClient, + jitClient: mockJitClient, + mfaClient: mockMfaClient, + correlationId: correlationId, + logger: getDefaultLogger(), + continuationToken: continuationToken, + config: mockConfig, + scopes: [], // Empty scopes + }); + + 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 stateWithEmptyScopes.submitPassword( + "valid-password" + ); + + expect(result.isCompleted()).toBe(true); + expect(mockSignInClient.submitPassword).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: ["password"], + scopes: [], + continuationToken: continuationToken, + password: "valid-password", + username: username, + claims: undefined, + }); + }); + + it("should handle undefined scopes", async () => { + const stateWithUndefinedScopes = new SignInPasswordRequiredState({ + username: username, + signInClient: mockSignInClient, + cacheClient: mockCacheClient, + jitClient: mockJitClient, + mfaClient: mockMfaClient, + correlationId: correlationId, + logger: getDefaultLogger(), + continuationToken: continuationToken, + config: mockConfig, + scopes: undefined, // Undefined scopes + }); + + 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 stateWithUndefinedScopes.submitPassword( + "valid-password" + ); + + expect(result.isCompleted()).toBe(true); + expect(mockSignInClient.submitPassword).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: ["password"], + scopes: [], + continuationToken: continuationToken, + password: "valid-password", + username: username, + claims: undefined, + }); + }); + + it("should handle missing continuation token gracefully", async () => { + const stateWithoutToken = new SignInPasswordRequiredState({ + username: username, + signInClient: mockSignInClient, + cacheClient: mockCacheClient, + jitClient: mockJitClient, + mfaClient: mockMfaClient, + correlationId: correlationId, + logger: getDefaultLogger(), + continuationToken: "valid-token", // Must be non-empty + config: mockConfig, + scopes: ["scope1", "scope2"], + }); + + 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 stateWithoutToken.submitPassword("valid-password"); + + expect(result.isCompleted()).toBe(true); + expect(mockSignInClient.submitPassword).toHaveBeenCalledWith({ + clientId: "test-client-id", + correlationId: correlationId, + challengeType: ["password"], + scopes: ["scope1", "scope2"], + continuationToken: "valid-token", + password: "valid-password", + username: username, + claims: undefined, }); }); @@ -279,12 +614,13 @@ describe("SignInPasswordRequiredState", () => { expect(scopes).toEqual(["scope1", "scope2"]); }); - it("should handle case when scopes are undefined", () => { + it("should return undefined scopes when not set", () => { const stateWithoutScopes = new SignInPasswordRequiredState({ username: username, signInClient: mockSignInClient, cacheClient: mockCacheClient, jitClient: mockJitClient, + mfaClient: mockMfaClient, correlationId: correlationId, logger: getDefaultLogger(), continuationToken: continuationToken, @@ -292,48 +628,79 @@ describe("SignInPasswordRequiredState", () => { scopes: undefined, }); - const scopes = stateWithoutScopes.getScopes(); - expect(scopes).toBeUndefined(); + expect(stateWithoutScopes.getScopes()).toBeUndefined(); }); - it("should handle submitPassword with complex authentication result", async () => { - const complexAuthResult = { - accessToken: "complex-access-token", - idToken: "complex-id-token", - expiresOn: new Date(Date.now() + 7200 * 1000), - tokenType: "Bearer", + it("should return empty array when scopes are empty", () => { + const stateWithEmptyScopes = new SignInPasswordRequiredState({ + username: username, + signInClient: mockSignInClient, + cacheClient: mockCacheClient, + jitClient: mockJitClient, + mfaClient: mockMfaClient, correlationId: correlationId, - authority: "https://complex-authority.com", - tenantId: "complex-tenant-id", - scopes: ["complex-scope1", "complex-scope2"], - account: { - homeAccountId: "complex-home-account-id", - environment: "complex-environment", - tenantId: "complex-tenant-id", - username: "complex-username", - localAccountId: "complex-local-account-id", - idToken: "complex-id-token", - }, - idTokenClaims: { - sub: "complex-subject", - aud: "complex-audience", - iss: "complex-issuer", - }, - fromCache: false, - uniqueId: "complex-unique-id", - }; + logger: getDefaultLogger(), + continuationToken: continuationToken, + config: mockConfig, + scopes: [], + }); + + expect(stateWithEmptyScopes.getScopes()).toEqual([]); + }); + + it("should handle error propagation from handleSignInResult", async () => { + // Mock the base class method to return an error + const mockError = new Error("HandleSignInResult error"); + jest.spyOn(state as any, "handleSignInResult").mockReturnValue({ + error: mockError, + state: null, + accountInfo: null, + }); mockSignInClient.submitPassword.mockResolvedValue( createSignInCompleteResult({ correlationId: correlationId, - authenticationResult: complexAuthResult, + 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("complex-password"); + const result = await state.submitPassword("valid-password"); - expect(result.isCompleted()).toBe(true); - expect(result.data).toBeInstanceOf(CustomAuthAccountData); - expect(result.data?.getAccount()).toBeDefined(); + expect(result.isFailed()).toBe(true); + expect(result.error).toBeDefined(); + }); + + it("should handle unknown state from handleSignInResult and return error", async () => { + mockSignInClient.submitPassword.mockResolvedValue({ + type: "UnknownResultType", + correlationId: correlationId, + } as any); + + const result = await state.submitPassword("valid-password"); + + expect(result).toBeDefined(); + expect(result).toBeInstanceOf(SignInSubmitPasswordResult); + expect(result.isFailed()).toBe(true); + expect(result.error).toBeDefined(); }); }); 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 index d322429cc3..f176e2a220 100644 --- 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 @@ -8,6 +8,7 @@ import { SignInClient } from "../../../../../src/custom_auth/sign_in/interaction import { UserAccountAttributes } from "../../../../../src/custom_auth/UserAccountAttributes.js"; import { CustomAuthSilentCacheClient } from "../../../../../src/custom_auth/get_account/interaction_client/CustomAuthSilentCacheClient.js"; import { JitClient } from "../../../../../src/custom_auth/core/interaction_client/jit/JitClient.js"; +import { MfaClient } from "../../../../../src/custom_auth/core/interaction_client/mfa/MfaClient.js"; import { getDefaultLogger } from "../../../test_resources/TestModules.js"; describe("SignUpAttributesRequiredState", () => { @@ -27,6 +28,11 @@ describe("SignUpAttributesRequiredState", () => { requestChallenge: jest.fn(), continueChallenge: jest.fn(), } as unknown as jest.Mocked; + const mockMfaClient = { + requestChallenge: jest.fn(), + submitChallenge: jest.fn(), + getAuthMethods: jest.fn(), + } as unknown as jest.Mocked; const username = "testuser"; const correlationId = "test-correlation-id"; @@ -43,6 +49,7 @@ describe("SignUpAttributesRequiredState", () => { signUpClient: mockSignUpClient, signInClient: mockSignInClient, jitClient: mockJitClient, + mfaClient: mockMfaClient, cacheClient: {} as unknown as jest.Mocked, correlationId: correlationId, 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 index 48d10829f4..dd8c834586 100644 --- 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 @@ -14,6 +14,7 @@ import { SignUpClient } from "../../../../../src/custom_auth/sign_up/interaction 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 { JitClient } from "../../../../../src/custom_auth/core/interaction_client/jit/JitClient.js"; +import { MfaClient } from "../../../../../src/custom_auth/core/interaction_client/mfa/MfaClient.js"; import { getDefaultLogger } from "../../../test_resources/TestModules.js"; describe("SignUpCodeRequiredState", () => { @@ -34,6 +35,11 @@ describe("SignUpCodeRequiredState", () => { requestChallenge: jest.fn(), continueChallenge: jest.fn(), } as unknown as jest.Mocked; + const mockMfaClient = { + requestChallenge: jest.fn(), + submitChallenge: jest.fn(), + getAuthMethods: jest.fn(), + } as unknown as jest.Mocked; const username = "testuser"; const correlationId = "test-correlation-id"; @@ -47,6 +53,7 @@ describe("SignUpCodeRequiredState", () => { signUpClient: mockSignUpClient, signInClient: mockSignInClient, jitClient: mockJitClient, + mfaClient: mockMfaClient, cacheClient: {} as unknown as jest.Mocked, correlationId: correlationId, 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 index 167f51b080..cbe0c810ff 100644 --- 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 @@ -11,6 +11,7 @@ import { SignUpClient } from "../../../../../src/custom_auth/sign_up/interaction 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 { JitClient } from "../../../../../src/custom_auth/core/interaction_client/jit/JitClient.js"; +import { MfaClient } from "../../../../../src/custom_auth/core/interaction_client/mfa/MfaClient.js"; import { getDefaultLogger } from "../../../test_resources/TestModules.js"; describe("SignUpPasswordRequiredState", () => { @@ -30,6 +31,11 @@ describe("SignUpPasswordRequiredState", () => { requestChallenge: jest.fn(), continueChallenge: jest.fn(), } as unknown as jest.Mocked; + const mockMfaClient = { + requestChallenge: jest.fn(), + submitChallenge: jest.fn(), + getAuthMethods: jest.fn(), + } as unknown as jest.Mocked; const username = "testuser"; const correlationId = "test-correlation-id"; @@ -43,6 +49,7 @@ describe("SignUpPasswordRequiredState", () => { signUpClient: mockSignUpClient, signInClient: mockSignInClient, jitClient: mockJitClient, + mfaClient: mockMfaClient, cacheClient: {} as unknown as jest.Mocked, correlationId: correlationId,