From 8ea6425608a0a9315816b93baad3371280161a7f Mon Sep 17 00:00:00 2001 From: "george.bafaloukas" Date: Fri, 15 May 2026 11:06:46 +0100 Subject: [PATCH 1/4] fix(swiftui-mfa): add full Journey OIDC config and SDK 2.0 upgrade (SDKS-5029) - Add PingOidc to project dependencies (build file, frameworks phase, package product) - Replace 3-field Journey stub with full Journey.createJourney block including PingJourney.OidcModule.config (clientId, redirectUri, discoveryEndpoint, scopes) - Add commented-out PingOne block so sample covers both AM/AIC and PingOne environments - Annotate every placeholder with TODO comments explaining what to fill in - Add import PingOidc to JourneyManager.swift - Add TODO comment on Journey tree name in LoginViewModel.startLogin() - Update README: full config examples for both AM/AIC and PingOne, clarify AppConfiguration.swift is optional, fix FailureNode.cause non-optional usage, note journey name is case-sensitive - Package.resolved: pin ping-ios-sdk to 2.0.0 (was develop branch), bump pingone-signals-sdk-ios to 5.4.0 Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../MfaSample.xcodeproj/project.pbxproj | 8 ++ .../xcshareddata/swiftpm/Package.resolved | 8 +- .../Core/Managers/JourneyManager.swift | 93 +++++++++++++- .../MfaSample/ViewModels/LoginViewModel.swift | 8 ++ iOS/swiftui-mfa/README.md | 114 +++++++++++++----- 5 files changed, 188 insertions(+), 43 deletions(-) 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..cc01bf50 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 @@ -36,13 +37,93 @@ 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" + // --------------------------------------------------------------------------- + // STEP 1 — Choose your deployment model and uncomment the matching block. + // + // This app supports two server configurations: + // A) PingAM / PingOne Advanced Identity Cloud (AIC) + // B) PingOne (standalone, using the PingOne Journey API) + // + // Only one block should be active at a time. + // --------------------------------------------------------------------------- + + // --------------------------------------------------------------------------- + // OPTION A — PingAM / PingOne Advanced Identity Cloud (AIC) + // + // 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. For AIC / AM use: + // "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"] + } } + + // --------------------------------------------------------------------------- + // OPTION B — PingOne (standalone) + // + // Uncomment this block and comment out Option A above. + // + // Required values: + // serverUrl — PingOne environment URL. Format: + // "https://auth.pingone.//as" + // Regions: com (NA), eu, ca, ap + // Find your Environment ID in PingOne Admin > Settings. + // realm — Not used by PingOne Journey; set to "alpha" as a placeholder. + // cookie — PingOne session cookie: "ST" + // + // OIDC values: + // clientId — Application ID from PingOne Admin > Applications. + // redirectUri — Redirect URI registered on the PingOne application. + // discoveryEndpoint — PingOne OIDC discovery URL: + // "https://auth.pingone.//as/.well-known/openid-configuration" + // scopes — Must include "openid". Add "profile" and "email" as needed. + // --------------------------------------------------------------------------- + // journey = Journey.createJourney { journeyConfig in + // // TODO: Replace and with your PingOne values + // journeyConfig.serverUrl = "https://auth.pingone.//as" + // journeyConfig.realm = "alpha" + // journeyConfig.cookie = "ST" + // + // journeyConfig.module(PingJourney.OidcModule.config) { oidcConfig in + // // TODO: Replace with your PingOne Application ID + // oidcConfig.clientId = "" + // // TODO: Replace with your registered redirect URI + // oidcConfig.redirectUri = "com.example.mfasample://oauth2redirect" + // // TODO: Replace and + // oidcConfig.discoveryEndpoint = "https://auth.pingone.//as/.well-known/openid-configuration" + // oidcConfig.scopes = ["openid", "profile", "email"] + // } + // } } // MARK: - Journey Flow diff --git a/iOS/swiftui-mfa/MfaSample/MfaSample/ViewModels/LoginViewModel.swift b/iOS/swiftui-mfa/MfaSample/MfaSample/ViewModels/LoginViewModel.swift index 91bb89a0..23dee9f0 100644 --- a/iOS/swiftui-mfa/MfaSample/MfaSample/ViewModels/LoginViewModel.swift +++ b/iOS/swiftui-mfa/MfaSample/MfaSample/ViewModels/LoginViewModel.swift @@ -63,6 +63,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/README.md b/iOS/swiftui-mfa/README.md index 90b81a3d..0ec28434 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. Contains ready-to-use configuration blocks for both PingAM/AIC and PingOne — uncomment the appropriate block and fill in the `TODO` values: ```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) | @@ -204,10 +212,16 @@ Dependencies are managed via Swift Package Manager, pointing to `https://github. ### 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. + +#### PingOne (standalone) +1. In PingOne Admin, create an application of type **Native** with the same custom redirect URI. +2. Enable MFA policies and the relevant OATH/Push authenticator apps. +3. Note your **Environment ID** and **Application ID** — you will need these for configuration. ### Installation Steps @@ -224,21 +238,56 @@ 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). + + The file contains two ready-to-use configuration blocks — one for **PingAM / AIC** and one for **PingOne**. Uncomment the block that matches your environment and fill in the `TODO` values: + + **PingAM / AIC:** ```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 + **PingOne:** + ```swift + journey = Journey.createJourney { journeyConfig in + journeyConfig.serverUrl = "https://auth.pingone.//as" // TODO: your PingOne URL + journeyConfig.realm = "alpha" + journeyConfig.cookie = "ST" + + journeyConfig.module(PingJourney.OidcModule.config) { oidcConfig in + oidcConfig.clientId = "" // TODO: PingOne Application ID + oidcConfig.redirectUri = "com.example.mfasample://oauth2redirect" // TODO: redirect URI + oidcConfig.discoveryEndpoint = "https://auth.pingone.//as/.well-known/openid-configuration" + oidcConfig.scopes = ["openid", "profile", "email"] + } + } + ``` -6. **Build and run** on a physical device (recommended for push and biometric features) + > **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. + +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 (for AIC/AM) or the policy name in PingOne Admin > Authentication > Policies. + +6. **Configure APNs** for push notifications: + - Add your push notification entitlement in `MfaSample.entitlements`. + - Register your APNs certificate or key in your PingAM / PingOne environment. + +7. **Build and run** on a physical device (recommended for push and biometric features). ## Understanding the SDK Modules @@ -293,23 +342,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 +364,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 +458,9 @@ 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 only one configuration block (PingAM/AIC **or** PingOne) is active — the other must be commented out +- 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" From bcecdd1510cb168a992c54d5c12cb21be4f68c8e Mon Sep 17 00:00:00 2001 From: "george.bafaloukas" Date: Fri, 15 May 2026 11:10:28 +0100 Subject: [PATCH 2/4] fix(swiftui-mfa): scope Journey config to AM/AIC only (SDKS-5029) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the commented-out PingOne standalone configuration block from JourneyManager.swift and all corresponding PingOne references from the README — this sample targets PingAM / AIC exclusively. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../Core/Managers/JourneyManager.swift | 51 +------------------ iOS/swiftui-mfa/README.md | 35 ++----------- 2 files changed, 7 insertions(+), 79 deletions(-) diff --git a/iOS/swiftui-mfa/MfaSample/MfaSample/Core/Managers/JourneyManager.swift b/iOS/swiftui-mfa/MfaSample/MfaSample/Core/Managers/JourneyManager.swift index cc01bf50..edfffe16 100644 --- a/iOS/swiftui-mfa/MfaSample/MfaSample/Core/Managers/JourneyManager.swift +++ b/iOS/swiftui-mfa/MfaSample/MfaSample/Core/Managers/JourneyManager.swift @@ -38,17 +38,7 @@ class JourneyManager: ObservableObject { // MARK: - Initialization private init() { // --------------------------------------------------------------------------- - // STEP 1 — Choose your deployment model and uncomment the matching block. - // - // This app supports two server configurations: - // A) PingAM / PingOne Advanced Identity Cloud (AIC) - // B) PingOne (standalone, using the PingOne Journey API) - // - // Only one block should be active at a time. - // --------------------------------------------------------------------------- - - // --------------------------------------------------------------------------- - // OPTION A — PingAM / PingOne Advanced Identity Cloud (AIC) + // PingAM / PingOne Advanced Identity Cloud (AIC) configuration // // Required values: // serverUrl — Base URL of your AM instance, e.g.: @@ -63,7 +53,7 @@ class JourneyManager: ObservableObject { // 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. For AIC / AM use: + // discoveryEndpoint — OIDC discovery document URL: // "https://.forgeblocks.com/am/oauth2//.well-known/openid-configuration" // scopes — Requested OAuth2 scopes. Must include "openid". // --------------------------------------------------------------------------- @@ -87,43 +77,6 @@ class JourneyManager: ObservableObject { oidcConfig.scopes = ["openid", "profile", "email"] } } - - // --------------------------------------------------------------------------- - // OPTION B — PingOne (standalone) - // - // Uncomment this block and comment out Option A above. - // - // Required values: - // serverUrl — PingOne environment URL. Format: - // "https://auth.pingone.//as" - // Regions: com (NA), eu, ca, ap - // Find your Environment ID in PingOne Admin > Settings. - // realm — Not used by PingOne Journey; set to "alpha" as a placeholder. - // cookie — PingOne session cookie: "ST" - // - // OIDC values: - // clientId — Application ID from PingOne Admin > Applications. - // redirectUri — Redirect URI registered on the PingOne application. - // discoveryEndpoint — PingOne OIDC discovery URL: - // "https://auth.pingone.//as/.well-known/openid-configuration" - // scopes — Must include "openid". Add "profile" and "email" as needed. - // --------------------------------------------------------------------------- - // journey = Journey.createJourney { journeyConfig in - // // TODO: Replace and with your PingOne values - // journeyConfig.serverUrl = "https://auth.pingone.//as" - // journeyConfig.realm = "alpha" - // journeyConfig.cookie = "ST" - // - // journeyConfig.module(PingJourney.OidcModule.config) { oidcConfig in - // // TODO: Replace with your PingOne Application ID - // oidcConfig.clientId = "" - // // TODO: Replace with your registered redirect URI - // oidcConfig.redirectUri = "com.example.mfasample://oauth2redirect" - // // TODO: Replace and - // oidcConfig.discoveryEndpoint = "https://auth.pingone.//as/.well-known/openid-configuration" - // oidcConfig.scopes = ["openid", "profile", "email"] - // } - // } } // MARK: - Journey Flow diff --git a/iOS/swiftui-mfa/README.md b/iOS/swiftui-mfa/README.md index 0ec28434..4ac66c1d 100644 --- a/iOS/swiftui-mfa/README.md +++ b/iOS/swiftui-mfa/README.md @@ -147,7 +147,7 @@ try await pushManager.respondToNotification(notification, approved: true) ``` #### JourneyManager.swift -Wraps `Journey` to drive server-side authentication flows. Contains ready-to-use configuration blocks for both PingAM/AIC and PingOne — uncomment the appropriate block and fill in the `TODO` values: +Wraps `Journey` to drive server-side authentication flows. Fill in the `TODO` values for your PingAM / AIC environment: ```swift journey = Journey.createJourney { journeyConfig in @@ -207,7 +207,7 @@ 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 @@ -218,11 +218,6 @@ Dependencies are managed via Swift Package Manager, pointing to `https://github. 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. -#### PingOne (standalone) -1. In PingOne Admin, create an application of type **Native** with the same custom redirect URI. -2. Enable MFA policies and the relevant OATH/Push authenticator apps. -3. Note your **Environment ID** and **Application ID** — you will need these for configuration. - ### Installation Steps 1. **Clone the repository**: @@ -238,11 +233,8 @@ 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 the Journey connection** in [MfaSample/Core/Managers/JourneyManager.swift](MfaSample/MfaSample/Core/Managers/JourneyManager.swift). - - The file contains two ready-to-use configuration blocks — one for **PingAM / AIC** and one for **PingOne**. Uncomment the block that matches your environment and fill in the `TODO` values: +4. **Configure the Journey connection** in [MfaSample/Core/Managers/JourneyManager.swift](MfaSample/MfaSample/Core/Managers/JourneyManager.swift) by filling in the `TODO` values: - **PingAM / AIC:** ```swift journey = Journey.createJourney { journeyConfig in journeyConfig.serverUrl = "https://.forgeblocks.com/am" // TODO: your AM URL @@ -258,22 +250,6 @@ Dependencies are managed via Swift Package Manager, pointing to `https://github. } ``` - **PingOne:** - ```swift - journey = Journey.createJourney { journeyConfig in - journeyConfig.serverUrl = "https://auth.pingone.//as" // TODO: your PingOne URL - journeyConfig.realm = "alpha" - journeyConfig.cookie = "ST" - - journeyConfig.module(PingJourney.OidcModule.config) { oidcConfig in - oidcConfig.clientId = "" // TODO: PingOne Application ID - oidcConfig.redirectUri = "com.example.mfasample://oauth2redirect" // TODO: redirect URI - oidcConfig.discoveryEndpoint = "https://auth.pingone.//as/.well-known/openid-configuration" - oidcConfig.scopes = ["openid", "profile", "email"] - } - } - ``` - > **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. 5. **Set the Journey name** in [MfaSample/ViewModels/LoginViewModel.swift](MfaSample/MfaSample/ViewModels/LoginViewModel.swift): @@ -281,11 +257,11 @@ Dependencies are managed via Swift Package Manager, pointing to `https://github. // 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 (for AIC/AM) or the policy name in PingOne Admin > Authentication > Policies. + 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 / PingOne environment. + - Register your APNs certificate or key in your PingAM / AIC environment. 7. **Build and run** on a physical device (recommended for push and biometric features). @@ -459,7 +435,6 @@ A physical device is **required** for: **Issue**: Journey authentication fails with "Configuration error" **Solution**: - Verify `serverUrl`, `realm`, `cookie`, `clientId`, `redirectUri`, and `discoveryEndpoint` values in [JourneyManager.swift](MfaSample/MfaSample/Core/Managers/JourneyManager.swift) -- Ensure only one configuration block (PingAM/AIC **or** PingOne) is active — the other must be commented out - 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 From 3df13d32344b68f6adeae5d4cea2fb6df9e93960 Mon Sep 17 00:00:00 2001 From: "george.bafaloukas" Date: Fri, 15 May 2026 11:16:48 +0100 Subject: [PATCH 3/4] fix(swiftui-mfa): auto-register MFA from Journey and show spinner (SDKS-5029) When the Journey returns a HiddenValueCallback with an MFA registration URI, register the credential automatically instead of exposing it in the UI. - JourneyManager: add isMfaRegistering published flag, set true/false around the credential registration call in detectAndHandleMfaRegistration - LoginViewModel: forward isMfaRegistering from JourneyManager; add isMfaRegistrationNode computed property to detect the registration node - LoginScreen: branch on isMfaRegistrationNode before the normal callback list; show MfaRegistrationView (spinner while registering, success + Continue when done) - Add MfaRegistrationView component: ProgressView during registration, checkmark shield on success, Continue button enabled only after completion Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../Core/Managers/JourneyManager.swift | 27 ++++---- .../MfaSample/ViewModels/LoginViewModel.swift | 16 ++++- .../MfaSample/Views/Screens/LoginScreen.swift | 63 +++++++++++++++++++ 3 files changed, 90 insertions(+), 16 deletions(-) diff --git a/iOS/swiftui-mfa/MfaSample/MfaSample/Core/Managers/JourneyManager.swift b/iOS/swiftui-mfa/MfaSample/MfaSample/Core/Managers/JourneyManager.swift index edfffe16..9454f6b0 100644 --- a/iOS/swiftui-mfa/MfaSample/MfaSample/Core/Managers/JourneyManager.swift +++ b/iOS/swiftui-mfa/MfaSample/MfaSample/Core/Managers/JourneyManager.swift @@ -28,6 +28,7 @@ class JourneyManager: ObservableObject { // MARK: - Published State @Published var currentNode: Node? @Published var isLoading = false + @Published var isMfaRegistering = false @Published var errorMessage: String? @Published var isAuthenticated = false @Published var userId: String? @@ -112,33 +113,29 @@ 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. + /// Detects a HiddenValueCallback containing an MFA registration URI and registers + /// the credential automatically. Sets isMfaRegistering while the work is in progress + /// so the UI can show a spinner, then clears it when done. 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 { - - // Parse the URI and register the credential - await registerMfaCredential(uri: hiddenCallback.value) - } + guard let hiddenCallback = callback as? HiddenValueCallback, + hiddenCallback.valueId == "mfaDeviceRegistration", + !hiddenCallback.value.isEmpty else { continue } + + isMfaRegistering = true + await registerMfaCredential(uri: hiddenCallback.value) + isMfaRegistering = false + return } } diff --git a/iOS/swiftui-mfa/MfaSample/MfaSample/ViewModels/LoginViewModel.swift b/iOS/swiftui-mfa/MfaSample/MfaSample/ViewModels/LoginViewModel.swift index 23dee9f0..1fa63949 100644 --- a/iOS/swiftui-mfa/MfaSample/MfaSample/ViewModels/LoginViewModel.swift +++ b/iOS/swiftui-mfa/MfaSample/MfaSample/ViewModels/LoginViewModel.swift @@ -23,9 +23,21 @@ class LoginViewModel: ObservableObject { // MARK: - Published State @Published var currentNode: Node? @Published var isLoading = false + @Published var isMfaRegistering = false @Published var errorMessage: String? @Published var shouldDismiss = false + /// True when the current node contains an MFA registration URI that has been + /// (or is being) registered automatically. The UI uses this to show the + /// "Registering MFA…" / "Continue" state instead of the normal callback list. + var isMfaRegistrationNode: Bool { + guard let continueNode = currentNode 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 + } + } + // MARK: - Callback Values /// Dictionary to store callback values by callback type and index. @Published var callbackValues: [String: String] = [:] @@ -40,13 +52,15 @@ 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.$errorMessage .assign(to: &$errorMessage) diff --git a/iOS/swiftui-mfa/MfaSample/MfaSample/Views/Screens/LoginScreen.swift b/iOS/swiftui-mfa/MfaSample/MfaSample/Views/Screens/LoginScreen.swift index f1bc853f..90bb0ea8 100644 --- a/iOS/swiftui-mfa/MfaSample/MfaSample/Views/Screens/LoginScreen.swift +++ b/iOS/swiftui-mfa/MfaSample/MfaSample/Views/Screens/LoginScreen.swift @@ -27,6 +27,11 @@ struct LoginScreen: View { Text("Loading...") .foregroundColor(.secondary) } + } else if viewModel.isMfaRegistrationNode { + // MFA registration is handled automatically — show progress or success + MfaRegistrationView(isRegistering: viewModel.isMfaRegistering) { + Task { await viewModel.submitNode() } + } } else if let node = viewModel.currentNode, let continueNode = node as? ContinueNode { // Node with callbacks @@ -218,6 +223,64 @@ 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. +struct MfaRegistrationView: View { + let isRegistering: Bool + 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 { + 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("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() From 4a4245fd3182cb848d751b2d6dfd7c8685f3dbb8 Mon Sep 17 00:00:00 2001 From: "george.bafaloukas" Date: Fri, 15 May 2026 11:30:56 +0100 Subject: [PATCH 4/4] fix(swiftui-mfa): address coderabbit review comments (SDKS-5029) - Centralize MFA registration node detection: move the HiddenValueCallback check into JourneyManager.nodeIsMfaRegistration (static helper); LoginViewModel.isMfaRegistrationNode delegates to it, eliminating duplication - Surface registration failures: registerMfaCredential now throws; errors are caught in detectAndHandleMfaRegistration and published via mfaRegistrationError; use defer to guarantee isMfaRegistering is always cleared - MfaRegistrationView: accept errorMessage param, show error state with exclamationmark.shield icon, and relabel button to "Retry" on failure - README: add URL scheme registration note next to the redirectUri config step Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../Core/Managers/JourneyManager.swift | 59 ++++++++++--------- .../MfaSample/ViewModels/LoginViewModel.swift | 16 ++--- .../MfaSample/Views/Screens/LoginScreen.swift | 24 +++++++- iOS/swiftui-mfa/README.md | 2 + 4 files changed, 61 insertions(+), 40 deletions(-) diff --git a/iOS/swiftui-mfa/MfaSample/MfaSample/Core/Managers/JourneyManager.swift b/iOS/swiftui-mfa/MfaSample/MfaSample/Core/Managers/JourneyManager.swift index 9454f6b0..fe5e87fb 100644 --- a/iOS/swiftui-mfa/MfaSample/MfaSample/Core/Managers/JourneyManager.swift +++ b/iOS/swiftui-mfa/MfaSample/MfaSample/Core/Managers/JourneyManager.swift @@ -29,6 +29,7 @@ class JourneyManager: ObservableObject { @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? @@ -121,9 +122,20 @@ class JourneyManager: ObservableObject { } } + /// 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 the work is in progress - /// so the UI can show a spinner, then clears it when done. + /// 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 } @@ -133,47 +145,36 @@ class JourneyManager: ObservableObject { !hiddenCallback.value.isEmpty else { continue } isMfaRegistering = true - await registerMfaCredential(uri: hiddenCallback.value) - isMfaRegistering = false + 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 1fa63949..1dfe58bc 100644 --- a/iOS/swiftui-mfa/MfaSample/MfaSample/ViewModels/LoginViewModel.swift +++ b/iOS/swiftui-mfa/MfaSample/MfaSample/ViewModels/LoginViewModel.swift @@ -24,18 +24,15 @@ class LoginViewModel: ObservableObject { @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 contains an MFA registration URI that has been - /// (or is being) registered automatically. The UI uses this to show the - /// "Registering MFA…" / "Continue" state instead of the normal callback list. + /// True when the current node is an MFA registration node. Delegates to + /// `JourneyManager.nodeIsMfaRegistration` — single source of truth. var isMfaRegistrationNode: Bool { - guard let continueNode = currentNode 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 - } + guard let node = currentNode else { return false } + return JourneyManager.nodeIsMfaRegistration(node) } // MARK: - Callback Values @@ -61,6 +58,9 @@ class LoginViewModel: ObservableObject { journeyManager.$isMfaRegistering .assign(to: &$isMfaRegistering) + journeyManager.$mfaRegistrationError + .assign(to: &$mfaRegistrationError) + journeyManager.$errorMessage .assign(to: &$errorMessage) diff --git a/iOS/swiftui-mfa/MfaSample/MfaSample/Views/Screens/LoginScreen.swift b/iOS/swiftui-mfa/MfaSample/MfaSample/Views/Screens/LoginScreen.swift index 90bb0ea8..b9118819 100644 --- a/iOS/swiftui-mfa/MfaSample/MfaSample/Views/Screens/LoginScreen.swift +++ b/iOS/swiftui-mfa/MfaSample/MfaSample/Views/Screens/LoginScreen.swift @@ -28,8 +28,11 @@ struct LoginScreen: View { .foregroundColor(.secondary) } } else if viewModel.isMfaRegistrationNode { - // MFA registration is handled automatically — show progress or success - MfaRegistrationView(isRegistering: viewModel.isMfaRegistering) { + // 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, @@ -228,8 +231,10 @@ struct CallbackView: 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 { @@ -248,6 +253,19 @@ struct MfaRegistrationView: View { .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") @@ -266,7 +284,7 @@ struct MfaRegistrationView: View { Spacer() Button(action: onContinue) { - Text("Continue") + Text(errorMessage != nil ? "Retry" : "Continue") .fontWeight(.semibold) .frame(maxWidth: .infinity) .padding() diff --git a/iOS/swiftui-mfa/README.md b/iOS/swiftui-mfa/README.md index 4ac66c1d..86c4bf5e 100644 --- a/iOS/swiftui-mfa/README.md +++ b/iOS/swiftui-mfa/README.md @@ -252,6 +252,8 @@ Dependencies are managed via Swift Package Manager, pointing to `https://github. > **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