Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions iOS/swiftui-mfa/MfaSample/MfaSample.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -80,6 +81,7 @@
1B9C98192F104EEE00F1A4EE /* PingLogger in Frameworks */,
1B9C98182F104EEE00F1A4EE /* PingJourney in Frameworks */,
1B9C98172F104EEE00F1A4EE /* PingCommons in Frameworks */,
1B9C981D2F104EEE00F1A4EE /* PingOidc in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -146,6 +148,7 @@
1B7A9E272EEA110000989A55 /* PingOath */,
1B7A9E292EEA110000989A55 /* PingPush */,
1B7A9E2B2EEA110000989A55 /* PingStorage */,
1B7A9E2D2EEA110000989A55 /* PingOidc */,
);
productName = MfaExample;
productReference = 1B7A9DEC2EEA102800989A55 /* MfaSample.app */;
Expand Down Expand Up @@ -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 */;
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

120 changes: 76 additions & 44 deletions iOS/swiftui-mfa/MfaSample/MfaSample/Core/Managers/JourneyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import Combine
import PingJourney
import PingOrchestrate
import PingJourneyPlugin
import PingOidc

/// Manager for Journey-based authentication and MFA registration.
@MainActor
Expand All @@ -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?
Expand All @@ -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://<tenant>.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://<tenant>.forgeblocks.com/am/oauth2/<realm>/.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://<tenant>.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 = "<your-client-id>"
// 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://<tenant>.forgeblocks.com/am/oauth2/<realm>/.well-known/openid-configuration"
oidcConfig.discoveryEndpoint = "https://<tenant>.forgeblocks.com/am/oauth2/alpha/.well-known/openid-configuration"
// TODO: Adjust scopes if your AM client requires additional claims
oidcConfig.scopes = ["openid", "profile", "email"]
}
}
}

Expand Down Expand Up @@ -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)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [:]
Expand All @@ -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)

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Loading