diff --git a/iOS/swiftui-mfa/MfaSample/MfaSample.xcodeproj/project.pbxproj b/iOS/swiftui-mfa/MfaSample/MfaSample.xcodeproj/project.pbxproj index 31ca20e8..05b15f24 100644 --- a/iOS/swiftui-mfa/MfaSample/MfaSample.xcodeproj/project.pbxproj +++ b/iOS/swiftui-mfa/MfaSample/MfaSample.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 1B9C981A2F104EEE00F1A4EE /* PingOath in Frameworks */ = {isa = PBXBuildFile; productRef = 1B7A9E272EEA110000989A55 /* PingOath */; }; 1B9C981B2F104EEE00F1A4EE /* PingPush in Frameworks */ = {isa = PBXBuildFile; productRef = 1B7A9E292EEA110000989A55 /* PingPush */; }; 1B9C981C2F104EEE00F1A4EE /* PingStorage in Frameworks */ = {isa = PBXBuildFile; productRef = 1B7A9E2B2EEA110000989A55 /* PingStorage */; }; + 1B9C981D2F104EEE00F1A4EE /* PingOidc in Frameworks */ = {isa = PBXBuildFile; productRef = 1B7A9E2D2EEA110000989A55 /* PingOidc */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -80,6 +81,7 @@ 1B9C98192F104EEE00F1A4EE /* PingLogger in Frameworks */, 1B9C98182F104EEE00F1A4EE /* PingJourney in Frameworks */, 1B9C98172F104EEE00F1A4EE /* PingCommons in Frameworks */, + 1B9C981D2F104EEE00F1A4EE /* PingOidc in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -146,6 +148,7 @@ 1B7A9E272EEA110000989A55 /* PingOath */, 1B7A9E292EEA110000989A55 /* PingPush */, 1B7A9E2B2EEA110000989A55 /* PingStorage */, + 1B7A9E2D2EEA110000989A55 /* PingOidc */, ); productName = MfaExample; productReference = 1B7A9DEC2EEA102800989A55 /* MfaSample.app */; @@ -676,6 +679,11 @@ package = 1B7A9E202EEA110000989A55 /* XCRemoteSwiftPackageReference "ping-ios-sdk" */; productName = PingStorage; }; + 1B7A9E2D2EEA110000989A55 /* PingOidc */ = { + isa = XCSwiftPackageProductDependency; + package = 1B7A9E202EEA110000989A55 /* XCRemoteSwiftPackageReference "ping-ios-sdk" */; + productName = PingOidc; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 1B7A9DE42EEA102800989A55 /* Project object */; diff --git a/iOS/swiftui-mfa/MfaSample/MfaSample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/iOS/swiftui-mfa/MfaSample/MfaSample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 710b484f..c1f9eb91 100644 --- a/iOS/swiftui-mfa/MfaSample/MfaSample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/iOS/swiftui-mfa/MfaSample/MfaSample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ForgeRock/ping-ios-sdk", "state" : { - "branch" : "develop", - "revision" : "9b37c20f99eb1ce418e6dc8ab491e32fad86051e" + "revision" : "d025874d598d7337a72f0a8ee446dcddae7b6ef2", + "version" : "2.0.0" } }, { @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pingidentity/pingone-signals-sdk-ios.git", "state" : { - "revision" : "bf37bd85fa909428d764630c71cf0d4f3d2d7e05", - "version" : "5.3.0" + "revision" : "e41f7070fdbb43dd7762274ec610c099256a7c7e", + "version" : "5.4.0" } }, { diff --git a/iOS/swiftui-mfa/MfaSample/MfaSample/Core/Managers/JourneyManager.swift b/iOS/swiftui-mfa/MfaSample/MfaSample/Core/Managers/JourneyManager.swift index f1251f21..fe5e87fb 100644 --- a/iOS/swiftui-mfa/MfaSample/MfaSample/Core/Managers/JourneyManager.swift +++ b/iOS/swiftui-mfa/MfaSample/MfaSample/Core/Managers/JourneyManager.swift @@ -13,6 +13,7 @@ import Combine import PingJourney import PingOrchestrate import PingJourneyPlugin +import PingOidc /// Manager for Journey-based authentication and MFA registration. @MainActor @@ -27,6 +28,8 @@ class JourneyManager: ObservableObject { // MARK: - Published State @Published var currentNode: Node? @Published var isLoading = false + @Published var isMfaRegistering = false + @Published var mfaRegistrationError: String? @Published var errorMessage: String? @Published var isAuthenticated = false @Published var userId: String? @@ -36,12 +39,45 @@ class JourneyManager: ObservableObject { // MARK: - Initialization private init() { - // Create Journey instance with default configuration - journey = Journey.createJourney { config in - // TODO: Configure with actual server details - config.serverUrl = "https://your-server.example.com/am" - config.realm = "alpha" - config.cookie = "iPlanetDirectoryPro" + // --------------------------------------------------------------------------- + // PingAM / PingOne Advanced Identity Cloud (AIC) configuration + // + // Required values: + // serverUrl — Base URL of your AM instance, e.g.: + // PingOne AIC: "https://.forgeblocks.com/am" + // Self-hosted PingAM: "https://am.example.com/openam" + // realm — The AM realm your Journey is published in. + // Cloud tenants typically use "alpha" or "bravo". + // cookie — AM session cookie name. Cloud default: "iPlanetDirectoryPro". + // Check AM Admin > Realms > Authentication > Settings. + // + // OIDC values (used to exchange the Journey session for OAuth2 tokens): + // clientId — OAuth2 client ID registered in AM. + // redirectUri — Custom-scheme URI registered on the AM client, e.g. + // "com.example.mfasample://oauth2redirect" + // discoveryEndpoint — OIDC discovery document URL: + // "https://.forgeblocks.com/am/oauth2//.well-known/openid-configuration" + // scopes — Requested OAuth2 scopes. Must include "openid". + // --------------------------------------------------------------------------- + journey = Journey.createJourney { journeyConfig in + // TODO: Replace with your PingAM / AIC server URL + journeyConfig.serverUrl = "https://.forgeblocks.com/am" + // TODO: Replace with your realm name ("alpha" is the default for cloud tenants) + journeyConfig.realm = "alpha" + // TODO: Replace with your AM session cookie name + journeyConfig.cookie = "iPlanetDirectoryPro" + + journeyConfig.module(PingJourney.OidcModule.config) { oidcConfig in + // TODO: Replace with your OAuth2 client ID registered in AM + oidcConfig.clientId = "" + // TODO: Replace with your OAuth2 redirect URI (must match the AM client registration) + oidcConfig.redirectUri = "com.example.mfasample://oauth2redirect" + // TODO: Replace with your OIDC discovery endpoint + // AIC format: "https://.forgeblocks.com/am/oauth2//.well-known/openid-configuration" + oidcConfig.discoveryEndpoint = "https://.forgeblocks.com/am/oauth2/alpha/.well-known/openid-configuration" + // TODO: Adjust scopes if your AM client requires additional claims + oidcConfig.scopes = ["openid", "profile", "email"] + } } } @@ -78,71 +114,67 @@ class JourneyManager: ObservableObject { /// Processes a node and handles MFA registration. private func processNode(_ node: Node) async { if node is SuccessNode { - // Authentication successful isAuthenticated = true - - // Note: Token retrieval would be done through the OidcModule - // For now, we'll mark as authenticated currentNode = nil } else { - // Continue with next node currentNode = node - - // Check for MFA registration callbacks await detectAndHandleMfaRegistration(in: node) } } - /// Detects and handles HiddenValueCallback for MFA device registration. + /// Returns true if the given node contains a `HiddenValueCallback` that carries + /// an MFA registration URI. Single source of truth used by both this manager + /// and `LoginViewModel.isMfaRegistrationNode`. + static func nodeIsMfaRegistration(_ node: Node) -> Bool { + guard let continueNode = node as? ContinueNode else { return false } + return continueNode.callbacks.contains { callback in + guard let hidden = callback as? HiddenValueCallback else { return false } + return hidden.valueId == "mfaDeviceRegistration" && !hidden.value.isEmpty + } + } + + /// Detects a HiddenValueCallback containing an MFA registration URI and registers + /// the credential automatically. Sets `isMfaRegistering` while work is in progress + /// so the UI can show a spinner. Surfaces any failure via `mfaRegistrationError`. private func detectAndHandleMfaRegistration(in node: Node) async { guard let continueNode = node as? ContinueNode else { return } for callback in continueNode.callbacks { - if let hiddenCallback = callback as? HiddenValueCallback, - hiddenCallback.valueId == "mfaDeviceRegistration", - !hiddenCallback.value.isEmpty { + guard let hiddenCallback = callback as? HiddenValueCallback, + hiddenCallback.valueId == "mfaDeviceRegistration", + !hiddenCallback.value.isEmpty else { continue } - // Parse the URI and register the credential - await registerMfaCredential(uri: hiddenCallback.value) + isMfaRegistering = true + mfaRegistrationError = nil + defer { isMfaRegistering = false } + + do { + try await registerMfaCredential(uri: hiddenCallback.value) + } catch { + mfaRegistrationError = error.localizedDescription } + return } } - /// Registers an MFA credential from the Journey flow. - private func registerMfaCredential(uri: String) async { + /// Registers an MFA credential from the Journey flow. Throws on failure. + private func registerMfaCredential(uri: String) async throws { let parseResult = QRCodeParser.parse(uri) switch parseResult { case .oath(let oathUri): - do { - _ = try await oathManager.addCredentialFromUri(oathUri) - } catch { - print("Failed to register OATH credential from Journey: \(error)") - } + _ = try await oathManager.addCredentialFromUri(oathUri) case .push(let pushUri): - do { - _ = try await pushManager.addCredentialFromUri(pushUri) - } catch { - print("Failed to register Push credential from Journey: \(error)") - } + _ = try await pushManager.addCredentialFromUri(pushUri) case .mfa(let mfaUri): - // Register both - let the SDK handle the mfauth:// format - do { - _ = try await oathManager.addCredentialFromUri(mfaUri) - } catch { - print("Failed to register OATH credential from Journey mfauth://: \(error)") - } - - do { - _ = try await pushManager.addCredentialFromUri(mfaUri) - } catch { - print("Failed to register Push credential from Journey mfauth://: \(error)") - } + // Register both — let the SDK handle the mfauth:// format + _ = try await oathManager.addCredentialFromUri(mfaUri) + _ = try await pushManager.addCredentialFromUri(mfaUri) case .invalid(let message): - print("Invalid MFA registration URI from Journey: \(message)") + throw AppError.qrCodeError(message) } } diff --git a/iOS/swiftui-mfa/MfaSample/MfaSample/ViewModels/LoginViewModel.swift b/iOS/swiftui-mfa/MfaSample/MfaSample/ViewModels/LoginViewModel.swift index 91bb89a0..1dfe58bc 100644 --- a/iOS/swiftui-mfa/MfaSample/MfaSample/ViewModels/LoginViewModel.swift +++ b/iOS/swiftui-mfa/MfaSample/MfaSample/ViewModels/LoginViewModel.swift @@ -23,9 +23,18 @@ class LoginViewModel: ObservableObject { // MARK: - Published State @Published var currentNode: Node? @Published var isLoading = false + @Published var isMfaRegistering = false + @Published var mfaRegistrationError: String? @Published var errorMessage: String? @Published var shouldDismiss = false + /// True when the current node is an MFA registration node. Delegates to + /// `JourneyManager.nodeIsMfaRegistration` — single source of truth. + var isMfaRegistrationNode: Bool { + guard let node = currentNode else { return false } + return JourneyManager.nodeIsMfaRegistration(node) + } + // MARK: - Callback Values /// Dictionary to store callback values by callback type and index. @Published var callbackValues: [String: String] = [:] @@ -40,13 +49,18 @@ class LoginViewModel: ObservableObject { // MARK: - Setup private func setupObservers() { - // Observe journey manager state journeyManager.$currentNode .assign(to: &$currentNode) journeyManager.$isLoading .assign(to: &$isLoading) + journeyManager.$isMfaRegistering + .assign(to: &$isMfaRegistering) + + journeyManager.$mfaRegistrationError + .assign(to: &$mfaRegistrationError) + journeyManager.$errorMessage .assign(to: &$errorMessage) @@ -63,6 +77,14 @@ class LoginViewModel: ObservableObject { /// Starts the login journey. func startLogin() async { do { + // TODO: Replace "Login" with the name of the Journey tree configured on your server. + // + // PingAM / AIC: the tree name is set in AM Admin > Authentication > Trees. + // Common names: "Login", "MFARegistration", "Registration". + // PingOne: the flow name is the DaVinci flow or Journey policy name shown + // in PingOne Admin > Authentication > Policies. + // + // The name here must match exactly (case-sensitive) what is configured on the server. try await journeyManager.startJourney(journeyName: "Login") } catch { errorMessage = error.localizedDescription diff --git a/iOS/swiftui-mfa/MfaSample/MfaSample/Views/Screens/LoginScreen.swift b/iOS/swiftui-mfa/MfaSample/MfaSample/Views/Screens/LoginScreen.swift index f1bc853f..b9118819 100644 --- a/iOS/swiftui-mfa/MfaSample/MfaSample/Views/Screens/LoginScreen.swift +++ b/iOS/swiftui-mfa/MfaSample/MfaSample/Views/Screens/LoginScreen.swift @@ -27,6 +27,14 @@ struct LoginScreen: View { Text("Loading...") .foregroundColor(.secondary) } + } else if viewModel.isMfaRegistrationNode { + // MFA registration is handled automatically — show progress, success, or error + MfaRegistrationView( + isRegistering: viewModel.isMfaRegistering, + errorMessage: viewModel.mfaRegistrationError + ) { + Task { await viewModel.submitNode() } + } } else if let node = viewModel.currentNode, let continueNode = node as? ContinueNode { // Node with callbacks @@ -218,6 +226,79 @@ struct CallbackView: View { } } +// MARK: - MFA Registration View + +/// Shown when the Journey node contains an MFA registration URI. +/// Automatically registers the credential while showing a spinner, then presents +/// a success state with a Continue button so the user can advance the Journey. +/// If registration fails, shows an error message with a retry option. +struct MfaRegistrationView: View { + let isRegistering: Bool + let errorMessage: String? + let onContinue: () -> Void + + var body: some View { + VStack(spacing: 32) { + Spacer() + + if isRegistering { + VStack(spacing: 16) { + ProgressView() + .scaleEffect(1.5) + Text("Registering MFA…") + .font(.headline) + Text("Setting up your authenticator. This will only take a moment.") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + } else if let error = errorMessage { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.shield.fill") + .font(.system(size: 60)) + .foregroundColor(.red) + Text("Registration Failed") + .font(.headline) + Text(error) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + } else { + VStack(spacing: 16) { + Image(systemName: "checkmark.shield.fill") + .font(.system(size: 60)) + .foregroundColor(.green) + Text("MFA Registered") + .font(.headline) + Text("Your authenticator has been set up successfully.") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + } + + Spacer() + + Button(action: onContinue) { + Text(errorMessage != nil ? "Retry" : "Continue") + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .padding() + .background(isRegistering ? Color.gray : Color.blue) + .foregroundColor(.white) + .cornerRadius(12) + } + .disabled(isRegistering) + .padding(.horizontal) + .padding(.bottom, 32) + } + } +} + #Preview { NavigationView { LoginScreen() diff --git a/iOS/swiftui-mfa/README.md b/iOS/swiftui-mfa/README.md index 90b81a3d..86c4bf5e 100644 --- a/iOS/swiftui-mfa/README.md +++ b/iOS/swiftui-mfa/README.md @@ -147,16 +147,23 @@ try await pushManager.respondToNotification(notification, approved: true) ``` #### JourneyManager.swift -Wraps `Journey` to drive server-side authentication flows: +Wraps `Journey` to drive server-side authentication flows. Fill in the `TODO` values for your PingAM / AIC environment: ```swift -journey = Journey.createJourney { config in - config.serverUrl = "https://your-server.example.com/am" - config.realm = "alpha" - config.cookie = "iPlanetDirectoryPro" +journey = Journey.createJourney { journeyConfig in + journeyConfig.serverUrl = "https://.forgeblocks.com/am" + journeyConfig.realm = "alpha" + journeyConfig.cookie = "iPlanetDirectoryPro" + + journeyConfig.module(PingJourney.OidcModule.config) { oidcConfig in + oidcConfig.clientId = "" + oidcConfig.redirectUri = "com.example.mfasample://oauth2redirect" + oidcConfig.discoveryEndpoint = "https://.forgeblocks.com/am/oauth2/alpha/.well-known/openid-configuration" + oidcConfig.scopes = ["openid", "profile", "email"] + } } -// Start a Journey flow +// Start a Journey flow (replace "Login" with your Journey tree name) try await journeyManager.startJourney(journeyName: "Login") // Advance to the next node after filling in callbacks @@ -179,13 +186,14 @@ try await journeyManager.submitNode() ## Dependencies -The application uses the following Ping iOS SDK modules (branch: `develop`): +The application uses the following Ping iOS SDK modules (version: `2.0.0+`): | Module | Purpose | |--------|---------| | **PingOath** | TOTP/HOTP credential management and code generation | | **PingPush** | Push notification credential management and approval flows | | **PingJourney** | Journey-based authentication flows and MFA registration | +| **PingOidc** | OIDC/OAuth2 token exchange after Journey authentication | | **PingLogger** | Logging utilities | | **PingStorage** | Secure Keychain-backed credential storage | | **PingOrchestrate** | Core workflow orchestration (transitive dependency) | @@ -199,15 +207,16 @@ Dependencies are managed via Swift Package Manager, pointing to `https://github. - Xcode 15.0 or later - iOS 16.0 or later - Swift 6.0 or later -- A configured PingOne or PingAM environment with MFA capabilities +- A configured PingAM or PingOne Advanced Identity Cloud (AIC) environment with MFA capabilities - An Apple Push Notification service (APNs) certificate or key for push features ### Server Configuration -#### PingAM / Advanced Identity Cloud Configuration -1. Enable the OATH and Push authentication modules in your realm -2. Configure the Journey (authentication tree) with an OATH Registration or Push Registration node -3. Ensure the `HiddenValueCallback` is configured to relay the registration URI to the app +#### PingAM / PingOne Advanced Identity Cloud (AIC) +1. Enable the OATH and Push authentication modules in your realm. +2. Configure the Journey (authentication tree) with an OATH Registration or Push Registration node. +3. Ensure the `HiddenValueCallback` is configured to relay the registration URI to the app. +4. Register an OAuth2/OIDC client in AM with a custom redirect URI (e.g. `com.example.mfasample://oauth2redirect`) and grant the `openid`, `profile`, and `email` scopes. ### Installation Steps @@ -224,21 +233,39 @@ Dependencies are managed via Swift Package Manager, pointing to `https://github. 3. **Resolve Swift Package dependencies** (Xcode does this automatically on first open, or via **File > Packages > Resolve Package Versions**). -4. **Configure server settings** in [MfaSample/Core/Configuration/AppConfiguration.swift](MfaSample/MfaSample/Core/Configuration/AppConfiguration.swift) and [MfaSample/Core/Managers/JourneyManager.swift](MfaSample/MfaSample/Core/Managers/JourneyManager.swift): +4. **Configure the Journey connection** in [MfaSample/Core/Managers/JourneyManager.swift](MfaSample/MfaSample/Core/Managers/JourneyManager.swift) by filling in the `TODO` values: + ```swift - // JourneyManager.swift - journey = Journey.createJourney { config in - config.serverUrl = "https://your-server.example.com/am" - config.realm = "alpha" - config.cookie = "iPlanetDirectoryPro" + journey = Journey.createJourney { journeyConfig in + journeyConfig.serverUrl = "https://.forgeblocks.com/am" // TODO: your AM URL + journeyConfig.realm = "alpha" // TODO: your realm + journeyConfig.cookie = "iPlanetDirectoryPro" // TODO: your cookie name + + journeyConfig.module(PingJourney.OidcModule.config) { oidcConfig in + oidcConfig.clientId = "" // TODO: OAuth2 client ID + oidcConfig.redirectUri = "com.example.mfasample://oauth2redirect" // TODO: redirect URI + oidcConfig.discoveryEndpoint = "https://.forgeblocks.com/am/oauth2/alpha/.well-known/openid-configuration" + oidcConfig.scopes = ["openid", "profile", "email"] + } } ``` -5. **Configure APNs** for push notifications: - - Add your push notification entitlement in `MfaSample.entitlements` - - Register your Amazon SNS credential in your PingAM environment + > **Note:** `AppConfiguration.swift` initialises the OATH and Push SDK clients only — you do not need to modify it unless you want to customise `OathClient` or `PushClient` storage/logging options. + + > **URL scheme registration:** The `redirectUri` scheme (e.g. `com.example.mfasample`) must be registered in `Info.plist` under `CFBundleURLTypes` / `CFBundleURLSchemes`, otherwise the OAuth2 redirect will not be handled by the app at runtime. In Xcode: **Target → Info → URL Types → +**, set the URL Scheme to the scheme portion of your redirect URI. + +5. **Set the Journey name** in [MfaSample/ViewModels/LoginViewModel.swift](MfaSample/MfaSample/ViewModels/LoginViewModel.swift): + ```swift + // TODO: Replace "Login" with the name of the Journey tree on your server + try await journeyManager.startJourney(journeyName: "Login") + ``` + The name must match exactly (case-sensitive) the tree name configured in AM Admin > Authentication > Trees. + +6. **Configure APNs** for push notifications: + - Add your push notification entitlement in `MfaSample.entitlements`. + - Register your APNs certificate or key in your PingAM / AIC environment. -6. **Build and run** on a physical device (recommended for push and biometric features) +7. **Build and run** on a physical device (recommended for push and biometric features). ## Understanding the SDK Modules @@ -293,23 +320,20 @@ try await client.respond(to: notification, approved: true) ### Journey (PingJourney) -`Journey` drives server-side authentication flows with callback handling: +`Journey` drives server-side authentication flows with callback handling. See `JourneyManager.swift` for the full configuration block including OIDC token exchange. ```swift -let journey = Journey.createJourney { config in - config.serverUrl = "https://tenant.example.forgeblocks.com/am" - config.realm = "alpha" - config.cookie = "iPlanetDirectoryPro" -} +// See JourneyManager.swift for Journey.createJourney { ... } configuration -let node = await journey.start("Login") +// Start a Journey flow +let node = await journey.start("Login") // replace "Login" with your Journey tree name switch node { case let continueNode as ContinueNode: - // Fill in callbacks and advance + // Fill in callbacks and advance to the next node for callback in continueNode.callbacks { if let nameCallback = callback as? NameCallback { - nameCallback.value = "username" + nameCallback.name = "username" } } let nextNode = await continueNode.next() @@ -318,7 +342,8 @@ case is SuccessNode: print("Authentication successful") case let failureNode as FailureNode: - print("Failed: \(failureNode.cause?.localizedDescription ?? "Unknown")") + // FailureNode.cause is non-optional + print("Failed: \(failureNode.cause.localizedDescription)") default: break @@ -411,8 +436,8 @@ A physical device is **required** for: **Issue**: Journey authentication fails with "Configuration error" **Solution**: -- Verify `serverUrl`, `realm`, and `cookie` values in [JourneyManager.swift](MfaSample/MfaSample/Core/Managers/JourneyManager.swift) -- Ensure the Journey name passed to `startJourney` matches the name configured on the server +- Verify `serverUrl`, `realm`, `cookie`, `clientId`, `redirectUri`, and `discoveryEndpoint` values in [JourneyManager.swift](MfaSample/MfaSample/Core/Managers/JourneyManager.swift) +- Ensure the Journey name in [LoginViewModel.swift](MfaSample/MfaSample/ViewModels/LoginViewModel.swift) matches the tree name configured on the server (case-sensitive) - Check network connectivity and server availability in the diagnostic logs **Issue**: Build fails with "Cannot find 'PingOath' in scope"