From 0c9eef83e26754ab710f246d446621a74c6fd492 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:30:00 +0000 Subject: [PATCH 1/6] Initial plan From af209037bae6602a138732e9bd9842a19af43f08 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:37:46 +0000 Subject: [PATCH 2/6] Add SSPR v2 SMS method selection Co-authored-by: antrix1989 <1989385+antrix1989@users.noreply.github.com> --- CHANGELOG.md | 1 + MSAL/MSAL.xcodeproj/project.pbxproj | 12 + .../v2/MSALNativeAuthFlowController.swift | 122 +++++++- ...MSALNativeAuthFlowResponseDispatcher.swift | 8 + .../MSALNativeAuthV2ParsedResponses.swift | 6 + ...AuthAuthMethodSelectionRequiredState.swift | 84 ++++++ ...tiveAuthResetPasswordV2EndToEndTests.swift | 21 ++ .../MSALNativeAuthFlowControllerTests.swift | 261 +++++++++++++++++- ...ativeAuthFlowResponseDispatcherTests.swift | 71 +++++ .../v2/MSALNativeAuthFlowControllerMock.swift | 6 + .../MSALNativeAuthV2ResponseParserTests.swift | 83 +++++- ...uthMethodSelectionRequiredStateTests.swift | 109 ++++++++ 12 files changed, 766 insertions(+), 18 deletions(-) create mode 100644 MSAL/src/native_auth/public/state_machine/v2/state/MSALNativeAuthAuthMethodSelectionRequiredState.swift create mode 100644 MSAL/test/unit/native_auth/public/state_machine/v2/MSALNativeAuthAuthMethodSelectionRequiredStateTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index c0726f49bb..975f0e2303 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ #TBD +* Add Native Auth V2 SSPR SMS authentication method selection support. * Add Native Auth V2 email OTP sign-in #3085 * Add SignIn v2 #3079 diff --git a/MSAL/MSAL.xcodeproj/project.pbxproj b/MSAL/MSAL.xcodeproj/project.pbxproj index 9e24ca2f55..fb2f1b90eb 100644 --- a/MSAL/MSAL.xcodeproj/project.pbxproj +++ b/MSAL/MSAL.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 01462653AC546A8B95A0D912 /* MSALNativeAuthMFARequiredState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FE5D3DE054DBA691A401E3D /* MSALNativeAuthMFARequiredState.swift */; }; + 0AA10000000000000000AA01 /* MSALNativeAuthAuthMethodSelectionRequiredState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AA10000000000000000AF01 /* MSALNativeAuthAuthMethodSelectionRequiredState.swift */; }; 01F6FDA46510AF671264602E /* MSALNativeAuthFlowControlling.swift in Sources */ = {isa = PBXBuildFile; fileRef = B414350D2B1EE1FA349DC550 /* MSALNativeAuthFlowControlling.swift */; }; 022239DBCF2EF4AD83359DD3 /* MailTMConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8B4CF9C872C00B3E5FD2C40 /* MailTMConstants.swift */; }; 026328B2E3D999D2224CA191 /* MSALNativeAuthV2LinkRelation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D259B6FA6FA078E5D941A12 /* MSALNativeAuthV2LinkRelation.swift */; }; @@ -429,6 +430,7 @@ 38880DF523280C5A00688C24 /* MSALPublicClientApplicationConfig.m in Sources */ = {isa = PBXBuildFile; fileRef = 23B1D35D22EA4797000954AF /* MSALPublicClientApplicationConfig.m */; }; 5759D66144CC92A16A2BBA84 /* MSALExternalKeyPair.h in Headers */ = {isa = PBXBuildFile; fileRef = C80780758A4E248897963095 /* MSALExternalKeyPair.h */; settings = {ATTRIBUTES = (Public, ); }; }; 3909B2CE15314B4B33F17289 /* MSALNativeAuthFlowErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C52731D0550F3D98B606302 /* MSALNativeAuthFlowErrorTests.swift */; }; + 0AA10000000000000000AA03 /* MSALNativeAuthAuthMethodSelectionRequiredStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AA10000000000000000AF02 /* MSALNativeAuthAuthMethodSelectionRequiredStateTests.swift */; }; 3910135713EE25264B751FF5 /* MSALNativeAuthV2ResponseErrorHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26A54EBC67992C2F90976346 /* MSALNativeAuthV2ResponseErrorHandlerTests.swift */; }; 3F2E65884A64B912E42B512D /* MSALNativeAuthV2RequestTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF017CDD211895E02588AA7E /* MSALNativeAuthV2RequestTarget.swift */; }; 4076304A09CC32719F50ED6A /* MSALNativeAuthHALReadyToCompleteResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AEC43FA4B89AAF40D902A54 /* MSALNativeAuthHALReadyToCompleteResponse.swift */; }; @@ -544,6 +546,7 @@ 9313B1799984552C778C5E5C /* MailTMHTTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 475F1413DA1D76D5EF31F4EC /* MailTMHTTPClient.swift */; }; 94E876CE1E492D6000FB96ED /* MSALAuthority.m in Sources */ = {isa = PBXBuildFile; fileRef = 94E876CB1E492D6000FB96ED /* MSALAuthority.m */; }; 9531B6F096270D19F6E95596 /* MSALNativeAuthFlowErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C52731D0550F3D98B606302 /* MSALNativeAuthFlowErrorTests.swift */; }; + 0AA10000000000000000AA04 /* MSALNativeAuthAuthMethodSelectionRequiredStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AA10000000000000000AF02 /* MSALNativeAuthAuthMethodSelectionRequiredStateTests.swift */; }; 960751BB2183E82C00F2BF2F /* MSALAccountIdTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 960751BA2183E82C00F2BF2F /* MSALAccountIdTests.m */; }; 960751BC2183E82C00F2BF2F /* MSALAccountIdTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 960751BA2183E82C00F2BF2F /* MSALAccountIdTests.m */; }; 96090D9020E58DE600E42B37 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 96902DEC20E1574F00200E6F /* WebKit.framework */; }; @@ -1085,6 +1088,7 @@ CA7959DF5D62E1756F8C2440 /* MSALExternalKeyPair.m in Sources */ = {isa = PBXBuildFile; fileRef = E3D6DF8D0582D8A2ABBE60AC /* MSALExternalKeyPair.m */; }; CB53AE75EED32902E5709ED8 /* MSALExternalKeyPair.h in Headers */ = {isa = PBXBuildFile; fileRef = C80780758A4E248897963095 /* MSALExternalKeyPair.h */; settings = {ATTRIBUTES = (Public, ); }; }; B311DA009515BDDE5FDF3679 /* MSALNativeAuthMFARequiredState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8FE5D3DE054DBA691A401E3D /* MSALNativeAuthMFARequiredState.swift */; }; + 0AA10000000000000000AA02 /* MSALNativeAuthAuthMethodSelectionRequiredState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AA10000000000000000AF01 /* MSALNativeAuthAuthMethodSelectionRequiredState.swift */; }; B39266A1EEC9C21B7686E148 /* MSALNativeAuthV2ParsedResponses.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D04D5E6EC9281BEF684A520 /* MSALNativeAuthV2ParsedResponses.swift */; }; B3E12C5ECC553A95521CFEFA /* MSALNativeAuthNewPasswordRequiredState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8273A2EAA82AE303691B976F /* MSALNativeAuthNewPasswordRequiredState.swift */; }; B4CDF4FB20138CF27310258B /* MSALNativeAuthV2RequestProviderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94DBF7076275DC959B195094 /* MSALNativeAuthV2RequestProviderTests.swift */; }; @@ -2413,6 +2417,7 @@ 58B81F6524AC59A000E8799E /* MSALTestCacheTokenResponse.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MSALTestCacheTokenResponse.h; sourceTree = ""; }; 58B81F6E24AC59C600E8799E /* MSALTestCacheTokenResponse.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MSALTestCacheTokenResponse.m; sourceTree = ""; }; 5C52731D0550F3D98B606302 /* MSALNativeAuthFlowErrorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MSALNativeAuthFlowErrorTests.swift; path = ../../public/state_machine/v2/MSALNativeAuthFlowErrorTests.swift; sourceTree = ""; }; + 0AA10000000000000000AF02 /* MSALNativeAuthAuthMethodSelectionRequiredStateTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MSALNativeAuthAuthMethodSelectionRequiredStateTests.swift; path = ../../public/state_machine/v2/MSALNativeAuthAuthMethodSelectionRequiredStateTests.swift; sourceTree = ""; }; 6077D49F22498BFF001798A2 /* MSALTenantProfile.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MSALTenantProfile.h; sourceTree = ""; }; 6077D4A822498D87001798A2 /* MSALTenantProfile.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MSALTenantProfile.m; sourceTree = ""; }; 609AF9322256BD0C00E2978D /* MSALAccountsProviderTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MSALAccountsProviderTests.m; sourceTree = ""; }; @@ -2450,6 +2455,7 @@ 8DDF473E2A98FE1C00126A47 /* MSALNativeAuthRequiredAttribute.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MSALNativeAuthRequiredAttribute.swift; sourceTree = ""; }; 8FB0FFEFC459978DDDDE9212 /* MSALNativeAuthCodeRequiredState.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MSALNativeAuthCodeRequiredState.swift; sourceTree = ""; }; 8FE5D3DE054DBA691A401E3D /* MSALNativeAuthMFARequiredState.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MSALNativeAuthMFARequiredState.swift; sourceTree = ""; }; + 0AA10000000000000000AF01 /* MSALNativeAuthAuthMethodSelectionRequiredState.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MSALNativeAuthAuthMethodSelectionRequiredState.swift; sourceTree = ""; }; 915ADC93F4A3F1D7DA35F8AC /* MSALNativeAuthFlowControllerSignInTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MSALNativeAuthFlowControllerSignInTests.swift; sourceTree = ""; }; 91AA24522BDF6439005037EA /* MSAL Test App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "MSAL Test App.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 91AA24582BDF643A005037EA /* MSAL_Test_App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MSAL_Test_App.swift; sourceTree = ""; }; @@ -3779,6 +3785,7 @@ C74AE8A04459BC8C4405B7CD /* MSALNativeAuthV2ParametersTests.swift */, 94DBF7076275DC959B195094 /* MSALNativeAuthV2RequestProviderTests.swift */, 5C52731D0550F3D98B606302 /* MSALNativeAuthFlowErrorTests.swift */, + 0AA10000000000000000AF02 /* MSALNativeAuthAuthMethodSelectionRequiredStateTests.swift */, 065DECC57E9CF4618C1D5494 /* MSALNativeAuthV2HALResponseSerializerTests.swift */, 26A54EBC67992C2F90976346 /* MSALNativeAuthV2ResponseErrorHandlerTests.swift */, ); @@ -3810,6 +3817,7 @@ A315CA10DE2299B7370E11BE /* MSALNativeAuthAttributesRequiredState.swift */, BFEF89FFAE159B6EE80EDFC7 /* MSALNativeAuthAttributesInvalidState.swift */, 8FE5D3DE054DBA691A401E3D /* MSALNativeAuthMFARequiredState.swift */, + 0AA10000000000000000AF01 /* MSALNativeAuthAuthMethodSelectionRequiredState.swift */, 6439500470AC3BBD79E7D046 /* MSALNativeAuthMFAVerificationRequiredState.swift */, 509588E9A2AE1A919D2AF029 /* MSALNativeAuthStrongAuthRegistrationRequiredState.swift */, FAEA06244B0DACD04D94193D /* MSALNativeAuthStrongAuthVerificationRequiredState.swift */, @@ -7412,6 +7420,7 @@ 6534A6BDFED26846E71370A9 /* MSALNativeAuthAttributesRequiredState.swift in Sources */, F68F10EB13E4A78906E6C12E /* MSALNativeAuthAttributesInvalidState.swift in Sources */, B311DA009515BDDE5FDF3679 /* MSALNativeAuthMFARequiredState.swift in Sources */, + 0AA10000000000000000AA02 /* MSALNativeAuthAuthMethodSelectionRequiredState.swift in Sources */, 6B4459C145930D5EC63EA477 /* MSALNativeAuthMFAVerificationRequiredState.swift in Sources */, 32EB647A08781A29C344ACC6 /* MSALNativeAuthStrongAuthRegistrationRequiredState.swift in Sources */, 42E1FE910A9592561A2F44DC /* MSALNativeAuthStrongAuthVerificationRequiredState.swift in Sources */, @@ -7811,6 +7820,7 @@ 9959BEB45FADB738C8BFE331 /* MSALNativeAuthAttributesRequiredState.swift in Sources */, 5DA9B72FCAECBF4718161CE1 /* MSALNativeAuthAttributesInvalidState.swift in Sources */, 01462653AC546A8B95A0D912 /* MSALNativeAuthMFARequiredState.swift in Sources */, + 0AA10000000000000000AA01 /* MSALNativeAuthAuthMethodSelectionRequiredState.swift in Sources */, F05FC2CFEF1AE5462086AD0C /* MSALNativeAuthMFAVerificationRequiredState.swift in Sources */, 4F6C95BC33A85725CB3F2185 /* MSALNativeAuthStrongAuthRegistrationRequiredState.swift in Sources */, 79B0D18719E266EBFAA96F9D /* MSALNativeAuthStrongAuthVerificationRequiredState.swift in Sources */, @@ -8215,6 +8225,7 @@ B4CDF4FB20138CF27310258B /* MSALNativeAuthV2RequestProviderTests.swift in Sources */, F819D42E8772D0CDAB08945A /* MSALNativeAuthFlowResponseDispatcherTests.swift in Sources */, 3909B2CE15314B4B33F17289 /* MSALNativeAuthFlowErrorTests.swift in Sources */, + 0AA10000000000000000AA03 /* MSALNativeAuthAuthMethodSelectionRequiredStateTests.swift in Sources */, C3B881EFA8EC0E8B506F576D /* MSALNativeAuthV2HALResponseSerializerTests.swift in Sources */, 3910135713EE25264B751FF5 /* MSALNativeAuthV2ResponseErrorHandlerTests.swift in Sources */, 143FA5DBABB342C09531D190 /* MSALNativeAuthFlowControllerSignInTests.swift in Sources */, @@ -8411,6 +8422,7 @@ D3C4A02BF6F6E02B8D58ACE8 /* MSALNativeAuthV2RequestProviderTests.swift in Sources */, 65F680460796E9A10FE8CD05 /* MSALNativeAuthFlowResponseDispatcherTests.swift in Sources */, 9531B6F096270D19F6E95596 /* MSALNativeAuthFlowErrorTests.swift in Sources */, + 0AA10000000000000000AA04 /* MSALNativeAuthAuthMethodSelectionRequiredStateTests.swift in Sources */, C277EAF06922901997D9D450 /* MSALNativeAuthV2HALResponseSerializerTests.swift in Sources */, B241DD9BDD1D50BFBAC9BEFF /* MSALNativeAuthV2ResponseErrorHandlerTests.swift in Sources */, 4B10EE83FDFA69F5A75129F3 /* MSALNativeAuthFlowControllerSignInTests.swift in Sources */, diff --git a/MSAL/src/native_auth/controllers/v2/MSALNativeAuthFlowController.swift b/MSAL/src/native_auth/controllers/v2/MSALNativeAuthFlowController.swift index ac442129a8..aa2e4ea2ed 100644 --- a/MSAL/src/native_auth/controllers/v2/MSALNativeAuthFlowController.swift +++ b/MSAL/src/native_auth/controllers/v2/MSALNativeAuthFlowController.swift @@ -169,12 +169,31 @@ final class MSALNativeAuthFlowController: MSALNativeAuthBaseController, MSALNati return interactionFailure(startResult, event: event, context: context, scenario: flowScenario, newState: nil) } - // Password reset is a code-first flow: select the email code method from the offered first-factor methods. - guard let method = methods.first(where: { $0.channelType.isEmailType }) else { - let error = MSALNativeAuthFlowError(type: .generalError, errorDescription: MSALNativeAuthErrorMessage.generalError) - return interactionFailure(.error(error), event: event, context: context, scenario: flowScenario, newState: nil) + let validMethods = passwordResetMethods(from: methods) + guard !validMethods.isEmpty else { + return noValidAuthMethodResponse(event: event, context: context, scenario: flowScenario) + } + + if validMethods.count > 1 { + let continuation = makeAuthMethodSelectionContinuation( + from: MSALNativeAuthFlowContinuationState( + flowScenario: flowScenario, + correlationId: context.correlationId(), + continuationToken: challengeContinuationToken, + links: [:] + ), + continuationToken: challengeContinuationToken, + methods: validMethods + ) + let step = MSALNativeAuthFlowStepContext( + apiId: .telemetryApiIdV2ResetPasswordStart, + event: event, + context: context + ) + return authMethodSelectionRequiredResponse(flowContinuationState: continuation, methods: validMethods, step: step) } + let method = validMethods[0] let challengeResult = await performInteraction(context: context) { try self.requestProvider.challenge( href: method.challengeHref, @@ -419,11 +438,17 @@ final class MSALNativeAuthFlowController: MSALNativeAuthBaseController, MSALNati state: MSALNativeAuthFlowInternalState ) async -> MSALNativeAuthFlowControllerResponse { let flowContinuationState = state.continuation - guard flowContinuationState.flowScenario == .signIn else { + let context = MSALNativeAuthRequestContext(correlationId: flowContinuationState.correlationId) + let apiId: MSALNativeAuthTelemetryApiId + switch flowContinuationState.flowScenario { + case .signIn: + apiId = .telemetryApiIdV2MFAGetAuthMethods + case .passwordReset: + apiId = .telemetryApiIdV2ResetPasswordStart + default: return notImplementedResponse(scenario: flowContinuationState.flowScenario) } - let context = MSALNativeAuthRequestContext(correlationId: flowContinuationState.correlationId) - let event = makeAndStartTelemetryEvent(id: .telemetryApiIdV2MFAGetAuthMethods, context: context) + let event = makeAndStartTelemetryEvent(id: apiId, context: context) guard let challengeHref = flowContinuationState.methodLink(for: method.id)?.absoluteString else { return failure( @@ -445,12 +470,19 @@ final class MSALNativeAuthFlowController: MSALNativeAuthBaseController, MSALNati try self.requestProvider.challenge( href: challengeHref, continuationToken: continuationToken, - apiId: .telemetryApiIdV2MFAGetAuthMethods, + apiId: apiId, context: context ) } - let step = MSALNativeAuthFlowStepContext(apiId: .telemetryApiIdV2MFAGetAuthMethods, event: event, context: context) - return handleMFASelectAuthMethodResult(result, flowContinuationState: flowContinuationState, step: step) + let step = MSALNativeAuthFlowStepContext(apiId: apiId, event: event, context: context) + switch flowContinuationState.flowScenario { + case .signIn: + return handleMFASelectAuthMethodResult(result, flowContinuationState: flowContinuationState, step: step) + case .passwordReset: + return await handlePasswordResetChallengeResult(result, flowContinuationState: flowContinuationState, step: step) + default: + return notImplementedResponse(scenario: flowContinuationState.flowScenario) + } } func submitChallenge(_ challenge: String, state: MSALNativeAuthFlowInternalState) async -> MSALNativeAuthFlowControllerResponse { @@ -784,9 +816,7 @@ final class MSALNativeAuthFlowController: MSALNativeAuthBaseController, MSALNati ) async -> MSALNativeAuthFlowControllerResponse { switch result { case .verificationRequired(let token, let verifyHref, let resendHref, let sentTo, let channelType, let codeLength): - // Password reset is a code-first flow: the server must select a code-based method, only email supported for now - // Any other method type cannot be verified in this flow, so it is an error. - guard channelType.isEmailType else { + guard channelType.isEmailType || channelType.isSMSType else { let error = MSALNativeAuthFlowError(type: .generalError, errorDescription: MSALNativeAuthErrorMessage.generalError) stopTelemetryEvent(step.event, context: step.context, error: error) return response(.error(error: error), context: step.context, scenario: flowContinuationState.flowScenario) @@ -814,7 +844,7 @@ final class MSALNativeAuthFlowController: MSALNativeAuthBaseController, MSALNati ) -> MSALNativeAuthFlowControllerResponse { switch result { case .verificationRequired(let token, let verifyHref, let resendHref, let sentTo, let channelType, let codeLength): - guard channelType.isEmailType else { + guard channelType.isEmailType || channelType.isSMSType else { let error = MSALNativeAuthFlowError(type: .generalError, errorDescription: MSALNativeAuthErrorMessage.generalError) stopTelemetryEvent(step.event, context: step.context, error: error) return response(.error(error: error), context: step.context, scenario: flowContinuationState.flowScenario) @@ -910,6 +940,42 @@ final class MSALNativeAuthFlowController: MSALNativeAuthBaseController, MSALNati ) } + private func makeAuthMethodSelectionContinuation( + from flowContinuationState: MSALNativeAuthFlowContinuationState, + continuationToken: String, + methods: [MSALNativeAuthV2ChallengeMethod] + ) -> MSALNativeAuthFlowContinuationState { + let resolver = MSALNativeAuthV2HrefURLResolver(config: config) + var resolvedLinks: [MSALNativeAuthV2LinkKey: URL] = [:] + for method in methods { + if let url = try? resolver.url(forHref: method.challengeHref) { + resolvedLinks[.method(id: method.id)] = url + } + } + return MSALNativeAuthFlowContinuationState( + flowScenario: flowContinuationState.flowScenario, + correlationId: flowContinuationState.correlationId, + continuationToken: continuationToken, + links: resolvedLinks, + scopes: flowContinuationState.scopes, + claimsRequestJson: flowContinuationState.claimsRequestJson + ) + } + + private func authMethodSelectionRequiredResponse( + flowContinuationState: MSALNativeAuthFlowContinuationState, + methods: [MSALNativeAuthV2ChallengeMethod], + step: MSALNativeAuthFlowStepContext + ) -> MSALNativeAuthFlowControllerResponse { + let internalState = MSALNativeAuthFlowInternalState(continuation: flowContinuationState, controller: self) + let state = MSALNativeAuthAuthMethodSelectionRequiredState( + internalState: internalState, + authMethods: publicAuthMethods(from: methods) + ) + stopTelemetryEvent(step.event, context: step.context) + return response(.actionRequired(state: state), context: step.context) + } + private func codeRequiredResponse( flowContinuationState: MSALNativeAuthFlowContinuationState, sentTo: String, @@ -928,6 +994,34 @@ final class MSALNativeAuthFlowController: MSALNativeAuthBaseController, MSALNati return response(.actionRequired(state: state), context: step.context) } + private func publicAuthMethods(from methods: [MSALNativeAuthV2ChallengeMethod]) -> [MSALAuthMethod] { + return methods.map { method in + MSALAuthMethod( + id: method.id, + challengeType: method.channelType.rawValue, + channelTargetType: MSALNativeAuthChannelType(value: method.channelType.rawValue), + loginHint: method.hint + ) + } + } + + private func passwordResetMethods(from methods: [MSALNativeAuthV2ChallengeMethod]) -> [MSALNativeAuthV2ChallengeMethod] { + return methods.filter { $0.channelType.isEmailType || $0.channelType.isSMSType } + } + + private func noValidAuthMethodResponse( + event: MSIDTelemetryAPIEvent?, + context: MSALNativeAuthRequestContext, + scenario: MSALNativeAuthFlowScenario + ) -> MSALNativeAuthFlowControllerResponse { + let error = MSALNativeAuthFlowError( + type: .generalError, + errorDescription: "No supported authentication method is available", + correlationId: context.correlationId() + ) + return interactionFailure(.error(error), event: event, context: context, scenario: scenario, newState: nil) + } + private func newPasswordRequiredResponse( flowContinuationState: MSALNativeAuthFlowContinuationState, step: MSALNativeAuthFlowStepContext diff --git a/MSAL/src/native_auth/controllers/v2/MSALNativeAuthFlowResponseDispatcher.swift b/MSAL/src/native_auth/controllers/v2/MSALNativeAuthFlowResponseDispatcher.swift index fa5234104f..2a5656ed32 100644 --- a/MSAL/src/native_auth/controllers/v2/MSALNativeAuthFlowResponseDispatcher.swift +++ b/MSAL/src/native_auth/controllers/v2/MSALNativeAuthFlowResponseDispatcher.swift @@ -93,6 +93,14 @@ struct MSALNativeAuthFlowResponseDispatcher { scenario: scenario) { await $0.onMFAVerificationRequired(state: state, scenario: scenario) } + case let state as MSALNativeAuthAuthMethodSelectionRequiredState: + await deliver(to: delegate, + delegateName: "MSALNativeAuthAuthMethodSelectionRequiredDelegate", + response: response, + as: MSALNativeAuthAuthMethodSelectionRequiredDelegate.self, + scenario: scenario) { + await $0.onAuthMethodSelectionRequired(state: state, scenario: scenario) + } case let state as MSALNativeAuthNewPasswordRequiredState: await deliver(to: delegate, delegateName: "MSALNativeAuthNewPasswordRequiredDelegate", diff --git a/MSAL/src/native_auth/network/responses/v2/parser/MSALNativeAuthV2ParsedResponses.swift b/MSAL/src/native_auth/network/responses/v2/parser/MSALNativeAuthV2ParsedResponses.swift index 0ce5802be5..eaeeb8370d 100644 --- a/MSAL/src/native_auth/network/responses/v2/parser/MSALNativeAuthV2ParsedResponses.swift +++ b/MSAL/src/native_auth/network/responses/v2/parser/MSALNativeAuthV2ParsedResponses.swift @@ -50,6 +50,7 @@ enum MSALNativeAuthV2AuthorizeChallengeParsedResponse: Equatable { enum MSALNativeAuthV2ChallengeMethodChannelType: String { case email + case sms case password /// Returns `true` if the channel is email. @@ -57,6 +58,11 @@ enum MSALNativeAuthV2ChallengeMethodChannelType: String { return rawValue.lowercased() == "email" } + /// Returns `true` if the channel is SMS. + var isSMSType: Bool { + return rawValue.lowercased() == "sms" + } + var isPasswordType: Bool { return rawValue.lowercased() == "password" } diff --git a/MSAL/src/native_auth/public/state_machine/v2/state/MSALNativeAuthAuthMethodSelectionRequiredState.swift b/MSAL/src/native_auth/public/state_machine/v2/state/MSALNativeAuthAuthMethodSelectionRequiredState.swift new file mode 100644 index 0000000000..0b53cf6e2a --- /dev/null +++ b/MSAL/src/native_auth/public/state_machine/v2/state/MSALNativeAuthAuthMethodSelectionRequiredState.swift @@ -0,0 +1,84 @@ +// +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import Foundation + +/// The server requires the user to select an authentication method. +/// Continue with ``selectAuthMethod(_:verificationContact:delegate:)``. +/// +/// - Warning: This API is experimental. It may be changed in the future without notice. Do not use in production applications. +@objcMembers +public class MSALNativeAuthAuthMethodSelectionRequiredState: MSALNativeAuthState { + + /// The authentication methods available for selection. + public let authMethods: [MSALAuthMethod] + + init(internalState: MSALNativeAuthFlowInternalState, authMethods: [MSALAuthMethod]) { + self.authMethods = authMethods + super.init(internalState: internalState) + } + + /// Select an authentication method. + public func selectAuthMethod( + _ method: MSALAuthMethod, + verificationContact: String?, + delegate: MSALNativeAuthFlowDelegate + ) { + run(delegate: delegate) { controller, state in + await controller.selectAuthMethod(method, verificationContact: verificationContact, state: state) + } + } + + /// Select an authentication method, without an explicit verification contact. + public func selectAuthMethod(_ method: MSALAuthMethod, delegate: MSALNativeAuthFlowDelegate) { + selectAuthMethod(method, verificationContact: nil, delegate: delegate) + } + + public override var description: String { + return "authMethodSelectionRequired" + } +} + +/// Per-state delegate for the ``MSALNativeAuthAuthMethodSelectionRequiredState`` step of a Native Auth V2 flow. +/// +/// Conform to this protocol (in addition to the terminal callbacks inherited from +/// ``MSALNativeAuthFlowDelegate``) to handle this state. Conforming is opt-in per state, but the +/// callback is required once you conform. +/// +/// - Warning: This API is experimental. It may be changed in the future without notice. Do not use in production applications. +@objc +public protocol MSALNativeAuthAuthMethodSelectionRequiredDelegate: MSALNativeAuthFlowDelegate { + + /// The server requires the user to select an authentication method. + /// Continue with ``MSALNativeAuthAuthMethodSelectionRequiredState/selectAuthMethod(_:verificationContact:delegate:)``. + /// - Parameters: + /// - state: The authentication-method-selection state (available auth methods). + /// - scenario: The flow that produced this callback. + /// - Note: If the app's delegate does not conform to this protocol, then + /// ``MSALNativeAuthFlowDelegate/onFlowError(error:scenario:)`` is called with error type `notImplemented`. + @MainActor func onAuthMethodSelectionRequired( + state: MSALNativeAuthAuthMethodSelectionRequiredState, + scenario: MSALNativeAuthFlowScenario + ) +} diff --git a/MSAL/test/integration/native_auth/end_to_end/reset_password/MSALNativeAuthResetPasswordV2EndToEndTests.swift b/MSAL/test/integration/native_auth/end_to_end/reset_password/MSALNativeAuthResetPasswordV2EndToEndTests.swift index 6b64c81eba..d46308537f 100644 --- a/MSAL/test/integration/native_auth/end_to_end/reset_password/MSALNativeAuthResetPasswordV2EndToEndTests.swift +++ b/MSAL/test/integration/native_auth/end_to_end/reset_password/MSALNativeAuthResetPasswordV2EndToEndTests.swift @@ -344,18 +344,21 @@ final class MSALNativeAuthResetPasswordV2EndToEndTests: MSALNativeAuthEndToEndBa /// before each continuation call. @MainActor private final class ResetPasswordV2DelegateSpy: NSObject, + MSALNativeAuthAuthMethodSelectionRequiredDelegate, MSALNativeAuthCodeRequiredDelegate, MSALNativeAuthNewPasswordRequiredDelegate, MSALNativeAuthSignInAfterResetPasswordRequiredDelegate { private var expectation: XCTestExpectation private(set) var onCodeRequiredCalled = false + private(set) var onAuthMethodSelectionRequiredCalled = false private(set) var onNewPasswordRequiredCalled = false private(set) var onSignInAfterResetPasswordRequiredCalled = false private(set) var onFlowCompletedCalled = false private(set) var onFlowErrorCalled = false private(set) var codeRequiredState: MSALNativeAuthCodeRequiredState? + private(set) var authMethodSelectionRequiredState: MSALNativeAuthAuthMethodSelectionRequiredState? private(set) var newPasswordRequiredState: MSALNativeAuthNewPasswordRequiredState? private(set) var signInAfterResetPasswordState: MSALNativeAuthSignInAfterResetPasswordState? private(set) var result: MSALNativeAuthUserAccountResult? @@ -373,11 +376,13 @@ private final class ResetPasswordV2DelegateSpy: NSObject, func reset(expectation: XCTestExpectation) { self.expectation = expectation onCodeRequiredCalled = false + onAuthMethodSelectionRequiredCalled = false onNewPasswordRequiredCalled = false onSignInAfterResetPasswordRequiredCalled = false onFlowCompletedCalled = false onFlowErrorCalled = false codeRequiredState = nil + authMethodSelectionRequiredState = nil newPasswordRequiredState = nil signInAfterResetPasswordState = nil result = nil @@ -388,6 +393,22 @@ private final class ResetPasswordV2DelegateSpy: NSObject, codeLength = 0 } + func onAuthMethodSelectionRequired( + state: MSALNativeAuthAuthMethodSelectionRequiredState, + scenario: MSALNativeAuthFlowScenario + ) { + onAuthMethodSelectionRequiredCalled = true + authMethodSelectionRequiredState = state + self.scenario = scenario + + guard let emailMethod = state.authMethods.first(where: { $0.channelTargetType.isEmailType }) else { + expectation.fulfill() + return + } + + state.selectAuthMethod(emailMethod, delegate: self) + } + func onCodeRequired(state: MSALNativeAuthCodeRequiredState, scenario: MSALNativeAuthFlowScenario) { onCodeRequiredCalled = true codeRequiredState = state diff --git a/MSAL/test/unit/native_auth/controllers/v2/MSALNativeAuthFlowControllerTests.swift b/MSAL/test/unit/native_auth/controllers/v2/MSALNativeAuthFlowControllerTests.swift index 8a7d13a1bb..0f6f4b3436 100644 --- a/MSAL/test/unit/native_auth/controllers/v2/MSALNativeAuthFlowControllerTests.swift +++ b/MSAL/test/unit/native_auth/controllers/v2/MSALNativeAuthFlowControllerTests.swift @@ -57,7 +57,7 @@ final class MSALNativeAuthFlowControllerTests: MSALNativeAuthTestCase { private func makeState( links: [MSALNativeAuthV2LinkRelation: URL] = [:], - continuationToken: String = "ct", + continuationToken: String? = "ct", correlationId: UUID = UUID() ) -> MSALNativeAuthFlowInternalState { let continuation = MSALNativeAuthFlowContinuationState( @@ -69,6 +69,24 @@ final class MSALNativeAuthFlowControllerTests: MSALNativeAuthTestCase { return MSALNativeAuthFlowInternalState(continuation: continuation, controller: sut) } + private func makeAuthMethodSelectionState( + methodLinks: [String: URL], + continuationToken: String? = "ct", + correlationId: UUID = UUID() + ) -> MSALNativeAuthFlowInternalState { + var links: [MSALNativeAuthV2LinkKey: URL] = [:] + for (id, url) in methodLinks { + links[.method(id: id)] = url + } + let continuation = MSALNativeAuthFlowContinuationState( + flowScenario: .passwordReset, + correlationId: correlationId, + continuationToken: continuationToken, + links: links + ) + return MSALNativeAuthFlowInternalState(continuation: continuation, controller: sut) + } + private func relationLinks(_ links: [MSALNativeAuthV2LinkRelation: URL]) -> [MSALNativeAuthV2LinkKey: URL] { links.reduce(into: [:]) { result, entry in result[.relation(entry.key)] = entry.value @@ -164,6 +182,25 @@ final class MSALNativeAuthFlowControllerTests: MSALNativeAuthTestCase { XCTAssertTrue(error.isGeneralError) } + func test_resetPassword_whenNoMethods_returnsExplicitError() async { + requestProviderMock.mockRequest() + parserMock.authorizeChallengeResponses = [ + .continuationToken(continuationToken: "ct-authorization-challenge", href: "https://contoso.com/reset") + ] + parserMock.interactionResponses = [ + .challengeRequired(continuationToken: "ct-2", methods: []) + ] + + let response = await sut.resetPassword(parameters: resetPasswordParameters()) + + guard case .error(let error) = response.result else { + return XCTFail("Expected error, got \(response.result)") + } + XCTAssertTrue(error.isGeneralError) + XCTAssertEqual(error.errorDescription, "No supported authentication method is available") + XCTAssertFalse(requestProviderMock.challengeCalled) + } + func test_resetPassword_whenMultipleMethodsIncludeEmail_selectsEmailMethod() async { requestProviderMock.mockRequest() parserMock.authorizeChallengeResponses = [ @@ -199,6 +236,200 @@ final class MSALNativeAuthFlowControllerTests: MSALNativeAuthTestCase { XCTAssertEqual(requestProviderMock.challengeHrefReceived, "https://contoso.com/email/challenge") } + func test_resetPassword_whenSingleSMSMethod_selectsSMSMethod() async { + requestProviderMock.mockRequest() + parserMock.authorizeChallengeResponses = [ + .continuationToken(continuationToken: "ct-authorization-challenge", href: "https://contoso.com/reset") + ] + parserMock.interactionResponses = [ + .challengeRequired( + continuationToken: "ct-2", + methods: [ + MSALNativeAuthV2ChallengeMethod( + id: "sms-id", + channelType: .sms, + hint: "+1********00", + challengeHref: "https://contoso.com/sms/challenge" + ) + ] + ), + .verificationRequired( + continuationToken: "ct-3", + verifyHref: "https://contoso.com/sms/verify", + resendHref: "https://contoso.com/sms/resend", + sentTo: "+1********00", + channelType: MSALNativeAuthChannelType(value: "sms"), + codeLength: 6 + ) + ] + + let response = await sut.resetPassword(parameters: resetPasswordParameters()) + + guard case .actionRequired(let state) = response.result else { + return XCTFail("Expected actionRequired, got \(response.result)") + } + guard let codeRequiredState = state as? MSALNativeAuthCodeRequiredState else { + return XCTFail("Expected codeRequired state, got \(state)") + } + XCTAssertEqual(codeRequiredState.sentTo, "+1********00") + XCTAssertTrue(codeRequiredState.channel.isSMSType) + XCTAssertEqual(codeRequiredState.codeLength, 6) + XCTAssertEqual(requestProviderMock.challengeHrefReceived, "https://contoso.com/sms/challenge") + } + + func test_resetPassword_whenEmailAndSMSMethods_returnsAuthMethodSelectionRequired() async { + requestProviderMock.mockRequest() + parserMock.authorizeChallengeResponses = [ + .continuationToken(continuationToken: "ct-authorization-challenge", href: "https://contoso.com/reset") + ] + parserMock.interactionResponses = [ + .challengeRequired( + continuationToken: "ct-2", + methods: [ + MSALNativeAuthV2ChallengeMethod( + id: "email-id", + channelType: .email, + hint: "u***@contoso.com", + challengeHref: "https://contoso.com/email/challenge" + ), + MSALNativeAuthV2ChallengeMethod( + id: "sms-id", + channelType: .sms, + hint: "+1********00", + challengeHref: "https://contoso.com/sms/challenge" + ) + ] + ) + ] + + let response = await sut.resetPassword(parameters: resetPasswordParameters()) + + guard case .actionRequired(let state) = response.result else { + return XCTFail("Expected actionRequired, got \(response.result)") + } + guard let selectionState = state as? MSALNativeAuthAuthMethodSelectionRequiredState else { + return XCTFail("Expected authMethodSelectionRequired state, got \(state)") + } + XCTAssertEqual(selectionState.authMethods.count, 2) + XCTAssertEqual(selectionState.authMethods[0].id, "email-id") + XCTAssertTrue(selectionState.authMethods[0].channelTargetType.isEmailType) + XCTAssertEqual(selectionState.authMethods[1].id, "sms-id") + XCTAssertTrue(selectionState.authMethods[1].channelTargetType.isSMSType) + XCTAssertEqual(selectionState.internalState.continuation.flowScenario, .passwordReset) + XCTAssertEqual(selectionState.internalState.continuation.continuationToken, "ct-2") + XCTAssertFalse(requestProviderMock.challengeCalled) + } + + // MARK: - selectAuthMethod (password reset) + + func test_selectAuthMethod_passwordReset_whenEmailCodeRequired_returnsCodeRequired() async { + requestProviderMock.mockRequest() + parserMock.interactionResponses = [ + .verificationRequired( + continuationToken: "ct-otp", + verifyHref: "https://contoso.com/email/verify", + resendHref: "https://contoso.com/email/resend", + sentTo: "u***@contoso.com", + channelType: MSALNativeAuthChannelType(value: "email"), + codeLength: 8 + ) + ] + let method = MSALAuthMethod( + id: "email-id", + challengeType: "email", + channelTargetType: MSALNativeAuthChannelType(value: "email"), + loginHint: "u***@contoso.com" + ) + let state = makeAuthMethodSelectionState(methodLinks: ["email-id": URL(string: "https://contoso.com/email/challenge")!]) + + let response = await sut.selectAuthMethod(method, verificationContact: nil, state: state) + + guard case .actionRequired(let state) = response.result else { + return XCTFail("Expected actionRequired, got \(response.result)") + } + guard let codeRequiredState = state as? MSALNativeAuthCodeRequiredState else { + return XCTFail("Expected codeRequired state, got \(state)") + } + XCTAssertEqual(codeRequiredState.sentTo, "u***@contoso.com") + XCTAssertTrue(codeRequiredState.channel.isEmailType) + XCTAssertEqual(requestProviderMock.challengeHrefReceived, "https://contoso.com/email/challenge") + XCTAssertEqual(requestProviderMock.challengeApiIdReceived, .telemetryApiIdV2ResetPasswordStart) + XCTAssertEqual(codeRequiredState.internalState.continuation.flowScenario, .passwordReset) + } + + func test_selectAuthMethod_passwordReset_whenSMSCodeRequired_returnsCodeRequired() async { + requestProviderMock.mockRequest() + parserMock.interactionResponses = [ + .verificationRequired( + continuationToken: "ct-otp", + verifyHref: "https://contoso.com/sms/verify", + resendHref: "https://contoso.com/sms/resend", + sentTo: "+1********00", + channelType: MSALNativeAuthChannelType(value: "sms"), + codeLength: 6 + ) + ] + let method = MSALAuthMethod( + id: "sms-id", + challengeType: "sms", + channelTargetType: MSALNativeAuthChannelType(value: "sms"), + loginHint: "+1********00" + ) + let state = makeAuthMethodSelectionState(methodLinks: ["sms-id": URL(string: "https://contoso.com/sms/challenge")!]) + + let response = await sut.selectAuthMethod(method, verificationContact: nil, state: state) + + guard case .actionRequired(let state) = response.result else { + return XCTFail("Expected actionRequired, got \(response.result)") + } + guard let codeRequiredState = state as? MSALNativeAuthCodeRequiredState else { + return XCTFail("Expected codeRequired state, got \(state)") + } + XCTAssertEqual(codeRequiredState.sentTo, "+1********00") + XCTAssertTrue(codeRequiredState.channel.isSMSType) + XCTAssertEqual(codeRequiredState.codeLength, 6) + XCTAssertEqual(requestProviderMock.challengeHrefReceived, "https://contoso.com/sms/challenge") + } + + func test_selectAuthMethod_passwordReset_whenChallengeLinkMissing_returnsError() async { + requestProviderMock.mockRequest() + let method = MSALAuthMethod( + id: "unknown-id", + challengeType: "sms", + channelTargetType: MSALNativeAuthChannelType(value: "sms"), + loginHint: "+1********00" + ) + let state = makeAuthMethodSelectionState(methodLinks: [:]) + + let response = await sut.selectAuthMethod(method, verificationContact: nil, state: state) + + guard case .error = response.result else { + return XCTFail("Expected error, got \(response.result)") + } + XCTAssertFalse(requestProviderMock.challengeCalled) + } + + func test_selectAuthMethod_passwordReset_whenContinuationTokenMissing_returnsError() async { + requestProviderMock.mockRequest() + let method = MSALAuthMethod( + id: "sms-id", + challengeType: "sms", + channelTargetType: MSALNativeAuthChannelType(value: "sms"), + loginHint: "+1********00" + ) + let state = makeAuthMethodSelectionState( + methodLinks: ["sms-id": URL(string: "https://contoso.com/sms/challenge")!], + continuationToken: nil + ) + + let response = await sut.selectAuthMethod(method, verificationContact: nil, state: state) + + guard case .error = response.result else { + return XCTFail("Expected error, got \(response.result)") + } + XCTAssertFalse(requestProviderMock.challengeCalled) + } + // MARK: - submitCode func test_submitCode_whenUpdateRequired_returnsNewPasswordRequired() async { @@ -402,7 +633,7 @@ final class MSALNativeAuthFlowControllerTests: MSALNativeAuthTestCase { XCTAssertTrue(requestProviderMock.challengeCalled) } - func test_resendCode_whenChannelNotEmail_returnsError() async { + func test_resendCode_whenChannelIsSMS_returnsCodeRequired() async { requestProviderMock.mockRequest() parserMock.interactionResponses = [ .verificationRequired( @@ -418,6 +649,32 @@ final class MSALNativeAuthFlowControllerTests: MSALNativeAuthTestCase { let response = await sut.resendCode(state: state) + guard case .actionRequired(let state) = response.result else { + return XCTFail("Expected actionRequired, got \(response.result)") + } + guard let codeRequiredState = state as? MSALNativeAuthCodeRequiredState else { + return XCTFail("Expected codeRequired state, got \(state)") + } + XCTAssertTrue(codeRequiredState.channel.isSMSType) + XCTAssertTrue(requestProviderMock.challengeCalled) + } + + func test_resendCode_whenChannelIsPassword_returnsError() async { + requestProviderMock.mockRequest() + parserMock.interactionResponses = [ + .verificationRequired( + continuationToken: "ct-3", + verifyHref: "https://contoso.com/verify", + resendHref: "https://contoso.com/resend", + sentTo: "", + channelType: MSALNativeAuthChannelType(value: "password"), + codeLength: 0 + ) + ] + let state = makeState(links: [.resend: URL(string: "https://contoso.com/resend")!]) + + let response = await sut.resendCode(state: state) + guard case .error(let error) = response.result else { return XCTFail("Expected error, got \(response.result)") } diff --git a/MSAL/test/unit/native_auth/controllers/v2/MSALNativeAuthFlowResponseDispatcherTests.swift b/MSAL/test/unit/native_auth/controllers/v2/MSALNativeAuthFlowResponseDispatcherTests.swift index b22a3e201e..0cd614f986 100644 --- a/MSAL/test/unit/native_auth/controllers/v2/MSALNativeAuthFlowResponseDispatcherTests.swift +++ b/MSAL/test/unit/native_auth/controllers/v2/MSALNativeAuthFlowResponseDispatcherTests.swift @@ -116,6 +116,36 @@ final class MSALNativeAuthFlowResponseDispatcherTests: XCTestCase { assertTelemetrySuccess(telemetryResult) } + func test_dispatch_authMethodSelectionRequired_conformingDelegate_callsTypedCallbackAndTelemetry() async { + let delegate = AuthMethodSelectionRequiredDelegateSpy() + let internalState = makeInternalState(scenario: .passwordReset) + let state = MSALNativeAuthAuthMethodSelectionRequiredState( + internalState: internalState, + authMethods: [ + MSALAuthMethod( + id: "sms-id", + challengeType: "sms", + channelTargetType: MSALNativeAuthChannelType(value: "sms"), + loginHint: "+1********00" + ) + ] + ) + var telemetryResult: Result? + let response = MSALNativeAuthFlowControllerResponse( + .actionRequired(state: state), + correlationId: UUID(), + scenario: .unknown, + telemetryUpdate: { telemetryResult = $0 } + ) + + await sut.dispatch(response, delegate: delegate) + + XCTAssertTrue(delegate.authMethodSelectionRequiredState === state) + XCTAssertEqual(delegate.authMethodSelectionRequiredScenario, .passwordReset) + XCTAssertNil(delegate.error) + assertTelemetrySuccess(telemetryResult) + } + func test_dispatch_signInAfterResetPassword_callsTypedCallbackAndTelemetry() async { let delegate = V2SignInAfterResetPasswordDelegateSpy() let internalState = makeInternalState(scenario: .passwordReset) @@ -184,6 +214,33 @@ final class MSALNativeAuthFlowResponseDispatcherTests: XCTestCase { ) } + func test_dispatch_authMethodSelectionRequired_nonConformingDelegate_callsNotImplementedWithScenarioAndCorrelation() async { + let correlationId = UUID() + let state = MSALNativeAuthAuthMethodSelectionRequiredState( + internalState: makeInternalState(scenario: .passwordReset), + authMethods: [] + ) + let delegate = BaseDelegateSpy() + var telemetryFired = false + let response = MSALNativeAuthFlowControllerResponse( + .actionRequired(state: state), + correlationId: correlationId, + scenario: .unknown, + telemetryUpdate: { _ in telemetryFired = true } + ) + + await sut.dispatch(response, delegate: delegate) + + XCTAssertEqual(delegate.errorScenario, .passwordReset) + XCTAssertTrue(delegate.error?.isNotImplemented ?? false) + XCTAssertEqual(delegate.error?.correlationId, correlationId) + XCTAssertEqual( + delegate.error?.errorDescription, + String(format: MSALNativeAuthErrorMessage.delegateNotImplemented, "MSALNativeAuthAuthMethodSelectionRequiredDelegate") + ) + XCTAssertFalse(telemetryFired) + } + func test_dispatch_newPasswordRequired_nonConformingDelegate_callsNotImplementedWithDelegateName() async { let state = MSALNativeAuthNewPasswordRequiredState(internalState: makeInternalState(scenario: .passwordReset)) await assertNotImplemented( @@ -283,6 +340,20 @@ private final class CodeRequiredDelegateSpy: BaseDelegateSpy, MSALNativeAuthCode } } +private final class AuthMethodSelectionRequiredDelegateSpy: BaseDelegateSpy, MSALNativeAuthAuthMethodSelectionRequiredDelegate { + + var authMethodSelectionRequiredState: MSALNativeAuthAuthMethodSelectionRequiredState? + var authMethodSelectionRequiredScenario: MSALNativeAuthFlowScenario? + + func onAuthMethodSelectionRequired( + state: MSALNativeAuthAuthMethodSelectionRequiredState, + scenario: MSALNativeAuthFlowScenario + ) { + authMethodSelectionRequiredState = state + authMethodSelectionRequiredScenario = scenario + } +} + private final class V2SignInAfterResetPasswordDelegateSpy: BaseDelegateSpy, MSALNativeAuthSignInAfterResetPasswordRequiredDelegate { var signInAfterResetPasswordState: MSALNativeAuthSignInAfterResetPasswordState? diff --git a/MSAL/test/unit/native_auth/mock/v2/MSALNativeAuthFlowControllerMock.swift b/MSAL/test/unit/native_auth/mock/v2/MSALNativeAuthFlowControllerMock.swift index e03fe6ee81..11f293e460 100644 --- a/MSAL/test/unit/native_auth/mock/v2/MSALNativeAuthFlowControllerMock.swift +++ b/MSAL/test/unit/native_auth/mock/v2/MSALNativeAuthFlowControllerMock.swift @@ -37,6 +37,9 @@ class MSALNativeAuthFlowControllerMock: MSALNativeAuthFlowControlling { var signInAfterResetPasswordResponse: MSALNativeAuthFlowControllerResponse? var submitAttributesResponse: MSALNativeAuthFlowControllerResponse? var selectAuthMethodResponse: MSALNativeAuthFlowControllerResponse? + private(set) var selectedAuthMethod: MSALAuthMethod? + private(set) var selectedVerificationContact: String? + private(set) var selectedAuthMethodState: MSALNativeAuthFlowInternalState? var submitChallengeResponse: MSALNativeAuthFlowControllerResponse? var resendCodeResponse: MSALNativeAuthFlowControllerResponse? @@ -88,6 +91,9 @@ class MSALNativeAuthFlowControllerMock: MSALNativeAuthFlowControlling { verificationContact: String?, state: MSALNativeAuthFlowInternalState ) async -> MSALNativeAuthFlowControllerResponse { + selectedAuthMethod = method + selectedVerificationContact = verificationContact + selectedAuthMethodState = state return selectAuthMethodResponse ?? notImplementedResponse() } diff --git a/MSAL/test/unit/native_auth/network/v2/MSALNativeAuthV2ResponseParserTests.swift b/MSAL/test/unit/native_auth/network/v2/MSALNativeAuthV2ResponseParserTests.swift index ae57c4163f..79e76859cc 100644 --- a/MSAL/test/unit/native_auth/network/v2/MSALNativeAuthV2ResponseParserTests.swift +++ b/MSAL/test/unit/native_auth/network/v2/MSALNativeAuthV2ResponseParserTests.swift @@ -186,6 +186,34 @@ final class MSALNativeAuthV2ResponseParserTests: XCTestCase { )) } + func test_parseInteraction_challengeAction_singleSMS_returnsChallengeRequired() { + let method = MSALNativeAuthHALChallengeResponse.EmbeddedMethod( + id: "sms-id", + type: "sms", + hint: "+1********00", + links: ["challenge": "https://contoso.com/sms/challenge"] + ) + let response = makeResponse( + state: "interactionRequired", + action: "challenge", + continuationToken: "ct", + methods: [method], + authenticationFactor: "singleFactor" + ) + let result = sut.parseInteraction(context: context, .success(response)) + XCTAssertEqual(result, .challengeRequired( + continuationToken: "ct", + methods: [ + MSALNativeAuthV2ChallengeMethod( + id: "sms-id", + channelType: .sms, + hint: "+1********00", + challengeHref: "https://contoso.com/sms/challenge" + ) + ] + )) + } + func test_parseInteraction_challengeAction_multiFactor_returnsMFARequired() { let method = MSALNativeAuthHALChallengeResponse.EmbeddedMethod(id: "1", type: "email", hint: "u***@contoso.com", links: ["challenge": "https://contoso.com/challenge"]) let response = makeResponse(state: "interactionRequired", action: "challenge", continuationToken: "ct", methods: [method], authenticationFactor: "multiFactor") @@ -210,10 +238,61 @@ final class MSALNativeAuthV2ResponseParserTests: XCTestCase { )) } + func test_parseInteraction_challengeAction_singleFactorWithEmailAndSMS_returnsAllMethods() { + let emailMethod = MSALNativeAuthHALChallengeResponse.EmbeddedMethod( + id: "email-id", + type: "email", + hint: "u***@contoso.com", + links: ["challenge": "https://contoso.com/email/challenge"] + ) + let smsMethod = MSALNativeAuthHALChallengeResponse.EmbeddedMethod( + id: "sms-id", + type: "sms", + hint: "+1********00", + links: ["challenge": "https://contoso.com/sms/challenge"] + ) + let response = makeResponse( + state: "interactionRequired", + action: "challenge", + continuationToken: "ct", + methods: [emailMethod, smsMethod], + authenticationFactor: "singleFactor" + ) + let result = sut.parseInteraction(context: context, .success(response)) + XCTAssertEqual(result, .challengeRequired( + continuationToken: "ct", + methods: [ + MSALNativeAuthV2ChallengeMethod( + id: "email-id", + channelType: .email, + hint: "u***@contoso.com", + challengeHref: "https://contoso.com/email/challenge" + ), + MSALNativeAuthV2ChallengeMethod( + id: "sms-id", + channelType: .sms, + hint: "+1********00", + challengeHref: "https://contoso.com/sms/challenge" + ) + ] + )) + } + func test_parseInteraction_challengeAction_withUnrecognizedMethodType_returnsError() { let validMethod = MSALNativeAuthHALChallengeResponse.EmbeddedMethod(id: "1", type: "password", hint: "", links: ["challenge": "https://contoso.com/password/challenge"]) - let unsupportedMethod = MSALNativeAuthHALChallengeResponse.EmbeddedMethod(id: "2", type: "sms", hint: "+1********00", links: ["challenge": "https://contoso.com/sms/challenge"]) - let response = makeResponse(state: "interactionRequired", action: "challenge", continuationToken: "ct", methods: [validMethod, unsupportedMethod], authenticationFactor: "singleFactor") + let unsupportedMethod = MSALNativeAuthHALChallengeResponse.EmbeddedMethod( + id: "2", + type: "phone", + hint: "+1********00", + links: ["challenge": "https://contoso.com/phone/challenge"] + ) + let response = makeResponse( + state: "interactionRequired", + action: "challenge", + continuationToken: "ct", + methods: [validMethod, unsupportedMethod], + authenticationFactor: "singleFactor" + ) let result = sut.parseInteraction(context: context, .success(response)) XCTAssertEqual(result, .error(MSALNativeAuthFlowError(type: .generalError))) } diff --git a/MSAL/test/unit/native_auth/public/state_machine/v2/MSALNativeAuthAuthMethodSelectionRequiredStateTests.swift b/MSAL/test/unit/native_auth/public/state_machine/v2/MSALNativeAuthAuthMethodSelectionRequiredStateTests.swift new file mode 100644 index 0000000000..c7b68d81bb --- /dev/null +++ b/MSAL/test/unit/native_auth/public/state_machine/v2/MSALNativeAuthAuthMethodSelectionRequiredStateTests.swift @@ -0,0 +1,109 @@ +// +// Copyright (c) Microsoft Corporation. +// All rights reserved. +// +// This code is licensed under the MIT License. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files(the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and / or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions : +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. + +import XCTest +@testable import MSAL + +final class MSALNativeAuthAuthMethodSelectionRequiredStateTests: XCTestCase { + + func test_state_exposesAuthMethods() { + let methods = [ + MSALAuthMethod( + id: "email-id", + challengeType: "email", + channelTargetType: MSALNativeAuthChannelType(value: "email"), + loginHint: "u***@contoso.com" + ), + MSALAuthMethod(id: "sms-id", challengeType: "sms", channelTargetType: MSALNativeAuthChannelType(value: "sms"), loginHint: "+1********00") + ] + let state = makeState(methods: methods) + + XCTAssertEqual(state.authMethods.count, 2) + XCTAssertEqual(state.authMethods[0].id, "email-id") + XCTAssertEqual(state.authMethods[0].loginHint, "u***@contoso.com") + XCTAssertEqual(state.authMethods[1].id, "sms-id") + XCTAssertTrue(state.authMethods[1].channelTargetType.isSMSType) + XCTAssertEqual(state.description, "authMethodSelectionRequired") + } + + func test_selectAuthMethod_forwardsSelectionToController() async { + let expectation = expectation(description: "delegate called") + let controller = MSALNativeAuthFlowControllerMock() + let method = MSALAuthMethod( + id: "sms-id", + challengeType: "sms", + channelTargetType: MSALNativeAuthChannelType(value: "sms"), + loginHint: "+1********00" + ) + controller.selectAuthMethodResponse = MSALNativeAuthFlowControllerResponse( + .error(error: MSALNativeAuthFlowError(type: .generalError)), + correlationId: UUID(), + scenario: .passwordReset + ) + let state = makeState(methods: [method], controller: controller) + let delegate = FlowDelegateSpy(expectation: expectation) + + state.selectAuthMethod(method, verificationContact: "+1********00", delegate: delegate) + + await fulfillment(of: [expectation]) + XCTAssertTrue(controller.selectedAuthMethod === method) + XCTAssertEqual(controller.selectedVerificationContact, "+1********00") + XCTAssertTrue(controller.selectedAuthMethodState === state.internalState) + XCTAssertEqual(delegate.errorScenario, .passwordReset) + } + + private func makeState( + methods: [MSALAuthMethod], + controller: MSALNativeAuthFlowControllerMock = MSALNativeAuthFlowControllerMock() + ) -> MSALNativeAuthAuthMethodSelectionRequiredState { + let continuation = MSALNativeAuthFlowContinuationState( + flowScenario: .passwordReset, + correlationId: UUID(), + continuationToken: "ct", + links: [:] + ) + let internalState = MSALNativeAuthFlowInternalState(continuation: continuation, controller: controller) + return MSALNativeAuthAuthMethodSelectionRequiredState(internalState: internalState, authMethods: methods) + } +} + +private final class FlowDelegateSpy: NSObject, MSALNativeAuthFlowDelegate { + + private let expectation: XCTestExpectation + private(set) var errorScenario: MSALNativeAuthFlowScenario? + + init(expectation: XCTestExpectation) { + self.expectation = expectation + super.init() + } + + func onFlowCompleted(result: MSALNativeAuthUserAccountResult, scenario: MSALNativeAuthFlowScenario) { + expectation.fulfill() + } + + func onFlowError(error: MSALNativeAuthFlowError, scenario: MSALNativeAuthFlowScenario) { + errorScenario = scenario + expectation.fulfill() + } +} From 90d5e5aacbe11bf2aa98e930240f3fa4deb08018 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:39:58 +0000 Subject: [PATCH 3/6] Handle invalid SSPR method links Co-authored-by: antrix1989 <1989385+antrix1989@users.noreply.github.com> --- .../v2/MSALNativeAuthFlowController.swift | 56 +++++++++++-------- .../MSALNativeAuthFlowControllerTests.swift | 35 ++++++++++++ 2 files changed, 68 insertions(+), 23 deletions(-) diff --git a/MSAL/src/native_auth/controllers/v2/MSALNativeAuthFlowController.swift b/MSAL/src/native_auth/controllers/v2/MSALNativeAuthFlowController.swift index aa2e4ea2ed..72cf3249c7 100644 --- a/MSAL/src/native_auth/controllers/v2/MSALNativeAuthFlowController.swift +++ b/MSAL/src/native_auth/controllers/v2/MSALNativeAuthFlowController.swift @@ -174,23 +174,24 @@ final class MSALNativeAuthFlowController: MSALNativeAuthBaseController, MSALNati return noValidAuthMethodResponse(event: event, context: context, scenario: flowScenario) } + let continuation = MSALNativeAuthFlowContinuationState( + flowScenario: flowScenario, + correlationId: context.correlationId(), + continuationToken: challengeContinuationToken, + links: [:] + ) + if validMethods.count > 1 { - let continuation = makeAuthMethodSelectionContinuation( - from: MSALNativeAuthFlowContinuationState( - flowScenario: flowScenario, - correlationId: context.correlationId(), - continuationToken: challengeContinuationToken, - links: [:] - ), + guard let selectionContinuation = makeAuthMethodSelectionContinuation( + from: continuation, continuationToken: challengeContinuationToken, methods: validMethods - ) - let step = MSALNativeAuthFlowStepContext( - apiId: .telemetryApiIdV2ResetPasswordStart, - event: event, - context: context - ) - return authMethodSelectionRequiredResponse(flowContinuationState: continuation, methods: validMethods, step: step) + ) else { + return invalidAuthMethodLinkResponse(event: event, context: context, scenario: flowScenario) + } + + let step = MSALNativeAuthFlowStepContext(apiId: .telemetryApiIdV2ResetPasswordStart, event: event, context: context) + return authMethodSelectionRequiredResponse(flowContinuationState: selectionContinuation, methods: validMethods, step: step) } let method = validMethods[0] @@ -203,12 +204,6 @@ final class MSALNativeAuthFlowController: MSALNativeAuthBaseController, MSALNati ) } - let continuation = MSALNativeAuthFlowContinuationState( - flowScenario: flowScenario, - correlationId: context.correlationId(), - continuationToken: challengeContinuationToken, - links: [:] - ) let step = MSALNativeAuthFlowStepContext(apiId: .telemetryApiIdV2ResetPasswordStart, event: event, context: context) return await handlePasswordResetChallengeResult(challengeResult, flowContinuationState: continuation, step: step) } @@ -944,12 +939,14 @@ final class MSALNativeAuthFlowController: MSALNativeAuthBaseController, MSALNati from flowContinuationState: MSALNativeAuthFlowContinuationState, continuationToken: String, methods: [MSALNativeAuthV2ChallengeMethod] - ) -> MSALNativeAuthFlowContinuationState { + ) -> MSALNativeAuthFlowContinuationState? { let resolver = MSALNativeAuthV2HrefURLResolver(config: config) var resolvedLinks: [MSALNativeAuthV2LinkKey: URL] = [:] for method in methods { - if let url = try? resolver.url(forHref: method.challengeHref) { - resolvedLinks[.method(id: method.id)] = url + do { + resolvedLinks[.method(id: method.id)] = try resolver.url(forHref: method.challengeHref) + } catch { + return nil } } return MSALNativeAuthFlowContinuationState( @@ -962,6 +959,19 @@ final class MSALNativeAuthFlowController: MSALNativeAuthBaseController, MSALNati ) } + private func invalidAuthMethodLinkResponse( + event: MSIDTelemetryAPIEvent?, + context: MSALNativeAuthRequestContext, + scenario: MSALNativeAuthFlowScenario + ) -> MSALNativeAuthFlowControllerResponse { + let error = MSALNativeAuthFlowError( + type: .generalError, + errorDescription: "Invalid challenge link for authentication method", + correlationId: context.correlationId() + ) + return interactionFailure(.error(error), event: event, context: context, scenario: scenario, newState: nil) + } + private func authMethodSelectionRequiredResponse( flowContinuationState: MSALNativeAuthFlowContinuationState, methods: [MSALNativeAuthV2ChallengeMethod], diff --git a/MSAL/test/unit/native_auth/controllers/v2/MSALNativeAuthFlowControllerTests.swift b/MSAL/test/unit/native_auth/controllers/v2/MSALNativeAuthFlowControllerTests.swift index 0f6f4b3436..ec522d0179 100644 --- a/MSAL/test/unit/native_auth/controllers/v2/MSALNativeAuthFlowControllerTests.swift +++ b/MSAL/test/unit/native_auth/controllers/v2/MSALNativeAuthFlowControllerTests.swift @@ -320,6 +320,41 @@ final class MSALNativeAuthFlowControllerTests: MSALNativeAuthTestCase { XCTAssertFalse(requestProviderMock.challengeCalled) } + func test_resetPassword_whenMultipleMethodsContainInvalidChallengeLink_returnsError() async { + requestProviderMock.mockRequest() + parserMock.authorizeChallengeResponses = [ + .continuationToken(continuationToken: "ct-authorization-challenge", href: "https://contoso.com/reset") + ] + parserMock.interactionResponses = [ + .challengeRequired( + continuationToken: "ct-2", + methods: [ + MSALNativeAuthV2ChallengeMethod( + id: "email-id", + channelType: .email, + hint: "u***@contoso.com", + challengeHref: "https://contoso.com/email/challenge" + ), + MSALNativeAuthV2ChallengeMethod( + id: "sms-id", + channelType: .sms, + hint: "+1********00", + challengeHref: "https://exa mple.com/sms/challenge" + ) + ] + ) + ] + + let response = await sut.resetPassword(parameters: resetPasswordParameters()) + + guard case .error(let error) = response.result else { + return XCTFail("Expected error, got \(response.result)") + } + XCTAssertTrue(error.isGeneralError) + XCTAssertEqual(error.errorDescription, "Invalid challenge link for authentication method") + XCTAssertFalse(requestProviderMock.challengeCalled) + } + // MARK: - selectAuthMethod (password reset) func test_selectAuthMethod_passwordReset_whenEmailCodeRequired_returnsCodeRequired() async { From 676c0556f6061e589c5ebdb6b300a082df34bdd0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:40:51 +0000 Subject: [PATCH 4/6] Address method selection review feedback Co-authored-by: antrix1989 <1989385+antrix1989@users.noreply.github.com> --- .../controllers/v2/MSALNativeAuthFlowController.swift | 6 ++++-- .../MSALNativeAuthAuthMethodSelectionRequiredState.swift | 5 +++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/MSAL/src/native_auth/controllers/v2/MSALNativeAuthFlowController.swift b/MSAL/src/native_auth/controllers/v2/MSALNativeAuthFlowController.swift index 72cf3249c7..9483cd6ede 100644 --- a/MSAL/src/native_auth/controllers/v2/MSALNativeAuthFlowController.swift +++ b/MSAL/src/native_auth/controllers/v2/MSALNativeAuthFlowController.swift @@ -184,7 +184,6 @@ final class MSALNativeAuthFlowController: MSALNativeAuthBaseController, MSALNati if validMethods.count > 1 { guard let selectionContinuation = makeAuthMethodSelectionContinuation( from: continuation, - continuationToken: challengeContinuationToken, methods: validMethods ) else { return invalidAuthMethodLinkResponse(event: event, context: context, scenario: flowScenario) @@ -937,9 +936,12 @@ final class MSALNativeAuthFlowController: MSALNativeAuthBaseController, MSALNati private func makeAuthMethodSelectionContinuation( from flowContinuationState: MSALNativeAuthFlowContinuationState, - continuationToken: String, methods: [MSALNativeAuthV2ChallengeMethod] ) -> MSALNativeAuthFlowContinuationState? { + guard let continuationToken = flowContinuationState.continuationToken else { + return nil + } + let resolver = MSALNativeAuthV2HrefURLResolver(config: config) var resolvedLinks: [MSALNativeAuthV2LinkKey: URL] = [:] for method in methods { diff --git a/MSAL/src/native_auth/public/state_machine/v2/state/MSALNativeAuthAuthMethodSelectionRequiredState.swift b/MSAL/src/native_auth/public/state_machine/v2/state/MSALNativeAuthAuthMethodSelectionRequiredState.swift index 0b53cf6e2a..5c34346e59 100644 --- a/MSAL/src/native_auth/public/state_machine/v2/state/MSALNativeAuthAuthMethodSelectionRequiredState.swift +++ b/MSAL/src/native_auth/public/state_machine/v2/state/MSALNativeAuthAuthMethodSelectionRequiredState.swift @@ -40,6 +40,11 @@ public class MSALNativeAuthAuthMethodSelectionRequiredState: MSALNativeAuthState } /// Select an authentication method. + /// - Parameters: + /// - method: The authentication method selected from ``authMethods``. + /// - verificationContact: An optional contact value to verify for flows that require the app to + /// provide one. Pass `nil` when the server-provided method already contains the destination. + /// - delegate: The delegate that receives the next flow callback. public func selectAuthMethod( _ method: MSALAuthMethod, verificationContact: String?, From 6084d7eb66327d709106f2132bbcdbfae7d153db Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:42:00 +0000 Subject: [PATCH 5/6] Clean up auth method selection API Co-authored-by: antrix1989 <1989385+antrix1989@users.noreply.github.com> --- .../MSALNativeAuthV2ParsedResponses.swift | 18 +++++++++++++++--- ...eAuthAuthMethodSelectionRequiredState.swift | 5 ----- ...ativeAuthResetPasswordV2EndToEndTests.swift | 2 +- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/MSAL/src/native_auth/network/responses/v2/parser/MSALNativeAuthV2ParsedResponses.swift b/MSAL/src/native_auth/network/responses/v2/parser/MSALNativeAuthV2ParsedResponses.swift index eaeeb8370d..3d0e368f9a 100644 --- a/MSAL/src/native_auth/network/responses/v2/parser/MSALNativeAuthV2ParsedResponses.swift +++ b/MSAL/src/native_auth/network/responses/v2/parser/MSALNativeAuthV2ParsedResponses.swift @@ -55,16 +55,28 @@ enum MSALNativeAuthV2ChallengeMethodChannelType: String { /// Returns `true` if the channel is email. var isEmailType: Bool { - return rawValue.lowercased() == "email" + if case .email = self { + return true + } + + return false } /// Returns `true` if the channel is SMS. var isSMSType: Bool { - return rawValue.lowercased() == "sms" + if case .sms = self { + return true + } + + return false } var isPasswordType: Bool { - return rawValue.lowercased() == "password" + if case .password = self { + return true + } + + return false } } diff --git a/MSAL/src/native_auth/public/state_machine/v2/state/MSALNativeAuthAuthMethodSelectionRequiredState.swift b/MSAL/src/native_auth/public/state_machine/v2/state/MSALNativeAuthAuthMethodSelectionRequiredState.swift index 5c34346e59..f09f0a3bce 100644 --- a/MSAL/src/native_auth/public/state_machine/v2/state/MSALNativeAuthAuthMethodSelectionRequiredState.swift +++ b/MSAL/src/native_auth/public/state_machine/v2/state/MSALNativeAuthAuthMethodSelectionRequiredState.swift @@ -55,11 +55,6 @@ public class MSALNativeAuthAuthMethodSelectionRequiredState: MSALNativeAuthState } } - /// Select an authentication method, without an explicit verification contact. - public func selectAuthMethod(_ method: MSALAuthMethod, delegate: MSALNativeAuthFlowDelegate) { - selectAuthMethod(method, verificationContact: nil, delegate: delegate) - } - public override var description: String { return "authMethodSelectionRequired" } diff --git a/MSAL/test/integration/native_auth/end_to_end/reset_password/MSALNativeAuthResetPasswordV2EndToEndTests.swift b/MSAL/test/integration/native_auth/end_to_end/reset_password/MSALNativeAuthResetPasswordV2EndToEndTests.swift index d46308537f..3f770adc57 100644 --- a/MSAL/test/integration/native_auth/end_to_end/reset_password/MSALNativeAuthResetPasswordV2EndToEndTests.swift +++ b/MSAL/test/integration/native_auth/end_to_end/reset_password/MSALNativeAuthResetPasswordV2EndToEndTests.swift @@ -406,7 +406,7 @@ private final class ResetPasswordV2DelegateSpy: NSObject, return } - state.selectAuthMethod(emailMethod, delegate: self) + state.selectAuthMethod(emailMethod, verificationContact: nil, delegate: self) } func onCodeRequired(state: MSALNativeAuthCodeRequiredState, scenario: MSALNativeAuthFlowScenario) { From 4ed6eb30c9474a20af43a5e7f53d139342a42f5f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 27 Aug 2026 00:47:51 +0000 Subject: [PATCH 6/6] Reject malformed auth method challenge links Co-authored-by: antrix1989 <1989385+antrix1989@users.noreply.github.com> --- .../controllers/v2/MSALNativeAuthFlowController.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/MSAL/src/native_auth/controllers/v2/MSALNativeAuthFlowController.swift b/MSAL/src/native_auth/controllers/v2/MSALNativeAuthFlowController.swift index 9483cd6ede..0879677cc5 100644 --- a/MSAL/src/native_auth/controllers/v2/MSALNativeAuthFlowController.swift +++ b/MSAL/src/native_auth/controllers/v2/MSALNativeAuthFlowController.swift @@ -945,6 +945,10 @@ final class MSALNativeAuthFlowController: MSALNativeAuthBaseController, MSALNati let resolver = MSALNativeAuthV2HrefURLResolver(config: config) var resolvedLinks: [MSALNativeAuthV2LinkKey: URL] = [:] for method in methods { + guard method.challengeHref.rangeOfCharacter(from: .whitespacesAndNewlines) == nil else { + return nil + } + do { resolvedLinks[.method(id: method.id)] = try resolver.url(forHref: method.challengeHref) } catch {