diff --git a/Package.resolved b/Package.resolved index 797388b5..e4312860 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "569db33fa2bc5ee6b5eea0c6fac19c9d737c08f8990441b36b1f5e2e7ed28ede", + "originHash" : "ec20051f1a5a11254aa0e619ad6be3ac3c019dccb12fc2022b4a68ebeb044cce", "pins" : [ { "identity" : "app-check", @@ -73,6 +73,15 @@ "version" : "101.0.0" } }, + { + "identity" : "pingone-mobile-sdk-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pingidentity/pingone-mobile-sdk-ios.git", + "state" : { + "revision" : "6023edf096b885a7208efe7d94b230180a7318cc", + "version" : "2.3.1" + } + }, { "identity" : "pingone-signals-sdk-ios", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 8134e67f..2b7de039 100644 --- a/Package.swift +++ b/Package.swift @@ -48,6 +48,7 @@ let package = Package( .library(name: "PingOath", targets: ["PingOath"]), .library(name: "PingPush", targets: ["PingPush"]), .library(name: "PingAuthMigration", targets: ["PingAuthMigration"]), + .library(name: "PingOneMFA", targets: ["PingOneMFA"]), // MARK: - Utilities .library(name: "PingBinding", targets: ["PingBinding"]) @@ -57,7 +58,8 @@ let package = Package( .package(url: "https://github.com/pingidentity/pingone-signals-sdk-ios.git", "5.4.0"..<"5.5.0"), .package(url: "https://github.com/facebook/facebook-ios-sdk.git", "16.3.1"..<"16.4.0"), .package(url: "https://github.com/google/GoogleSignIn-iOS.git", exact: "9.0.0"), - .package(url: "https://github.com/GoogleCloudPlatform/recaptcha-enterprise-mobile-sdk.git", "18.8.1"..<"18.9.0") + .package(url: "https://github.com/GoogleCloudPlatform/recaptcha-enterprise-mobile-sdk.git", "18.8.1"..<"18.9.0"), + .package(url: "https://github.com/pingidentity/pingone-mobile-sdk-ios.git", exact: "2.3.1") ], targets: [ // MARK: - Foundation Targets (No dependencies) @@ -303,7 +305,16 @@ let package = Package( exclude: ["AuthMigration.h"], resources: [.copy("PrivacyInfo.xcprivacy")] ), - + .target( + name: "PingOneMFA", + dependencies: [ + .product(name: "PingOneSDK", package: "pingone-mobile-sdk-ios") + ], + path: "PingOneMFA/PingOneMFA", + exclude: ["PingOneMFA.h"], + resources: [.copy("PrivacyInfo.xcprivacy")] + ), + // MARK: - Utility Targets .target( name: "PingBinding", diff --git a/PingOneMFA/PingOneMFA.xcodeproj/project.pbxproj b/PingOneMFA/PingOneMFA.xcodeproj/project.pbxproj new file mode 100644 index 00000000..68a88a0b --- /dev/null +++ b/PingOneMFA/PingOneMFA.xcodeproj/project.pbxproj @@ -0,0 +1,592 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 240C95657B48433E8CD75019 /* MockPingOneMFA.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C30C6DF01DC455797CC25F3 /* MockPingOneMFA.swift */; }; + 2EFFFE472FCC36D20094E6C0 /* PushType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EFFFE462FCC36D20094E6C0 /* PushType.swift */; }; + 2EFFFE482FCC36D20094E6C0 /* PushType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EFFFE462FCC36D20094E6C0 /* PushType.swift */; }; + 4D5B9C4E91C548879F3D0092 /* PingOneMFA.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FDE1452E7C56416FA24BE3FC /* PingOneMFA.framework */; }; + 5DF12D179015422B8D50E55C /* PushNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 838D8BFF67D246BD98BAE0D6 /* PushNotification.swift */; }; + 6DEF9C94A954460F80C2E875 /* AccountParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 264B5E42A00F444282EAE2B2 /* AccountParser.swift */; }; + 727AADB86ABE4022A65ED683 /* PingOneMfaAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62F5A4B5E637435BBD9202C8 /* PingOneMfaAccount.swift */; }; + 96FC692C7D39460B8F4F46B9 /* AccountParserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED414CD63DEB40468AD3DBFD /* AccountParserTests.swift */; }; + 99987F5A040F43A49A5DF553 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 27D20303A69B4012AE4848A0 /* PrivacyInfo.xcprivacy */; }; + 9CA840E7F2434ED28DFB38DD /* PingOneSDK in Frameworks */ = {isa = PBXBuildFile; productRef = AF2DE0899F084DD0B7D178F2 /* PingOneSDK */; }; + 9CA840E8F2434ED28DFB38DD /* PingOneSDK.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 9CA840EAF2434ED28DFB38DD /* PingOneSDK.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + A478018F2630425393CEE9B1 /* PingOneMFAErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ED305AC71AB43B0876EB3AF /* PingOneMFAErrorTests.swift */; }; + B34F10903DCA4ABABFDD3BAC /* PingOneMFA.h in Headers */ = {isa = PBXBuildFile; fileRef = 8E0FA1C73976462994E83668 /* PingOneMFA.h */; settings = {ATTRIBUTES = (Public, ); }; }; + C028FF71B2394B16B7CC0FEB /* Geo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92F6613AF2BA452FBEB3CC08 /* Geo.swift */; }; + CBF3C2244A354D3EB152BF1A /* PingOneMFATests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4A849F04B2246F998FAF85F /* PingOneMFATests.swift */; }; + CE135E73D0974DBCAFED0322 /* PingOneMFAError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF8E134ED07440F88A2DB536 /* PingOneMFAError.swift */; }; + D1A2B3C4E5F6A7B8C9D0E1F2 /* PingOneMFAInternalError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E1F2 /* PingOneMFAInternalError.swift */; }; + E2542F605D60443B9D2B95C7 /* PingOneMFA.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0474486D96034FBA9364F1DD /* PingOneMFA.swift */; }; + F1947554A491497E834731CA /* OtpCodeInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82377D219C21476B9282B0B2 /* OtpCodeInfo.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 012ED3B4B619491C98C0AABD /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 187599A2DF7049229C0F50D2 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 3116E2D77AC64ADA8B701AA6; + remoteInfo = PingOneMFA; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9CA840E9F2434ED28DFB38DD /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 9CA840E8F2434ED28DFB38DD /* PingOneSDK.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 0474486D96034FBA9364F1DD /* PingOneMFA.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PingOneMFA.swift; sourceTree = ""; }; + 264B5E42A00F444282EAE2B2 /* AccountParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountParser.swift; sourceTree = ""; }; + 27D20303A69B4012AE4848A0 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + 2EFFFE462FCC36D20094E6C0 /* PushType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushType.swift; sourceTree = ""; }; + 4ED305AC71AB43B0876EB3AF /* PingOneMFAErrorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PingOneMFAErrorTests.swift; sourceTree = ""; }; + 62F5A4B5E637435BBD9202C8 /* PingOneMfaAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PingOneMfaAccount.swift; sourceTree = ""; }; + 82377D219C21476B9282B0B2 /* OtpCodeInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OtpCodeInfo.swift; sourceTree = ""; }; + 838D8BFF67D246BD98BAE0D6 /* PushNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotification.swift; sourceTree = ""; }; + 8C30C6DF01DC455797CC25F3 /* MockPingOneMFA.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPingOneMFA.swift; sourceTree = ""; }; + 8E0FA1C73976462994E83668 /* PingOneMFA.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = PingOneMFA.h; sourceTree = ""; }; + 92F6613AF2BA452FBEB3CC08 /* Geo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Geo.swift; sourceTree = ""; }; + 9CA840EAF2434ED28DFB38DD /* PingOneSDK.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = PingOneSDK.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + A1B2C3D4E5F6A7B8C9D0E1F2 /* PingOneMFAInternalError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PingOneMFAInternalError.swift; sourceTree = ""; }; + D4A849F04B2246F998FAF85F /* PingOneMFATests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PingOneMFATests.swift; sourceTree = ""; }; + DF8E134ED07440F88A2DB536 /* PingOneMFAError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PingOneMFAError.swift; sourceTree = ""; }; + ED414CD63DEB40468AD3DBFD /* AccountParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountParserTests.swift; sourceTree = ""; }; + F748FD268F5344689F9DC02E /* PingOneMFATests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PingOneMFATests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + FDE1452E7C56416FA24BE3FC /* PingOneMFA.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = PingOneMFA.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 9DAC828D9AF246CFB59EB8B7 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4D5B9C4E91C548879F3D0092 /* PingOneMFA.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F8F3362045DB4AAC8C4AAB6D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 9CA840E7F2434ED28DFB38DD /* PingOneSDK in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 052A6060A0F84A1C95038AB3 /* PingOneMFA */ = { + isa = PBXGroup; + children = ( + 27D20303A69B4012AE4848A0 /* PrivacyInfo.xcprivacy */, + 8E0FA1C73976462994E83668 /* PingOneMFA.h */, + 264B5E42A00F444282EAE2B2 /* AccountParser.swift */, + 82377D219C21476B9282B0B2 /* OtpCodeInfo.swift */, + 0474486D96034FBA9364F1DD /* PingOneMFA.swift */, + 92F6613AF2BA452FBEB3CC08 /* Geo.swift */, + DF8E134ED07440F88A2DB536 /* PingOneMFAError.swift */, + A1B2C3D4E5F6A7B8C9D0E1F2 /* PingOneMFAInternalError.swift */, + 62F5A4B5E637435BBD9202C8 /* PingOneMfaAccount.swift */, + 838D8BFF67D246BD98BAE0D6 /* PushNotification.swift */, + 2EFFFE462FCC36D20094E6C0 /* PushType.swift */, + ); + path = PingOneMFA; + sourceTree = ""; + }; + 0A6FD42B92DA4DA4A8CA0E52 /* Products */ = { + isa = PBXGroup; + children = ( + FDE1452E7C56416FA24BE3FC /* PingOneMFA.framework */, + F748FD268F5344689F9DC02E /* PingOneMFATests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 2E90D40B2FC8385700E41057 /* Recovered References */ = { + isa = PBXGroup; + children = ( + 9CA840EAF2434ED28DFB38DD /* PingOneSDK.framework */, + ); + name = "Recovered References"; + sourceTree = ""; + }; + 4DAC2ED9E36440E8BB727098 /* PingOneMFATests */ = { + isa = PBXGroup; + children = ( + 8C30C6DF01DC455797CC25F3 /* MockPingOneMFA.swift */, + D4A849F04B2246F998FAF85F /* PingOneMFATests.swift */, + 4ED305AC71AB43B0876EB3AF /* PingOneMFAErrorTests.swift */, + ED414CD63DEB40468AD3DBFD /* AccountParserTests.swift */, + ); + path = PingOneMFATests; + sourceTree = ""; + }; + C73C2817920B43D5A250644D = { + isa = PBXGroup; + children = ( + 052A6060A0F84A1C95038AB3 /* PingOneMFA */, + 4DAC2ED9E36440E8BB727098 /* PingOneMFATests */, + 0A6FD42B92DA4DA4A8CA0E52 /* Products */, + 2E90D40B2FC8385700E41057 /* Recovered References */, + ); + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXHeadersBuildPhase section */ + C3DCFC969DA54E1FA82E3E97 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + B34F10903DCA4ABABFDD3BAC /* PingOneMFA.h in Headers */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + +/* Begin PBXNativeTarget section */ + 14C0CBAE9D4D412388F63F45 /* PingOneMFATests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 6927DABC98C64931A63BE0C6 /* Build configuration list for PBXNativeTarget "PingOneMFATests" */; + buildPhases = ( + 262B06DECA774550AF4B013E /* Sources */, + 9DAC828D9AF246CFB59EB8B7 /* Frameworks */, + D0E25507B4D3456D80C28FA8 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + C711120F1A6C4CC5B3E919EC /* PBXTargetDependency */, + ); + name = PingOneMFATests; + packageProductDependencies = ( + ); + productName = PingOneMFATests; + productReference = F748FD268F5344689F9DC02E /* PingOneMFATests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 3116E2D77AC64ADA8B701AA6 /* PingOneMFA */ = { + isa = PBXNativeTarget; + buildConfigurationList = 68235318B0034BD2A46DE20A /* Build configuration list for PBXNativeTarget "PingOneMFA" */; + buildPhases = ( + C3DCFC969DA54E1FA82E3E97 /* Headers */, + D85A13FA4FE54CCE8A1274CB /* Sources */, + F8F3362045DB4AAC8C4AAB6D /* Frameworks */, + E58F25BE1EE3494B9341C05F /* Resources */, + 9CA840E9F2434ED28DFB38DD /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = PingOneMFA; + packageProductDependencies = ( + AF2DE0899F084DD0B7D178F2 /* PingOneSDK */, + ); + productName = PingOneMFA; + productReference = FDE1452E7C56416FA24BE3FC /* PingOneMFA.framework */; + productType = "com.apple.product-type.framework"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 187599A2DF7049229C0F50D2 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1610; + LastUpgradeCheck = 1610; + TargetAttributes = { + 14C0CBAE9D4D412388F63F45 = { + CreatedOnToolsVersion = 16.1; + }; + 3116E2D77AC64ADA8B701AA6 = { + CreatedOnToolsVersion = 16.1; + LastSwiftMigration = 1610; + }; + }; + }; + buildConfigurationList = 765DC209A72C492D9DE150BF /* Build configuration list for PBXProject "PingOneMFA" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = C73C2817920B43D5A250644D; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 5F88B53928734959A448B7F6 /* XCRemoteSwiftPackageReference "pingone-mobile-sdk-ios" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 0A6FD42B92DA4DA4A8CA0E52 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 3116E2D77AC64ADA8B701AA6 /* PingOneMFA */, + 14C0CBAE9D4D412388F63F45 /* PingOneMFATests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + D0E25507B4D3456D80C28FA8 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + E58F25BE1EE3494B9341C05F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 99987F5A040F43A49A5DF553 /* PrivacyInfo.xcprivacy in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 262B06DECA774550AF4B013E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 240C95657B48433E8CD75019 /* MockPingOneMFA.swift in Sources */, + CBF3C2244A354D3EB152BF1A /* PingOneMFATests.swift in Sources */, + 2EFFFE482FCC36D20094E6C0 /* PushType.swift in Sources */, + A478018F2630425393CEE9B1 /* PingOneMFAErrorTests.swift in Sources */, + 96FC692C7D39460B8F4F46B9 /* AccountParserTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + D85A13FA4FE54CCE8A1274CB /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 6DEF9C94A954460F80C2E875 /* AccountParser.swift in Sources */, + F1947554A491497E834731CA /* OtpCodeInfo.swift in Sources */, + E2542F605D60443B9D2B95C7 /* PingOneMFA.swift in Sources */, + 2EFFFE472FCC36D20094E6C0 /* PushType.swift in Sources */, + C028FF71B2394B16B7CC0FEB /* Geo.swift in Sources */, + CE135E73D0974DBCAFED0322 /* PingOneMFAError.swift in Sources */, + D1A2B3C4E5F6A7B8C9D0E1F2 /* PingOneMFAInternalError.swift in Sources */, + 727AADB86ABE4022A65ED683 /* PingOneMfaAccount.swift in Sources */, + 5DF12D179015422B8D50E55C /* PushNotification.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + C711120F1A6C4CC5B3E919EC /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 3116E2D77AC64ADA8B701AA6 /* PingOneMFA */; + targetProxy = 012ED3B4B619491C98C0AABD /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 4BB5FEB529B343258D4ADC07 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.pingidentity.PingOneMFATests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/PingTestHost.app/PingTestHost"; + }; + name = Release; + }; + 80B6BBE1F29345AC99781875 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = YZYUGL7S48; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Release; + }; + A32D49EAB3D141789DF2E8B0 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUILD_LIBRARY_FOR_DISTRIBUTION = NO; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + PRODUCT_BUNDLE_IDENTIFIER = com.pingidentity.PingOneMFA; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INSTALL_OBJC_HEADER = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + CE84FA32CBA34220AEB125D6 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUILD_LIBRARY_FOR_DISTRIBUTION = NO; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + PRODUCT_BUNDLE_IDENTIFIER = com.pingidentity.PingOneMFA; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INSTALL_OBJC_HEADER = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + E5052C1556564979A2979640 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.pingidentity.PingOneMFATests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/PingTestHost.app/PingTestHost"; + }; + name = Debug; + }; + FFBA7AB4A95B4E13BF2DD7F4 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = YZYUGL7S48; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 68235318B0034BD2A46DE20A /* Build configuration list for PBXNativeTarget "PingOneMFA" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CE84FA32CBA34220AEB125D6 /* Debug */, + A32D49EAB3D141789DF2E8B0 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 6927DABC98C64931A63BE0C6 /* Build configuration list for PBXNativeTarget "PingOneMFATests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + E5052C1556564979A2979640 /* Debug */, + 4BB5FEB529B343258D4ADC07 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 765DC209A72C492D9DE150BF /* Build configuration list for PBXProject "PingOneMFA" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + FFBA7AB4A95B4E13BF2DD7F4 /* Debug */, + 80B6BBE1F29345AC99781875 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 5F88B53928734959A448B7F6 /* XCRemoteSwiftPackageReference "pingone-mobile-sdk-ios" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/pingidentity/pingone-mobile-sdk-ios.git"; + requirement = { + kind = exactVersion; + version = 2.3.1; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + AF2DE0899F084DD0B7D178F2 /* PingOneSDK */ = { + isa = XCSwiftPackageProductDependency; + package = 5F88B53928734959A448B7F6 /* XCRemoteSwiftPackageReference "pingone-mobile-sdk-ios" */; + productName = PingOneSDK; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 187599A2DF7049229C0F50D2 /* Project object */; +} diff --git a/PingOneMFA/PingOneMFA.xcodeproj/xcshareddata/IDETemplateMacros.plist b/PingOneMFA/PingOneMFA.xcodeproj/xcshareddata/IDETemplateMacros.plist new file mode 100644 index 00000000..fff3fea7 --- /dev/null +++ b/PingOneMFA/PingOneMFA.xcodeproj/xcshareddata/IDETemplateMacros.plist @@ -0,0 +1,17 @@ + + + + + FILEHEADER + +// ___FILENAME___ +// ___PACKAGENAME___ +// +// Copyright (c) ___YEAR___ Ping Identity Corporation. All rights reserved. +// +// This software may be modified and distributed under the terms +// of the MIT license. See the LICENSE file for details. +// + + + diff --git a/PingOneMFA/PingOneMFA.xcodeproj/xcshareddata/xcschemes/PingOneMFATests.xcscheme b/PingOneMFA/PingOneMFA.xcodeproj/xcshareddata/xcschemes/PingOneMFATests.xcscheme new file mode 100644 index 00000000..74e66037 --- /dev/null +++ b/PingOneMFA/PingOneMFA.xcodeproj/xcshareddata/xcschemes/PingOneMFATests.xcscheme @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/PingOneMFA/PingOneMFA/AccountParser.swift b/PingOneMFA/PingOneMFA/AccountParser.swift new file mode 100644 index 00000000..c63a5911 --- /dev/null +++ b/PingOneMFA/PingOneMFA/AccountParser.swift @@ -0,0 +1,69 @@ +// +// AccountParser.swift +// PingOneMFA +// +// Copyright (c) 2025 - 2026 Ping Identity Corporation. All rights reserved. +// +// This software may be modified and distributed under the terms +// of the MIT license. See the LICENSE file for details. +// + +import Foundation + +internal struct AccountParser { + + internal static func parse(_ deviceInfo: [String: Any]?) throws -> [PingOneMfaAccount] { + guard let deviceInfo, !deviceInfo.isEmpty else { return [] } + + guard JSONSerialization.isValidJSONObject(deviceInfo) else { + throw PingOneMFAError("Failed to serialize device info: contains a value that is not JSON-serializable") + } + + let data: Data + do { + data = try JSONSerialization.data(withJSONObject: deviceInfo) + } catch { + throw PingOneMFAError("Failed to serialize device info: \(error.localizedDescription)") + } + + let decoded: [String: RegionDto] + do { + decoded = try JSONDecoder().decode([String: RegionDto].self, from: data) + } catch { + throw PingOneMFAError("Failed to decode device info: \(error.localizedDescription)") + } + + return decoded.flatMap { region, regionDto in + (regionDto.users ?? []).map { user in + PingOneMfaAccount( + region: region, + id: user.id ?? "", + deviceId: user.device?.id ?? "", + environmentId: user.environment?.id ?? "", + name: user.name?.given ?? "", + family: user.name?.family ?? "" + ) + } + } + } +} + +private struct RegionDto: Decodable { + var users: [UserDto]? +} + +private struct UserDto: Decodable { + var id: String? + var environment: IdContainer? + var device: IdContainer? + var name: NameDto? +} + +private struct IdContainer: Decodable { + var id: String? +} + +private struct NameDto: Decodable { + var given: String? + var family: String? +} diff --git a/PingOneMFA/PingOneMFA/Geo.swift b/PingOneMFA/PingOneMFA/Geo.swift new file mode 100644 index 00000000..43659676 --- /dev/null +++ b/PingOneMFA/PingOneMFA/Geo.swift @@ -0,0 +1,29 @@ +// +// Geo.swift +// PingOneMFA +// +// Copyright (c) 2025 - 2026 Ping Identity Corporation. All rights reserved. +// +// This software may be modified and distributed under the terms +// of the MIT license. See the LICENSE file for details. +// + +import Foundation + +/// Geographic region selector for the PingOneMFA SDK. +/// +/// Specifies the PingOne cloud region to connect to. Maps 1:1 to `PingOneSDK.PingOneGeo` +/// without leaking the upstream module. +public enum Geo: Sendable, Equatable { + /// The North America region. + case northAmerica + /// The Europe region. + case europe + /// The Australia region. + case australia + /// The Canada region. + case canada + /// The Singapore region. + case singapore +} + diff --git a/PingOneMFA/PingOneMFA/OtpCodeInfo.swift b/PingOneMFA/PingOneMFA/OtpCodeInfo.swift new file mode 100644 index 00000000..39997c60 --- /dev/null +++ b/PingOneMFA/PingOneMFA/OtpCodeInfo.swift @@ -0,0 +1,29 @@ +// +// OtpCodeInfo.swift +// PingOneMFA +// +// Copyright (c) 2025 - 2026 Ping Identity Corporation. All rights reserved. +// +// This software may be modified and distributed under the terms +// of the MIT license. See the LICENSE file for details. +// + +import Foundation + +/// A value type representing a one-time passcode with its validity window. +public struct OtpCodeInfo: Sendable, Equatable { + /// The current OTP code string to display to the user. + public let code: String + /// The number of seconds remaining before this code expires, or zero if already expired. + public let secondsRemaining: Int + + /// Creates an `OtpCodeInfo` with the given code and remaining validity. + /// + /// - Parameters: + /// - code: The current OTP code string. + /// - secondsRemaining: Seconds until the code expires, or zero if already expired. + public init(code: String, secondsRemaining: Int) { + self.code = code + self.secondsRemaining = secondsRemaining + } +} diff --git a/PingOneMFA/PingOneMFA/PingOneMFA.h b/PingOneMFA/PingOneMFA/PingOneMFA.h new file mode 100644 index 00000000..c69468d6 --- /dev/null +++ b/PingOneMFA/PingOneMFA/PingOneMFA.h @@ -0,0 +1,21 @@ +// +// PingOneMFA.h +// PingOneMFA +// +// Copyright (c) 2025 - 2026 Ping Identity Corporation. All rights reserved. +// +// This software may be modified and distributed under the terms +// of the MIT license. See the LICENSE file for details. + + +#import + +//! Project version number for PingOneMFA. +FOUNDATION_EXPORT double PingOneMFAVersionNumber; + +//! Project version string for PingOneMFA. +FOUNDATION_EXPORT const unsigned char PingOneMFAVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import + + diff --git a/PingOneMFA/PingOneMFA/PingOneMFA.swift b/PingOneMFA/PingOneMFA/PingOneMFA.swift new file mode 100644 index 00000000..a905b097 --- /dev/null +++ b/PingOneMFA/PingOneMFA/PingOneMFA.swift @@ -0,0 +1,319 @@ +// +// PingOneMFA.swift +// PingOneMFA +// +// Copyright (c) 2025 - 2026 Ping Identity Corporation. All rights reserved. +// +// This software may be modified and distributed under the terms +// of the MIT license. See the LICENSE file for details. + +import Foundation +import UserNotifications +import PingOneSDK + +/// Actor to manage PingOneMFA SDK state with thread safety +@globalActor +public actor PingOneMFAActor { + public static let shared = PingOneMFAActor() +} + +/// The `PingOneMFA` class provides methods to initialize the SDK and interact with the PingOne MFA service. +@PingOneMFAActor +public class PingOneMFA { + internal private(set) static var isInitialized: Bool = false + private static var initializationTask: Task? + + /// Initializes the PingOneMFA SDK with the provided geographic region. + /// This method should be called before using any other methods in the PingOneMFA SDK. + /// This method is idempotent — if already initialized, it returns immediately. + /// + /// - Parameter geo: The geographic region for the PingOneMFA SDK. + /// - Throws: `PingOneMFAError` if initialization fails. + public nonisolated static func initialize(geo: Geo) async throws { + try await initializeIfNeeded { + try await configure(geo: geo) + } + } + + internal static func initializeIfNeeded(_ operation: @escaping @Sendable () async throws -> Void) async throws { + if isInitialized { + return + } + + if let initializationTask { + try await initializationTask.value + return + } + + let task = Task { @PingOneMFAActor in + do { + try await operation() + try Task.checkCancellation() + isInitialized = true + } catch { + isInitialized = false + throw error + } + } + initializationTask = task + defer { initializationTask = nil } + + try await task.value + } + + private nonisolated static func configure(geo: Geo) async throws { + let pingOneGeo = pingOneGeo(for: geo) + + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + PingOne.configure(geo: pingOneGeo) { error in + if let error = error { + continuation.resume(throwing: PingOneMFAError(error)) + } else { + continuation.resume() + } + } + } + } + + private nonisolated static func pingOneGeo(for geo: Geo) -> PingOneGeo { + switch geo { + case .northAmerica: + return .NorthAmerica + case .europe: + return .Europe + case .australia: + return .Australia + case .canada: + return .Canada + case .singapore: + return .Singapore + } + } + + /// Registers the device's APNS push token with the PingOne MFA service. + /// + /// Uses `.sandbox` token type in `DEBUG` builds and `.production` otherwise. + /// + /// - Parameter pushToken: The raw APNS device token `Data` received in + /// `application(_:didRegisterForRemoteNotificationsWithDeviceToken:)`. + /// - Throws: `PingOneMFAError` containing all errors reported by the upstream SDK + /// if at least one error is present. + public nonisolated static func setDeviceToken(_ pushToken: Data) async throws { + #if DEBUG + let tokenType = PingOne.APNSDeviceTokenType.sandbox + #else + let tokenType = PingOne.APNSDeviceTokenType.production + #endif + + return try await withCheckedThrowingContinuation { continuation in + PingOne.setDeviceToken(token: pushToken, type: tokenType) { errors in + if let errors = errors, !errors.isEmpty { + continuation.resume(throwing: PingOneMFAError(errors: errors)) + } else { + continuation.resume() + } + } + } + } + + /// Pairs this device with a PingOne environment using the provided pairing key. + /// + /// - Parameter pairingKey: The pairing key string provided by the PingOne environment. + /// - Throws: `PingOneMFAError` if pairing fails. + public nonisolated static func pair(pairingKey: String) async throws { + return try await withCheckedThrowingContinuation { continuation in + PingOne.pair(pairingKey) { _, error in + if let error = error { + continuation.resume(throwing: PingOneMFAError(error)) + } else { + continuation.resume() + } + } + } + } + + /// Returns all registered MFA accounts for this device, along with any non-fatal + /// diagnostic errors reported by the upstream SDK. + /// + /// - Returns: A `PingOneMFADeviceInfo` value containing parsed + /// `PingOneMfaAccount` values and any non-fatal diagnostic errors reported by + /// the upstream SDK. + /// - Throws: `PingOneMFAError` when the SDK returns no data and at least one real error, + /// or when the SDK returns neither data nor errors. + public nonisolated static func getDeviceInfo() async throws -> PingOneMFADeviceInfo { + return try await withCheckedThrowingContinuation { continuation in + PingOne.getInfo(completion: { deviceInfo, errors in + if let deviceInfo, !deviceInfo.isEmpty { + // Data available — return it along with any diagnostic errors from the SDK. + do { + let accounts = try AccountParser.parse(deviceInfo) + let mappedErrors = errors?.map { PingOneMFAError($0) } + continuation.resume(returning: PingOneMFADeviceInfo(accounts: accounts, errors: mappedErrors)) + } catch { + continuation.resume(throwing: error) + } + } else if let errors, !errors.isEmpty { + // No data and at least one real error — treat as failure. + continuation.resume(throwing: PingOneMFAError(errors: errors)) + } else { + // Neither data nor errors — SDK misbehaved; avoid hanging the continuation. + continuation.resume(throwing: PingOneMFAError("getDeviceInfo failed: no error details provided")) + } + }) + } + } + + /// Returns the current one-time passcode and its remaining validity window. + /// + /// `secondsRemaining` is computed from the upstream SDK's `validUntil` + /// epoch-seconds timestamp and clamped to zero when the passcode is expired. + /// + /// - Returns: An `OtpCodeInfo` containing the current passcode and seconds remaining. + /// - Throws: `PingOneMFAError` if the SDK call fails or returns no passcode info. + public nonisolated static func getOneTimePasscode() async throws -> OtpCodeInfo { + return try await withCheckedThrowingContinuation { continuation in + PingOne.getOneTimePasscode { passcodeInfo, error in + if let error = error { + continuation.resume(throwing: PingOneMFAError(error)) + } else if let passcodeInfo = passcodeInfo { + let now = Date().timeIntervalSince1970 + let secondsRemaining = max(0, Int(passcodeInfo.validUntil - now)) + continuation.resume(returning: OtpCodeInfo( + code: passcodeInfo.passcode, + secondsRemaining: secondsRemaining + )) + } else { + continuation.resume(throwing: PingOneMFAError()) + } + } + } + } + + /// Processes an incoming APNS push notification for MFA authentication. + /// + /// Extracts the `title` and `message` from `userInfo["aps"]["alert"]` (handling + /// both the string-alert and dict-alert forms of the APNS payload). + /// + /// - Parameter userInfo: The raw `userInfo` dictionary from + /// `application(_:didReceiveRemoteNotification:fetchCompletionHandler:)`. + /// - Returns: A `PushNotification` when the SDK yields a notification object; + /// `nil` when the SDK handles the notification internally and no UI is needed. + /// - Throws: `PingOneMFAError` if the underlying SDK call reports an error. + public nonisolated static func processRemoteNotification(userInfo: [AnyHashable: Any]) async throws -> PushNotification? { + let (title, message) = parseAPNSAlert(from: userInfo) + + return try await withCheckedThrowingContinuation { continuation in + PingOne.processRemoteNotification(userInfo) { notificationObject, error in + if let error = error { + continuation.resume(throwing: PingOneMFAError(error)) + } else if let notificationObject = notificationObject { + continuation.resume(returning: PushNotification( + notificationObject: notificationObject, + title: title, + message: message + )) + } else { + continuation.resume(returning: nil) + } + } + } + } + + /// Processes a user action taken on an APNS banner notification (e.g., approve or deny tap). + /// + /// Extracts the `title` and `message` from `userInfo["aps"]["alert"]` and wraps + /// `PingOne.processRemoteNotificationAction(_:authenticationMethod:forRemoteNotification:completionHandler:)`. + /// + /// - Parameters: + /// - identifier: The action identifier from the notification response + /// (e.g., `"notification.confirm"` or `"notification.deny"`). + /// - authenticationMethod: The authentication method string (e.g., `"user"`). + /// - userInfo: The raw `userInfo` dictionary from the notification response. + /// - Returns: A `PushNotification` when the SDK requires the app to present approve/deny UI, + /// or `nil` when the SDK handled the action internally. + /// - Throws: `PingOneMFAError` if the SDK reports an error. + public nonisolated static func processRemoteNotificationAction( + identifier: String, + authenticationMethod: String?, + userInfo: [AnyHashable: Any] + ) async throws -> PushNotification? { + let (title, message) = parseAPNSAlert(from: userInfo) + + return try await withCheckedThrowingContinuation { continuation in + PingOne.processRemoteNotificationAction( + identifier, + authenticationMethod: authenticationMethod, + forRemoteNotification: userInfo + ) { notificationObject, error in + if let error = error { + continuation.resume(throwing: PingOneMFAError(error)) + } else if let notificationObject = notificationObject { + continuation.resume(returning: PushNotification( + notificationObject: notificationObject, + title: title, + message: message + )) + } else { + // SDK handled the action internally; no UI needed + continuation.resume(returning: nil) + } + } + } + } + + /// Generates a mobile payload for use in PingOne authentication flows. + /// + /// - Returns: The mobile payload string. + /// - Throws: `PingOneMFAError` if payload generation fails or returns no payload. + public nonisolated static func generateMobilePayload() async throws -> String { + return try await withCheckedThrowingContinuation { continuation in + PingOne.generateMobilePayload(completionHandler: { payload, error in + if let error = error { + continuation.resume(throwing: PingOneMFAError(error)) + } else if let payload = payload { + continuation.resume(returning: payload) + } else { + continuation.resume(throwing: PingOneMFAError()) + } + }) + } + } + + /// Returns the set of `UNNotificationCategory` objects registered by the PingOneSDK. + /// + /// Call this at app launch to register PingOneMFA's notification categories with + /// `UNUserNotificationCenter` so that actionable banner notifications can be delivered. + /// + /// - Returns: The `Set` provided by the binary PingOneSDK. + public nonisolated static func getNotificationCategories() -> Set { + return PingOne.getUNNotificationCategories() as Set + } + + /// Resets the SDK to uninitialized state (useful for testing) + internal static func reset() { + isInitialized = false + initializationTask?.cancel() + initializationTask = nil + } + + /// Parses the `title` and `message` from an APNS `userInfo` payload. + /// + /// Handles both the dict-alert form (`aps.alert` is `[String: Any]`) and the + /// string-alert form (`aps.alert` is `String`). + /// + /// - Parameter userInfo: The raw APNS `userInfo` dictionary. + /// - Returns: A tuple of `(title: String?, message: String?)`. + nonisolated static func parseAPNSAlert(from userInfo: [AnyHashable: Any]) -> (title: String?, message: String?) { + guard let aps = userInfo["aps"] as? [String: Any] else { + return (title: nil, message: nil) + } + if let alert = aps["alert"] as? [String: Any] { + return (title: alert["title"] as? String, message: alert["body"] as? String) + } else if let alertString = aps["alert"] as? String { + return (title: nil, message: alertString) + } else { + return (title: nil, message: nil) + } + } + +} diff --git a/PingOneMFA/PingOneMFA/PingOneMFAError.swift b/PingOneMFA/PingOneMFA/PingOneMFAError.swift new file mode 100644 index 00000000..2fc48d86 --- /dev/null +++ b/PingOneMFA/PingOneMFA/PingOneMFAError.swift @@ -0,0 +1,75 @@ +// +// PingOneMFAError.swift +// PingOneMFA +// +// Copyright (c) 2025 - 2026 Ping Identity Corporation. All rights reserved. +// +// This software may be modified and distributed under the terms +// of the MIT license. See the LICENSE file for details. +// + +import Foundation + +/// Custom error type for PingOneMFA SDK exceptions. +/// +/// When an operation fails with one or more native SDK errors, ``internalErrorsList`` contains +/// structured representations of each error — useful for logging and diagnostics. +/// +/// ### Usage +/// ```swift +/// do { +/// try await PingOneMFA.setDeviceToken(deviceToken) +/// } catch let error as PingOneMFAError { +/// print(error.message) +/// error.internalErrorsList?.forEach { e in +/// print("code=\(e.code) message=\(e.message)") +/// } +/// } +/// ``` +public struct PingOneMFAError: Error, LocalizedError, Sendable { + public let message: String + + /// Structured list of individual SDK errors, or `nil` when the failure did not originate + /// from the native SDK (e.g. an unexpected exception). + public let internalErrorsList: [PingOneMFAInternalError]? + + public var errorDescription: String? { message } + + init(_ error: Error) { + if let existing = error as? PingOneMFAError { + self = existing + return + } + let nsError = error as NSError + let userInfoString = nsError.userInfo + .map { "\($0.key)=\($0.value)" } + .joined(separator: ", ") + self.message = "Code=\(nsError.code) \(nsError.localizedDescription)" + self.internalErrorsList = [PingOneMFAInternalError( + code: nsError.code, + message: nsError.localizedDescription, + userInfo: nsError.userInfo.compactMapValues { "\($0)" } + )] + } + + init(_ message: String = "Unknown error") { + self.message = message + self.internalErrorsList = nil + } + + /// Creates an error aggregating multiple native SDK errors. + /// - Parameter errors: The array of native `NSError` values returned by the upstream SDK. + init(errors: [Error]) { + let mapped = errors.map { e -> PingOneMFAInternalError in + let ns = e as NSError + return PingOneMFAInternalError( + code: ns.code, + message: ns.localizedDescription, + userInfo: ns.userInfo.compactMapValues { "\($0)" } + ) + } + let joined = mapped.map(\.message).joined(separator: "; ") + self.message = joined.isEmpty ? "Unknown error" : joined + self.internalErrorsList = mapped + } +} diff --git a/PingOneMFA/PingOneMFA/PingOneMFAInternalError.swift b/PingOneMFA/PingOneMFA/PingOneMFAInternalError.swift new file mode 100644 index 00000000..08e9c9fa --- /dev/null +++ b/PingOneMFA/PingOneMFA/PingOneMFAInternalError.swift @@ -0,0 +1,27 @@ +// +// PingOneMFAInternalError.swift +// PingOneMFA +// +// Copyright (c) 2025 - 2026 Ping Identity Corporation. All rights reserved. +// +// This software may be modified and distributed under the terms +// of the MIT license. See the LICENSE file for details. +// + +import Foundation + +/// Structured representation of a single PingOne SDK error. +/// +/// Instances are produced from the native SDK error type and exposed through +/// ``PingOneMFAError/internalErrorsList``. +/// +/// - SeeAlso: ``PingOneMFAError`` +public struct PingOneMFAInternalError: Sendable, Equatable { + /// Numeric error code returned by the PingOne MFA native SDK. + public let code: Int + /// Human-readable error message returned by the native SDK. + public let message: String + /// Additional diagnostic key/value pairs returned by the server. + /// May be empty if the server did not include additional context. + public let userInfo: [String: String] +} diff --git a/PingOneMFA/PingOneMFA/PingOneMfaAccount.swift b/PingOneMFA/PingOneMFA/PingOneMfaAccount.swift new file mode 100644 index 00000000..ff32140d --- /dev/null +++ b/PingOneMFA/PingOneMFA/PingOneMfaAccount.swift @@ -0,0 +1,63 @@ +// +// PingOneMfaAccount.swift +// PingOneMFA +// +// Copyright (c) 2025 - 2026 Ping Identity Corporation. All rights reserved. +// +// This software may be modified and distributed under the terms +// of the MIT license. See the LICENSE file for details. +// + +import Foundation + +/// Result returned by `PingOneMFA.getDeviceInfo()`. +public struct PingOneMFADeviceInfo: Sendable { + /// Registered PingOne MFA accounts found on this device. + public let accounts: [PingOneMfaAccount] + /// Non-fatal diagnostic errors returned by the upstream SDK, if any. + public let errors: [PingOneMFAError]? + + /// Creates a `PingOneMFADeviceInfo` result. + /// + /// - Parameters: + /// - accounts: Registered PingOne MFA accounts found on this device. + /// - errors: Non-fatal diagnostic errors returned by the upstream SDK. + public init(accounts: [PingOneMfaAccount], errors: [PingOneMFAError]? = nil) { + self.accounts = accounts + self.errors = errors + } +} + +/// Represents a registered PingOne MFA account on this device. +public struct PingOneMfaAccount: Sendable, Equatable { + /// The cloud region where this account is registered (e.g. `"NorthAmerica"`, `"Europe"`). + public let region: String + /// The unique identifier for this account. + public let id: String + /// The identifier of the device this account is bound to. + public let deviceId: String + /// The PingOne environment this account belongs to. + public let environmentId: String + /// The display name of the account. + public let name: String + /// The account family / application name shown to the user. + public let family: String + + /// Creates a `PingOneMfaAccount` with all required fields. + /// + /// - Parameters: + /// - region: The cloud region for this account. + /// - id: The unique account identifier. + /// - deviceId: The device identifier this account is bound to. + /// - environmentId: The PingOne environment identifier. + /// - name: The display name of the account. + /// - family: The account family / application name. + public init(region: String, id: String, deviceId: String, environmentId: String, name: String, family: String) { + self.region = region + self.id = id + self.deviceId = deviceId + self.environmentId = environmentId + self.name = name + self.family = family + } +} diff --git a/PingOneMFA/PingOneMFA/PrivacyInfo.xcprivacy b/PingOneMFA/PingOneMFA/PrivacyInfo.xcprivacy new file mode 100644 index 00000000..fcfc9b9f --- /dev/null +++ b/PingOneMFA/PingOneMFA/PrivacyInfo.xcprivacy @@ -0,0 +1,10 @@ + + + + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + + diff --git a/PingOneMFA/PingOneMFA/PushNotification.swift b/PingOneMFA/PingOneMFA/PushNotification.swift new file mode 100644 index 00000000..06c93762 --- /dev/null +++ b/PingOneMFA/PingOneMFA/PushNotification.swift @@ -0,0 +1,102 @@ +// +// PushNotification.swift +// PingOneMFA +// +// Copyright (c) 2025 - 2026 Ping Identity Corporation. All rights reserved. +// +// This software may be modified and distributed under the terms +// of the MIT license. See the LICENSE file for details. +// + +import Foundation +import PingOneSDK + +public typealias MFAPushNotification = PushNotification + +/// A push notification received from PingOne MFA representing an authentication request. +public struct PushNotification: Sendable, Identifiable { + /// A unique identifier for this notification instance. + public let id: String = UUID().uuidString + internal let notificationObject: NotificationObject + /// The notification title, if provided by the server. + public let title: String? + /// The notification body message, if provided by the server. + public let message: String? + + /// The interaction model required by this notification. + /// + /// Use this to determine the UI flow: + /// - `.silent` — no user interaction needed; the authentication completes silently. + /// - `.challenge` — present the number-matching challenge from `getNumbersChallenge`. + /// - `.default` — standard approve/deny prompt. + public let pushType: PushType + /// `true` when this notification represents a cancellation of an in-progress authentication. + public let isCancelAuthentication: Bool + + /// The list of number options presented to the user when `pushType` is `.challenge`. + /// Returns an empty array when number matching is not enabled. + public var getNumbersChallenge: [Int] { numberMatchingOptions } + private let numberMatchingOptions: [Int] + + internal init(notificationObject: NotificationObject, title: String?, message: String?) { + self.notificationObject = notificationObject + self.title = title + self.message = message + self.isCancelAuthentication = notificationObject.notificationType == .authCanceled + self.numberMatchingOptions = notificationObject.numberMatchingOptions + if notificationObject.notificationType == .done { + self.pushType = .dry + } else if !notificationObject.numberMatchingType.isEmpty { + self.pushType = .challenge + } else { + self.pushType = .default + } + } + + /// Approves the push notification authentication request. + /// + /// - Parameters: + /// - authMethod: The authentication method to use for approval. + /// - numberChallenge: The number matching challenge value, if applicable. + /// - Throws: `PingOneMFAError` if approval fails or the upstream SDK reports an error. + public func approveNotification(authMethod: String?, numberChallenge: Int?) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let numberMatchingPickedValue: NSNumber? = numberChallenge.map { NSNumber(value: $0) } + notificationObject.approve( + withAuthenticationMethod: authMethod, + numberMatchingPickedValue: numberMatchingPickedValue + ) { _, error in + if let error = error { + continuation.resume(throwing: PingOneMFAError(error)) + } else { + continuation.resume(returning: ()) + } + } + } + } + + /// Denies the push notification authentication request. + /// + /// - Throws: `PingOneMFAError` if denial fails. + public func denyNotification() async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + notificationObject.deny(reason: .none) { error in + if let error = error { + continuation.resume(throwing: PingOneMFAError(error)) + } else { + continuation.resume(returning: ()) + } + } + } + } +} + +// PingOneSDK's `NotificationObject` is a binary Objective-C type that is not annotated +// `Sendable`. We assert thread-safety here, at the type the assumption is actually about, +// rather than hiding it behind `@unchecked Sendable` on `PushNotification`: +// - The SDK delivers the object on its own queue and we hand it off serially +// (process → post over NotificationCenter → user tap), never mutating it concurrently. +// - `approveNotification`/`denyNotification` are the only members that touch it after init. +// Placing the conformance here means that if a future PingOneSDK marks `NotificationObject` +// as `Sendable`, this becomes a redundant-conformance compiler error and surfaces for review. +extension NotificationObject: @retroactive @unchecked Sendable {} diff --git a/PingOneMFA/PingOneMFA/PushType.swift b/PingOneMFA/PingOneMFA/PushType.swift new file mode 100644 index 00000000..ade64921 --- /dev/null +++ b/PingOneMFA/PingOneMFA/PushType.swift @@ -0,0 +1,25 @@ +// +// PushType.swift +// PingOneMFA +// +// Copyright (c) 2025 - 2026 Ping Identity Corporation. All rights reserved. +// +// This software may be modified and distributed under the terms +// of the MIT license. See the LICENSE file for details. +// + +import Foundation + +/// Describes the interaction model required by a PingOne MFA push notification. +public enum PushType: Sendable { + /// A standard authentication request. The user approves or denies with a single tap. + case `default` + + /// A test push sent by the server to verify the device's push registration. + /// No user action is required. + case dry + + /// A number-matching challenge. Use `getNumbersChallenge` to retrieve the options; + /// an empty array means free-form digit entry is expected. + case challenge +} diff --git a/PingOneMFA/PingOneMFATests/AccountParserTests.swift b/PingOneMFA/PingOneMFATests/AccountParserTests.swift new file mode 100644 index 00000000..9f35360a --- /dev/null +++ b/PingOneMFA/PingOneMFATests/AccountParserTests.swift @@ -0,0 +1,269 @@ +// +// AccountParserTests.swift +// PingOneMFA +// +// Copyright (c) 2025 - 2026 Ping Identity Corporation. All rights reserved. +// +// This software may be modified and distributed under the terms +// of the MIT license. See the LICENSE file for details. +// + +import XCTest +@testable import PingOneMFA + + +// MARK: - AccountParser Tests + +final class AccountParserTests: XCTestCase { + + // MARK: - Well-formed payload tests + + func testParseSingleRegionSingleUser() throws { + // Given — real payload shape from PingOne.getInfo + let deviceInfo: [String: Any] = [ + "NorthAmerica": [ + "users": [ + [ + "id": "c845dcd4-9696-45ce-b1b8-8797da941538", + "device": ["id": "05280532-42b0-4d29-93f2-9f2ed7acefc1"], + "environment": ["id": "803ca4d4-cd92-4cb8-9dd1-6fe68de0a5f0"] + ] + ], + "deviceRequirementsEvaluation": [ + "status": "PASSED", + "deviceRequirementsDataHash": "wz8xty+2gFcfepr5zPpg/7TJENBtxZQhRSVw3pVidiU=" + ], + "shouldRollback": 0 + ] + ] + + // When + let accounts = try AccountParser.parse(deviceInfo) + + // Then + XCTAssertEqual(accounts.count, 1) + XCTAssertEqual(accounts[0].region, "NorthAmerica") + XCTAssertEqual(accounts[0].id, "c845dcd4-9696-45ce-b1b8-8797da941538") + XCTAssertEqual(accounts[0].deviceId, "05280532-42b0-4d29-93f2-9f2ed7acefc1") + XCTAssertEqual(accounts[0].environmentId, "803ca4d4-cd92-4cb8-9dd1-6fe68de0a5f0") + } + + func testParseMultipleRegionsMultipleUsers() throws { + // Given + let deviceInfo: [String: Any] = [ + "NorthAmerica": [ + "users": [ + [ + "id": "user-na-1", + "device": ["id": "device-na-1"], + "environment": ["id": "env-na-1"] + ], + [ + "id": "user-na-2", + "device": ["id": "device-na-2"], + "environment": ["id": "env-na-2"] + ] + ] + ], + "Europe": [ + "users": [ + [ + "id": "user-eu-1", + "device": ["id": "device-eu-1"], + "environment": ["id": "env-eu-1"] + ] + ] + ] + ] + + // When + let accounts = try AccountParser.parse(deviceInfo) + + // Then + XCTAssertEqual(accounts.count, 3) + + let naAccounts = accounts.filter { $0.region == "NorthAmerica" } + XCTAssertEqual(naAccounts.count, 2) + + let euAccounts = accounts.filter { $0.region == "Europe" } + XCTAssertEqual(euAccounts.count, 1) + XCTAssertEqual(euAccounts[0].id, "user-eu-1") + } + + func testParseRegionWithEmptyUsersArray() throws { + // Given — region exists but users array is empty + let deviceInfo: [String: Any] = [ + "Australia": [ + "users": [[String: Any]]() + ] + ] + + // When + let accounts = try AccountParser.parse(deviceInfo) + + // Then + XCTAssertEqual(accounts.count, 0) + } + + func testParseRegionWithExtraKeysIgnored() throws { + // Given — non-"users" keys such as deviceRequirementsEvaluation and shouldRollback are ignored + let deviceInfo: [String: Any] = [ + "NorthAmerica": [ + "users": [ + [ + "id": "user-1", + "device": ["id": "device-1"], + "environment": ["id": "env-1"] + ] + ], + "deviceRequirementsEvaluation": ["status": "PASSED"], + "shouldRollback": 0 + ] + ] + + // When + let accounts = try AccountParser.parse(deviceInfo) + + // Then — extra keys do not affect parsing + XCTAssertEqual(accounts.count, 1) + XCTAssertEqual(accounts[0].id, "user-1") + } + + // MARK: - Edge cases + + func testParseNilDeviceInfo() throws { + // When + let accounts = try AccountParser.parse(nil) + + // Then + XCTAssertEqual(accounts.count, 0) + } + + func testParseEmptyDictionary() throws { + // When + let accounts = try AccountParser.parse([:]) + + // Then + XCTAssertEqual(accounts.count, 0) + } + + func testParseRegionValueNotADictionary() throws { + // Given — region value is a String, which is JSON-serializable but not decodable as RegionDto + let deviceInfo: [String: Any] = [ + "NorthAmerica": "not-a-dict" + ] + + // When / Then — throws because the top-level JSON structure is invalid for [String: RegionDto] + XCTAssertThrowsError(try AccountParser.parse(deviceInfo)) { error in + let pingError = error as? PingOneMFAError + XCTAssertNotNil(pingError) + XCTAssertTrue(pingError?.message.hasPrefix("Failed to decode device info:") == true) + } + } + + func testParseMissingUsersKeyInRegion() throws { + // Given — region dict has no "users" key; users is optional so this returns zero accounts + let deviceInfo: [String: Any] = [ + "NorthAmerica": [ + "deviceRequirementsEvaluation": ["status": "PASSED"] + ] + ] + + // When + let accounts = try AccountParser.parse(deviceInfo) + + // Then — no accounts, but no error + XCTAssertEqual(accounts.count, 0) + } + + func testParseMissingDeviceFieldYieldsEmptyDeviceId() throws { + // Given — user dict is missing the "device" field + let deviceInfo: [String: Any] = [ + "NorthAmerica": [ + "users": [ + [ + "id": "user-1", + // "device" missing + "environment": ["id": "env-1"] + ] + ] + ] + ] + + // When + let accounts = try AccountParser.parse(deviceInfo) + + // Then — user is still returned with empty deviceId + XCTAssertEqual(accounts.count, 1) + XCTAssertEqual(accounts[0].id, "user-1") + XCTAssertEqual(accounts[0].deviceId, "") + XCTAssertEqual(accounts[0].environmentId, "env-1") + } + + func testParseMissingEnvironmentIdYieldsEmptyEnvironmentId() throws { + // Given — environment dict exists but has no "id" key + let deviceInfo: [String: Any] = [ + "NorthAmerica": [ + "users": [ + [ + "id": "user-1", + "device": ["id": "device-1"], + "environment": ["wrongKey": "value"] + ] + ] + ] + ] + + // When + let accounts = try AccountParser.parse(deviceInfo) + + // Then — user is returned with empty environmentId + XCTAssertEqual(accounts.count, 1) + XCTAssertEqual(accounts[0].environmentId, "") + } + + func testParseMixedUsersAllIncluded() throws { + // Given — one full user, one missing device field + let deviceInfo: [String: Any] = [ + "NorthAmerica": [ + "users": [ + [ + "id": "full-user", + "device": ["id": "valid-device"], + "environment": ["id": "valid-env"] + ], + [ + "id": "partial-user", + // "device" missing — falls back to "" + "environment": ["id": "partial-env"] + ] + ] + ] + ] + + // When + let accounts = try AccountParser.parse(deviceInfo) + + // Then — both users are returned; partial user has empty deviceId + XCTAssertEqual(accounts.count, 2) + let partial = accounts.first { $0.id == "partial-user" } + XCTAssertNotNil(partial) + XCTAssertEqual(partial?.deviceId, "") + } + + // MARK: - Non-serializable value + + func testParseNonJSONSerializableValueThrows() { + // Given — Data/NSData is not JSON-serializable; would previously silently return [] + let deviceInfo: [String: Any] = [ + "NorthAmerica": Data([1, 2, 3]) + ] + + // When / Then — throws PingOneMFAError instead of silently returning [] + XCTAssertThrowsError(try AccountParser.parse(deviceInfo)) { error in + let pingError = error as? PingOneMFAError + XCTAssertNotNil(pingError) + XCTAssertTrue(pingError?.message.hasPrefix("Failed to serialize device info:") == true) + } + } +} diff --git a/PingOneMFA/PingOneMFATests/MockPingOneMFA.swift b/PingOneMFA/PingOneMFATests/MockPingOneMFA.swift new file mode 100644 index 00000000..1fe7ba29 --- /dev/null +++ b/PingOneMFA/PingOneMFATests/MockPingOneMFA.swift @@ -0,0 +1,149 @@ +// +// MockPingOneMFA.swift +// PingOneMFA +// +// Copyright (c) 2025 - 2026 Ping Identity Corporation. All rights reserved. +// +// This software may be modified and distributed under the terms +// of the MIT license. See the LICENSE file for details. +// +import Foundation +import UserNotifications +@testable import PingOneMFA + +class MockPingOneMFA { + private static let lock = NSLock() + nonisolated(unsafe) static var shouldThrowError = false + nonisolated(unsafe) static var errorMessage = "Operation failed" + nonisolated(unsafe) static var initializeCalled = false + nonisolated(unsafe) static var initializeCallCount = 0 + nonisolated(unsafe) static var registerPushTokenCalled = false + nonisolated(unsafe) static var pairCalled = false + nonisolated(unsafe) static var getDeviceInfoCalled = false + nonisolated(unsafe) static var getOneTimePasscodeCalled = false + nonisolated(unsafe) static var processRemoteNotificationCalled = false + nonisolated(unsafe) static var generateMobilePayloadCalled = false + nonisolated(unsafe) static var lastGeo: Geo? + + // Return values for happy-path tests + nonisolated(unsafe) static var accountsReturnValue: [PingOneMfaAccount] = [] + nonisolated(unsafe) static var otpReturnValue = OtpCodeInfo(code: "123456", secondsRemaining: 30) + nonisolated(unsafe) static var mobilePayloadReturnValue = "mockMobilePayload" + // processRemoteNotification cannot return a real PushNotification in tests because + // NotificationObject (from PingOneSDK) has no accessible initializer. The mock supports + // the nil-return path (SDK handles internally) and the error path. + nonisolated(unsafe) static var processRemoteNotificationReturnValue: PushNotification? = nil + + // getNotificationCategories tracking state + nonisolated(unsafe) static var getNotificationCategoriesCalled = false + nonisolated(unsafe) static var notificationCategoriesReturnValue: Set = [] + + // processRemoteNotificationAction tracking state + nonisolated(unsafe) static var processRemoteNotificationActionCalled = false + // processRemoteNotificationAction cannot return a real PushNotification in tests because + // NotificationObject (from PingOneSDK) has no accessible initialiser. The mock therefore + // only supports the nil-return and error paths for processRemoteNotificationAction. + nonisolated(unsafe) static var processRemoteNotificationActionReturnValue: PushNotification? = nil + nonisolated(unsafe) static var lastActionIdentifier: String? = nil + nonisolated(unsafe) static var lastActionAuthenticationMethod: String? = nil + + static func reset() { + shouldThrowError = false + errorMessage = "Operation failed" + initializeCalled = false + initializeCallCount = 0 + registerPushTokenCalled = false + pairCalled = false + getDeviceInfoCalled = false + getOneTimePasscodeCalled = false + processRemoteNotificationCalled = false + generateMobilePayloadCalled = false + lastGeo = nil + accountsReturnValue = [] + otpReturnValue = OtpCodeInfo(code: "123456", secondsRemaining: 30) + mobilePayloadReturnValue = "mockMobilePayload" + processRemoteNotificationReturnValue = nil + getNotificationCategoriesCalled = false + notificationCategoriesReturnValue = [] + processRemoteNotificationActionCalled = false + processRemoteNotificationActionReturnValue = nil + lastActionIdentifier = nil + lastActionAuthenticationMethod = nil + } + + static func initialize(geo: Geo) async throws { + lock.withLock { + initializeCalled = true + initializeCallCount += 1 + lastGeo = geo + } + if shouldThrowError { + throw PingOneMFAError(errorMessage) + } + } + + static func setDeviceToken(_ pushToken: Data) async throws { + registerPushTokenCalled = true + if shouldThrowError { + throw PingOneMFAError(errorMessage) + } + } + + static func pair(pairingKey: String) async throws { + pairCalled = true + if shouldThrowError { + throw PingOneMFAError(errorMessage) + } + } + + static func getDeviceInfo() async throws -> PingOneMFADeviceInfo { + getDeviceInfoCalled = true + if shouldThrowError { + throw PingOneMFAError(errorMessage) + } + return PingOneMFADeviceInfo(accounts: accountsReturnValue) + } + + static func getOneTimePasscode() async throws -> OtpCodeInfo { + getOneTimePasscodeCalled = true + if shouldThrowError { + throw PingOneMFAError(errorMessage) + } + return otpReturnValue + } + + static func processRemoteNotification(userInfo: [AnyHashable: Any]) async throws -> PushNotification? { + processRemoteNotificationCalled = true + if shouldThrowError { + throw PingOneMFAError(errorMessage) + } + return processRemoteNotificationReturnValue + } + + static func generateMobilePayload() async throws -> String { + generateMobilePayloadCalled = true + if shouldThrowError { + throw PingOneMFAError(errorMessage) + } + return mobilePayloadReturnValue + } + + static func getNotificationCategories() -> Set { + getNotificationCategoriesCalled = true + return notificationCategoriesReturnValue + } + + static func processRemoteNotificationAction( + identifier: String, + authenticationMethod: String?, + userInfo: [AnyHashable: Any] + ) async throws -> PushNotification? { + processRemoteNotificationActionCalled = true + lastActionIdentifier = identifier + lastActionAuthenticationMethod = authenticationMethod + if shouldThrowError { + throw PingOneMFAError(errorMessage) + } + return processRemoteNotificationActionReturnValue + } +} diff --git a/PingOneMFA/PingOneMFATests/PingOneMFAErrorTests.swift b/PingOneMFA/PingOneMFATests/PingOneMFAErrorTests.swift new file mode 100644 index 00000000..d8a12268 --- /dev/null +++ b/PingOneMFA/PingOneMFATests/PingOneMFAErrorTests.swift @@ -0,0 +1,78 @@ +// +// PingOneMFAErrorTests.swift +// PingOneMFA +// +// Copyright (c) 2025 - 2026 Ping Identity Corporation. All rights reserved. +// +// This software may be modified and distributed under the terms +// of the MIT license. See the LICENSE file for details. +// + +import XCTest +@testable import PingOneMFA + + +// MARK: - PingOneMFAError Tests + +final class PingOneMFAErrorTests: XCTestCase { + + func testInitWithMessage() { + let error = PingOneMFAError("Test error message") + + XCTAssertEqual(error.message, "Test error message") + } + + func testErrorDescriptionMatchesMessage() { + let message = "SDK initialization failed: connection timed out" + let error = PingOneMFAError(message) + + XCTAssertEqual(error.errorDescription, message) + } + + func testErrorDescriptionEqualsMessage() { + let error = PingOneMFAError("some error") + + XCTAssertEqual(error.errorDescription, error.message) + } + + func testConformsToError() { + // Compile-time check: PingOneMFAError conforms to Error + let error: Error = PingOneMFAError("error conformance test") + XCTAssertNotNil(error) + } + + func testConformsToLocalizedError() { + // Compile-time check: PingOneMFAError conforms to LocalizedError + let error: LocalizedError = PingOneMFAError("localized error test") + XCTAssertEqual(error.errorDescription, "localized error test") + } + + func testSendableConformance() { + // Compile-time check: PingOneMFAError is Sendable (can be used in async context) + let error = PingOneMFAError("sendable test") + Task { + let _ = error.message + } + XCTAssertEqual(error.message, "sendable test") + } + + func testErrorCanBeThrownAndCaught() { + let expectedMessage = "thrown error message" + + do { + throw PingOneMFAError(expectedMessage) + } catch let caught as PingOneMFAError { + XCTAssertEqual(caught.message, expectedMessage) + XCTAssertEqual(caught.errorDescription, expectedMessage) + } catch { + XCTFail("Wrong error type caught: \(error)") + } + } + + func testEmptyMessage() { + let error = PingOneMFAError("") + + XCTAssertEqual(error.message, "") + XCTAssertEqual(error.errorDescription, "") + } +} diff --git a/PingOneMFA/PingOneMFATests/PingOneMFATests.swift b/PingOneMFA/PingOneMFATests/PingOneMFATests.swift new file mode 100644 index 00000000..a309cd9e --- /dev/null +++ b/PingOneMFA/PingOneMFATests/PingOneMFATests.swift @@ -0,0 +1,538 @@ +// +// PingOneMFATests.swift +// PingOneMFATests +// +// Copyright (c) 2025 - 2026 Ping Identity Corporation. All rights reserved. +// +// This software may be modified and distributed under the terms +// of the MIT license. See the LICENSE file for details. +// + +import XCTest +import UserNotifications +@testable import PingOneMFA + +final class PingOneMFATests: XCTestCase { + + override func setUp() async throws { + try await super.setUp() + // Reset SDK state before each test + await PingOneMFA.reset() + MockPingOneMFA.reset() + } + + override func tearDown() async throws { + // Clean up after each test + await PingOneMFA.reset() + MockPingOneMFA.reset() + try await super.tearDown() + } + + // MARK: - Initialization Tests + + func test04_InitDoesNotReinitializeIfAlreadyInitialized() async throws { + let counter = CallCounter() + + // First call — configure runs once + try await PingOneMFA.initializeIfNeeded { + await counter.increment() + } + await XCTAssertEqualAsync(await counter.value, 1) + + // Second call — isInitialized is true, configure must be skipped + try await PingOneMFA.initializeIfNeeded { + await counter.increment() + } + await XCTAssertEqualAsync(await counter.value, 1) + } + + func test05_ConcurrentInitializeCallsShareSingleConfigure() async throws { + let probe = ConfigureProbe() + + try await withThrowingTaskGroup(of: Void.self) { group in + for _ in 0..<2 { + group.addTask { + try await PingOneMFA.initializeIfNeeded { + try await probe.configure() + } + } + } + + _ = await waitForConfigureCallCount(minimum: 1, probe: probe) + + for _ in 0..<50 { + await Task.yield() + } + + let callCount = await probe.callCount + XCTAssertEqual(callCount, 1) + + await probe.completeAll() + try await group.waitForAll() + } + + let isInitialized = await PingOneMFA.isInitialized + XCTAssertTrue(isInitialized) + } + + // MARK: - Mock-Based Happy-Path Tests + + func test06_MockInitializeHappyPath() async throws { + // Given + MockPingOneMFA.shouldThrowError = false + + // When + try await MockPingOneMFA.initialize(geo: .northAmerica) + + // Then + XCTAssertTrue(MockPingOneMFA.initializeCalled) + XCTAssertEqual(MockPingOneMFA.lastGeo, .northAmerica) + } + + func test07_MockSetDeviceTokenHappyPath() async throws { + // Given + MockPingOneMFA.shouldThrowError = false + let token = Data([0x01, 0x02, 0x03]) + + // When + try await MockPingOneMFA.setDeviceToken(token) + + // Then + XCTAssertTrue(MockPingOneMFA.registerPushTokenCalled) + } + + func test07b_MockSetDeviceTokenThrowsOnError() async { + // Given + MockPingOneMFA.shouldThrowError = true + MockPingOneMFA.errorMessage = "Token registration failed" + let token = Data([0x01, 0x02, 0x03]) + + // When / Then + do { + try await MockPingOneMFA.setDeviceToken(token) + XCTFail("Should have thrown an error") + } catch let error as PingOneMFAError { + XCTAssertEqual(error.message, "Token registration failed") + XCTAssertTrue(MockPingOneMFA.registerPushTokenCalled) + } catch { + XCTFail("Wrong error type: \(error)") + } + } + + func test08_MockPairHappyPath() async throws { + // Given + MockPingOneMFA.shouldThrowError = false + + // When + try await MockPingOneMFA.pair(pairingKey: "test-pairing-key") + + // Then + XCTAssertTrue(MockPingOneMFA.pairCalled) + } + + func test09_MockGetDeviceInfoHappyPath() async throws { + // Given + let expectedAccount = PingOneMfaAccount( + region: "NorthAmerica", + id: "user-id-1", + deviceId: "device-id-1", + environmentId: "env-id-1", + name: "Test User", + family: "User" + ) + MockPingOneMFA.accountsReturnValue = [expectedAccount] + + // When + let result = try await MockPingOneMFA.getDeviceInfo() + + // Then + XCTAssertTrue(MockPingOneMFA.getDeviceInfoCalled) + XCTAssertEqual(result.accounts.count, 1) + XCTAssertEqual(result.accounts[0], expectedAccount) + XCTAssertNil(result.errors) + } + + func test10_MockGetOneTimePasscodeHappyPath() async throws { + // Given + let expectedOtp = OtpCodeInfo(code: "654321", secondsRemaining: 25) + MockPingOneMFA.otpReturnValue = expectedOtp + + // When + let otpInfo = try await MockPingOneMFA.getOneTimePasscode() + + // Then + XCTAssertTrue(MockPingOneMFA.getOneTimePasscodeCalled) + XCTAssertEqual(otpInfo.code, "654321") + XCTAssertEqual(otpInfo.secondsRemaining, 25) + } + + func test11_MockGetMobilePayloadHappyPath() async throws { + // Given + MockPingOneMFA.mobilePayloadReturnValue = "test-payload-value" + + // When + let payload = try await MockPingOneMFA.generateMobilePayload() + + // Then + XCTAssertTrue(MockPingOneMFA.generateMobilePayloadCalled) + XCTAssertEqual(payload, "test-payload-value") + } + + // MARK: - Error-Path Tests + + func test12_MockThrowsErrorOnInitialize() async { + // Given + MockPingOneMFA.shouldThrowError = true + MockPingOneMFA.errorMessage = "Init failed" + + // When / Then + do { + try await MockPingOneMFA.initialize(geo: .northAmerica) + XCTFail("Should have thrown an error") + } catch let error as PingOneMFAError { + XCTAssertEqual(error.message, "Init failed") + } catch { + XCTFail("Wrong error type: \(error)") + } + } + + func test13_MockThrowsOnGetDeviceInfoError() async { + // Given + MockPingOneMFA.shouldThrowError = true + MockPingOneMFA.errorMessage = "Get accounts failed" + + // When / Then + do { + _ = try await MockPingOneMFA.getDeviceInfo() + XCTFail("Should have thrown an error") + } catch let error as PingOneMFAError { + XCTAssertTrue(MockPingOneMFA.getDeviceInfoCalled) + XCTAssertEqual(error.message, "Get accounts failed") + } catch { + XCTFail("Wrong error type: \(error)") + } + } + + // MARK: - Thread Safety Tests + + /// Mirrors ProtectTests.test12: fires 5 concurrent initialize() calls via mock, + /// asserts mock was called (idempotency under concurrency). + func test15_ConcurrentInitializationCalls() async throws { + // Given + MockPingOneMFA.shouldThrowError = false + MockPingOneMFA.initializeCallCount = 0 + + // When — multiple concurrent initialization calls + await withTaskGroup(of: Void.self) { group in + for _ in 0..<5 { + group.addTask { + do { + try await MockPingOneMFA.initialize(geo: .northAmerica) + } catch { + XCTFail("Mock initialization should not fail: \(error)") + } + } + } + } + + // Then — mock was called (concurrent invocations all completed) + XCTAssertTrue(MockPingOneMFA.initializeCalled) + XCTAssertEqual(MockPingOneMFA.initializeCallCount, 5) + } + + // MARK: - Edge Cases + + func test16_ResetFunctionality() async { + // When + await PingOneMFA.reset() + + // Then + let isInitialized = await PingOneMFA.isInitialized + XCTAssertFalse(isInitialized) + } + + // MARK: - processRemoteNotification Tests + + /// test18: SDK handles the notification internally — no PushNotification returned. + func test18_MockProcessRemoteNotificationReturnsNilWhenSDKHandlesInternally() async throws { + // Given + MockPingOneMFA.shouldThrowError = false + MockPingOneMFA.processRemoteNotificationReturnValue = nil + + // When + let result = try await MockPingOneMFA.processRemoteNotification(userInfo: [:]) + + // Then + XCTAssertTrue(MockPingOneMFA.processRemoteNotificationCalled) + XCTAssertNil(result) + } + + /// test18b: error-path — mock throws PingOneMFAError when shouldThrowError == true. + /// Happy-path (non-nil PushNotification) cannot be tested because NotificationObject + /// (PingOneSDK) has no accessible initialiser. + func test18b_MockProcessRemoteNotificationErrorPath() async { + // Given + MockPingOneMFA.shouldThrowError = true + MockPingOneMFA.errorMessage = "Collect push failed" + + // When / Then + do { + _ = try await MockPingOneMFA.processRemoteNotification(userInfo: [:]) + XCTFail("Should have thrown an error") + } catch let error as PingOneMFAError { + XCTAssertEqual(error.message, "Collect push failed") + XCTAssertTrue(MockPingOneMFA.processRemoteNotificationCalled) + } catch { + XCTFail("Wrong error type: \(error)") + } + } + + // MARK: - Value-Type Equality Smoke Tests + + func test19_OtpCodeInfoEquality() { + let a = OtpCodeInfo(code: "123456", secondsRemaining: 30) + let b = OtpCodeInfo(code: "123456", secondsRemaining: 30) + let c = OtpCodeInfo(code: "999999", secondsRemaining: 10) + + // Two instances with the same values are equal + XCTAssertEqual(a, b) + // Two instances with different values are not equal + XCTAssertNotEqual(a, c) + } + + func test20_PingOneMfaAccountEquality() { + let a = PingOneMfaAccount( + region: "NorthAmerica", + id: "user-1", + deviceId: "device-1", + environmentId: "env-1", + name: "Test User", + family: "User" + ) + let b = PingOneMfaAccount( + region: "NorthAmerica", + id: "user-1", + deviceId: "device-1", + environmentId: "env-1", + name: "Test User", + family: "User" + ) + let c = PingOneMfaAccount( + region: "Europe", + id: "user-2", + deviceId: "device-2", + environmentId: "env-2", + name: "Another User", + family: "User" + ) + + // Two instances with the same values are equal + XCTAssertEqual(a, b) + // Two instances with different values are not equal + XCTAssertNotEqual(a, c) + } + + func test20b_DeviceInfoResultExposesAccountsAndErrors() { + let account = PingOneMfaAccount( + region: "NorthAmerica", + id: "user-1", + deviceId: "device-1", + environmentId: "env-1", + name: "Test", + family: "User" + ) + let diagnosticError = PingOneMFAError("diagnostic") + + let result = PingOneMFADeviceInfo(accounts: [account], errors: [diagnosticError]) + + XCTAssertEqual(result.accounts, [account]) + XCTAssertEqual(result.errors?.first?.message, "diagnostic") + } + + // MARK: - getNotificationCategories Mock Test + + /// test21: verifies that MockPingOneMFA.getNotificationCategories() sets the + /// getNotificationCategoriesCalled flag and returns the configured category set. + /// No real PingOneSDK call is made — the mock is exercised directly. + func test21_MockGetNotificationCategoriesReturnsConfiguredCategories() { + // Given + let category = UNNotificationCategory( + identifier: "test.category", + actions: [], + intentIdentifiers: [] + ) + MockPingOneMFA.notificationCategoriesReturnValue = [category] + + // When + let returned = MockPingOneMFA.getNotificationCategories() + + // Then + XCTAssertTrue(MockPingOneMFA.getNotificationCategoriesCalled) + XCTAssertEqual(returned.count, 1) + XCTAssertTrue(returned.contains(where: { $0.identifier == "test.category" })) + } + + // MARK: - processRemoteNotificationAction Mock Tests + + /// test22: verifies that MockPingOneMFA.processRemoteNotificationAction returns nil when the + /// SDK handles the action internally (no PushNotification needed). + /// Happy-path (non-nil return) cannot be tested because NotificationObject (PingOneSDK) + /// has no accessible initialiser, preventing construction of a PushNotification stub value. + func test22_MockProcessRemoteNotificationActionReturnsNilWhenSDKHandlesInternally() async throws { + // Given + MockPingOneMFA.shouldThrowError = false + MockPingOneMFA.processRemoteNotificationActionReturnValue = nil + + // When + let result = try await MockPingOneMFA.processRemoteNotificationAction( + identifier: "notification.confirm", + authenticationMethod: "user", + userInfo: [:] + ) + + // Then + XCTAssertTrue(MockPingOneMFA.processRemoteNotificationActionCalled) + XCTAssertNil(result) + XCTAssertEqual(MockPingOneMFA.lastActionIdentifier, "notification.confirm") + XCTAssertEqual(MockPingOneMFA.lastActionAuthenticationMethod, "user") + } + + /// test23: verifies that MockPingOneMFA.processRemoteNotificationAction throws a PingOneMFAError + /// when shouldThrowError is set, exercising the error path. + func test23_MockProcessRemoteNotificationActionErrorPath() async { + // Given + MockPingOneMFA.shouldThrowError = true + MockPingOneMFA.errorMessage = "Process notification action failed" + + // When / Then + do { + _ = try await MockPingOneMFA.processRemoteNotificationAction( + identifier: "notification.deny", + authenticationMethod: "user", + userInfo: [:] + ) + XCTFail("Should have thrown an error") + } catch let error as PingOneMFAError { + XCTAssertEqual(error.message, "Process notification action failed") + XCTAssertTrue(MockPingOneMFA.processRemoteNotificationActionCalled) + XCTAssertEqual(MockPingOneMFA.lastActionIdentifier, "notification.deny") + } catch { + XCTFail("Wrong error type: \(error)") + } + } + + // MARK: - parseAPNSAlert Tests + + func test24_ParseAPNSAlert_DictAlert_ReturnsTitleAndBody() { + let userInfo: [AnyHashable: Any] = [ + "aps": ["alert": ["title": "Auth Request", "body": "Approve login?"]] + ] + let (title, message) = PingOneMFA.parseAPNSAlert(from: userInfo) + XCTAssertEqual(title, "Auth Request") + XCTAssertEqual(message, "Approve login?") + } + + func test25_ParseAPNSAlert_StringAlert_ReturnsNilTitleAndStringMessage() { + let userInfo: [AnyHashable: Any] = [ + "aps": ["alert": "You have a new login request"] + ] + let (title, message) = PingOneMFA.parseAPNSAlert(from: userInfo) + XCTAssertNil(title) + XCTAssertEqual(message, "You have a new login request") + } + + func test26_ParseAPNSAlert_MissingAlert_ReturnsBothNil() { + let userInfo: [AnyHashable: Any] = ["aps": [:]] + let (title, message) = PingOneMFA.parseAPNSAlert(from: userInfo) + XCTAssertNil(title) + XCTAssertNil(message) + } + + func test27_ParseAPNSAlert_MissingAps_ReturnsBothNil() { + let userInfo: [AnyHashable: Any] = ["custom": "value"] + let (title, message) = PingOneMFA.parseAPNSAlert(from: userInfo) + XCTAssertNil(title) + XCTAssertNil(message) + } + + func test28_ParseAPNSAlert_DictAlertMissingBody_ReturnsNilMessage() { + let userInfo: [AnyHashable: Any] = [ + "aps": ["alert": ["title": "Auth Request"]] + ] + let (title, message) = PingOneMFA.parseAPNSAlert(from: userInfo) + XCTAssertEqual(title, "Auth Request") + XCTAssertNil(message) + } + + func test29_ParseAPNSAlert_DictAlertMissingTitle_ReturnsNilTitle() { + let userInfo: [AnyHashable: Any] = [ + "aps": ["alert": ["body": "Approve login?"]] + ] + let (title, message) = PingOneMFA.parseAPNSAlert(from: userInfo) + XCTAssertNil(title) + XCTAssertEqual(message, "Approve login?") + } + + func test30_ParseAPNSAlert_EmptyUserInfo_ReturnsBothNil() { + let (title, message) = PingOneMFA.parseAPNSAlert(from: [:]) + XCTAssertNil(title) + XCTAssertNil(message) + } + + private func waitForConfigureCallCount(minimum: Int, probe: ConfigureProbe) async -> Int { + var latestCount = await probe.callCount + var attempts = 0 + + while latestCount < minimum && attempts < 100 { + try? await Task.sleep(nanoseconds: 10_000_000) + latestCount = await probe.callCount + attempts += 1 + } + + return latestCount + } +} + +private func XCTAssertEqualAsync( + _ expression1: @autoclosure () async throws -> T, + _ expression2: @autoclosure () async throws -> T, + file: StaticString = #filePath, + line: UInt = #line +) async { + do { + let lhs = try await expression1() + let rhs = try await expression2() + XCTAssertEqual(lhs, rhs, file: file, line: line) + } catch { + XCTFail("Unexpected error: \(error)", file: file, line: line) + } +} + +private actor CallCounter { + private(set) var value = 0 + func increment() { value += 1 } +} + +private actor ConfigureProbe { + private(set) var callCount = 0 + private var continuations: [CheckedContinuation] = [] + + func configure() async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + callCount += 1 + continuations.append(continuation) + } + } + + func completeAll(error: Error? = nil) { + let pendingContinuations = continuations + continuations.removeAll() + pendingContinuations.forEach { continuation in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } + } +} diff --git a/PingOneMFA/README.md b/PingOneMFA/README.md new file mode 100644 index 00000000..d8111742 --- /dev/null +++ b/PingOneMFA/README.md @@ -0,0 +1,385 @@ +[![Ping Identity](https://www.pingidentity.com/content/dam/picr/nav/Ping-Logo-2.svg)](https://github.com/ForgeRock/ping-ios-sdk) + +# PingOne MFA + +## Overview + +The `PingOneMFA` module wraps the PingOne MFA native SDK [PingOneSDK](https://github.com/pingidentity/pingone-mobile-sdk-ios) behind a clean, async/await Swift API. It is the adapter layer between your application and the PingOne MFA platform. All PingOne SDK callbacks are bridged to `async throws` functions — callers never need to work with raw callback closures. + +--- + +## Features + +- **Device Pairing** — pair new MFA accounts by scanning a QR code or entering a pairing key +- **OTP** — retrieve the current one-time passcode and its remaining validity window +- **Push Notifications (foreground)** — approve or deny incoming authentication requests while the app is active +- **Push Notifications (banner actions)** — handle Approve/Deny taps on system notification banners via `processRemoteNotificationAction`; register the required categories with `getNotificationCategories` +- **Mobile Payload** — generate a cryptographic mobile payload for server-side authentication flows + +--- + +## Getting Started + +### Prerequisites + +- iOS 16 or higher +- A PingOne environment with push notifications and/or MFA configured. For documentation on setting up PingOne MFA, see [PingOne MFA documentation](https://docs.pingidentity.com/pingone/strong_authentication_mfa/p1_strong_authentication_configure_mobile_applications.html). + +--- + +## Architecture Overview + +``` +┌──────────────────────────────────────────────────┐ +│ Your Application │ +│ │ +│ ┌────────────────────────────────────────────┐ │ +│ │ Push / OTP / Pairing Handlers │ │ +│ │ (your app code) │ │ +│ └──────────────────────┬─────────────────────┘ │ +│ │ │ +│ ┌──────────────────────▼─────────────────────┐ │ +│ │ PingOneMFA module │ │ +│ │ PingOneMFA (class) │ │ +│ └──────────────────────┬─────────────────────┘ │ +└──────────────────────────┼──────────────────────-┘ + │ + ┌────────────▼─────────┐ + │ PingOne MFA SDK │ + └──────────────────────┘ +``` + +--- + +## Add Dependency + +Add the package via Swift Package Manager in Xcode: + +```swift +// Package.swift +dependencies: [ + .package(url: "https://github.com/ForgeRock/ping-ios-sdk", from: "") +] +``` + +Then add `PingOneMFA` to your target's dependencies. + +--- + +## Setup and Configuration + +### 1. Initialize the SDK + +Call `initialize()` once at application startup, before any other call. Pass the `Geo` that matches your PingOne environment's service region. The call is idempotent — repeated calls after a successful initialisation return immediately without re-entering the native SDK. + +```swift +do { + try await PingOneMFA.initialize(geo: .northAmerica) +} catch { + print("Initialisation failed: \(error.localizedDescription)") +} +``` + +Supported regions: + +| `Geo` | PingOne region | +|---|---| +| `.northAmerica` | North America | +| `.europe` | Europe | +| `.canada` | Canada | +| `.australia` | Australia | +| `.singapore` | Singapore | + +### 2. Register the APNS Push Token + +Call `setDeviceToken()` each time the system delivers a new push token — typically from `application(_:didRegisterForRemoteNotificationsWithDeviceToken:)`. `initialize(geo:)` must have completed successfully before this call; `setDeviceToken` passes the token directly to the native PingOne SDK without re-initializing. + +```swift +func application(_ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + Task { + do { + try await ensurePingOneMFAInitialized() + try await PingOneMFA.setDeviceToken(deviceToken) + } catch { + print("Token registration failed: \(error.localizedDescription)") + } + } +} +``` + +Uses `.sandbox` token type in `DEBUG` builds and `.production` otherwise. + +### 3. Register Notification Categories + +At app launch, register PingOneMFA's notification categories with `UNUserNotificationCenter` so that actionable banner notifications are delivered correctly: + +```swift +let categories = PingOneMFA.getNotificationCategories() +UNUserNotificationCenter.current().setNotificationCategories(categories) +``` + +--- + +## Usage + +### Device Pairing + +```swift +do { + try await PingOneMFA.pair(pairingKey: pairingKey) + // Pairing succeeded — update UI as needed +} catch { + print("Pairing failed: \(error.localizedDescription)") +} +``` + +### Retrieve Paired Accounts + +```swift +do { + let deviceInfo = try await PingOneMFA.getDeviceInfo() + for account in deviceInfo.accounts { + print("\(account.name) \(account.family) | region: \(account.region)") + } + if let errors = deviceInfo.errors, !errors.isEmpty { + print("Retrieved accounts with diagnostics: \(errors)") + } +} catch { + print("Failed to retrieve accounts: \(error.localizedDescription)") +} +``` + +### OTP + +```swift +do { + let otp = try await PingOneMFA.getOneTimePasscode() + showCode(otp.code, otp.secondsRemaining) +} catch { + print("OTP failed: \(error.localizedDescription)") +} +``` + +`OtpCodeInfo.secondsRemaining` is a non-negative snapshot computed at call time. Re-call `getOneTimePasscode()` when it reaches zero to receive the next code. + +### Mobile Payload + +```swift +do { + let payload = try await PingOneMFA.generateMobilePayload() + // Submit payload to your server-side authentication flow +} catch { + print("Mobile payload failed: \(error.localizedDescription)") +} +``` + +### Push Notifications — Foreground + +When your app is in the foreground, process the incoming notification and present the appropriate UI based on push type: + +```swift +// In application(_:didReceiveRemoteNotification:fetchCompletionHandler:): +do { + if let push = try await PingOneMFA.processRemoteNotification(userInfo: userInfo) { + switch push.pushType { + case .default: showApproveDenyUI(push) + case .challenge: showNumberChallengeUI(push) + case .dry: break // test push — no user action required + } + } +} catch { + print("Push processing failed: \(error.localizedDescription)") +} +``` + +After the user responds: + +```swift +// Approve (pass numberChallenge for .challenge type, nil for .default) +do { + try await push.approveNotification(authMethod: "user", numberChallenge: selectedNumber) // or "biometric", depending on your UI +} catch { + print("Approve failed: \(error.localizedDescription)") +} + +// Deny +do { + try await push.denyNotification() +} catch { + print("Deny failed: \(error.localizedDescription)") +} +``` + +For number-matching challenge pushes, retrieve the options provided by the server: + +```swift +let options: [Int] = push.getNumbersChallenge +// empty array when the server expects free-form digit entry +``` + +### Push Notifications — Notification Banner Actions + +When the user taps Approve or Deny on the system notification banner, handle the action via `UNUserNotificationCenterDelegate`: + +```swift +func userNotificationCenter(_ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void) { + Task { + do { + let push = try await PingOneMFA.processRemoteNotificationAction( + identifier: response.actionIdentifier, + authenticationMethod: nil, + userInfo: response.notification.request.content.userInfo + ) + // push is nil when the SDK handled the action internally (no UI needed) + if let push = push { + // present approve/deny UI + } + } catch { + print("Notification action failed: \(error.localizedDescription)") + } + completionHandler() + } +} +``` + +--- + +## Error Handling + +All `async` functions throw `PingOneMFAError` on failure. The native `PingOneSDKError` type is not exposed, so your app does not need a direct dependency on the `PingOneSDK` framework. + +```swift +do { + try await PingOneMFA.pair(pairingKey: pairingKey) +} catch let error as PingOneMFAError { + print(error.message) + error.internalErrorsList?.forEach { e in + print("code=\(e.code) message=\(e.message)") + } +} catch { + print(error.localizedDescription) +} +``` + +--- + +## Sample Application + +PingOne MFA functionality is demonstrated in the [PingExample](../SampleApps/PingExample) sample app under the **PINGONE MFA** section: + +- QR code scanning for device pairing +- Paired accounts list +- OTP display with live countdown +- Push notification handling for DEFAULT, CHALLENGE, and DRY push types + +See the [PingExample README](../SampleApps/PingExample/README.md) for build instructions. + +--- + +## API Reference + +### `PingOneMFA` + +| Function | Returns | Description | +|---|---|---| +| `initialize(geo:)` | `async throws` | Configure the PingOne SDK for the selected service region. Idempotent after first success. | +| `setDeviceToken(_:)` | `async throws` | Register or refresh the APNS push token with PingOne. | +| `pair(pairingKey:)` | `async throws` | Pair a new MFA account. | +| `getDeviceInfo()` | `async throws -> PingOneMFADeviceInfo` | Return all paired accounts and any non-fatal diagnostic errors. | +| `getOneTimePasscode()` | `async throws -> OtpCodeInfo` | Return the current TOTP code and its remaining validity window. | +| `processRemoteNotification(userInfo:)` | `async throws -> PushNotification?` | Convert an APNS `userInfo` payload to a typed `PushNotification`. | +| `processRemoteNotificationAction(identifier:authenticationMethod:userInfo:)` | `async throws -> PushNotification?` | Handle a notification banner action; returns `nil` when the SDK handled it internally. | +| `generateMobilePayload()` | `async throws -> String` | Generate a mobile payload for server-side authentication. | +| `getNotificationCategories()` | `Set` | Return notification categories to register with `UNUserNotificationCenter`. | + +### `PingOneMFADeviceInfo` + +| Field | Type | Description | +|---|---|---| +| `accounts` | `[PingOneMfaAccount]` | Parsed paired accounts found on this device | +| `errors` | `[PingOneMFAError]?` | Non-fatal diagnostic errors returned by the upstream SDK while account data was still available | + +### `PingOneMfaAccount` + +| Field | Type | Description | +|---|---|---| +| `region` | `String` | Region key from the PingOne response (e.g. `"NorthAmerica"`, `"Europe"`) | +| `id` | `String` | PingOne user ID | +| `deviceId` | `String` | Device ID within PingOne | +| `environmentId` | `String` | PingOne environment ID | +| `name` | `String` | User's given name | +| `family` | `String` | User's family name | + +### `OtpCodeInfo` + +| Field | Type | Description | +|---|---|---| +| `code` | `String` | Current TOTP passcode | +| `secondsRemaining` | `Int` | Non-negative seconds until the code expires (0 if already expired, snapshot at call time) | + +### `PushNotification` + +| Method / Field | Type | Description | +|---|---|---| +| `id` | `String` | Unique identifier (UUID) for this notification instance | +| `approveNotification(authMethod:numberChallenge:)` | `async throws` | Approve the push authentication request | +| `denyNotification()` | `async throws` | Deny the push authentication request | +| `getNumbersChallenge` | `[Int]` | Options for a number-matching CHALLENGE push; empty array when free-form digit entry is expected | +| `pushType` | `PushType` | The interaction model required by this push (see `PushType`) | +| `isCancelAuthentication` | `Bool` | `true` when the notification type is `.authCanceled` | +| `title` | `String?` | Notification title extracted from the APNS payload | +| `message` | `String?` | Notification body extracted from the APNS payload | + +### `PushType` + +| Value | Description | +|---|---| +| `.default` | Standard authentication request — the user approves or denies with a single tap | +| `.challenge` | Number-matching push — present the options from `getNumbersChallenge`; an empty array means free-form digit entry is expected | +| `.dry` | Silent test push sent by the server to verify push registration — no user action required | + +### `PingOneMFAError` + +Thrown by all `async` functions on failure. The native `PingOneSDKError` is not exposed. + +| Field | Type | Description | +|---|---|---| +| `message` | `String` | Human-readable error message; also surfaced via `localizedDescription` | +| `internalErrorsList` | `[PingOneMFAInternalError]?` | Structured list of individual SDK errors; `nil` when the failure did not originate from the native SDK | + +### `PingOneMFAInternalError` + +Individual error entry within `PingOneMFAError.internalErrorsList`. + +| Field | Type | Description | +|---|---|---| +| `code` | `Int` | Numeric error code returned by the PingOne MFA native SDK | +| `message` | `String` | Human-readable error message returned by the native SDK | +| `userInfo` | `[String: String]` | Additional diagnostic key/value pairs returned by the server; may be empty | + +--- + +## Troubleshooting + +**Push notifications not received:** +- Verify the device token was registered via `PingOneMFA.setDeviceToken(_:)`. +- Confirm push notification entitlements and capabilities are enabled in your Xcode project. +- Check that the PingOne environment has APNS configured. + +**`getOneTimePasscode()` fails with a device-not-paired error:** +- Ensure `PingOneMFA.pair(pairingKey:)` was called and succeeded before requesting an OTP. + +**`generateMobilePayload()` fails:** +- Ensure `PingOneMFA.initialize(geo:)` was called and succeeded before this call. +- Check network connectivity and PingOne service status. + +--- + +## License + +Copyright (c) 2025 - 2026 Ping Identity Corporation. All rights reserved. + +This software may be modified and distributed under the terms of the MIT license. See the [LICENSE](../LICENSE) file for details. diff --git a/PingTestHost/PingTestHost.xcodeproj/xcshareddata/xcschemes/PingTestHost.xcscheme b/PingTestHost/PingTestHost.xcodeproj/xcshareddata/xcschemes/PingTestHost.xcscheme index 0eb46470..dc7ee104 100644 --- a/PingTestHost/PingTestHost.xcodeproj/xcshareddata/xcschemes/PingTestHost.xcscheme +++ b/PingTestHost/PingTestHost.xcodeproj/xcshareddata/xcschemes/PingTestHost.xcscheme @@ -59,6 +59,20 @@ reference = "container:PingTestHost.xctestplan"> + + + + + + + + = [] + func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil @@ -30,6 +34,12 @@ class AppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUserNotifi // Register for remote notifications application.registerForRemoteNotifications() + // Register PingOneMFA notification categories so the system can deliver + // actionable banner notifications (approve / deny actions). + let pingOneMFACategories = PingOneMFA.getNotificationCategories() + pingOneMFACategoryIdentifiers = Set(pingOneMFACategories.map { $0.identifier }) + UNUserNotificationCenter.current().setNotificationCategories(pingOneMFACategories) + return true } @@ -49,6 +59,14 @@ class AppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUserNotifi // MARK: - Helper Methods + /// Ensures PingOneMFA SDK is initialized. + /// - Throws: Error if initialization fails. + private func ensurePingOneMFAInitialized() async throws { + if !ConfigurationManager.shared.isPingOneMFAInitialized { + try await ConfigurationManager.shared.initializePingOneMFAClient() + } + } + /// Ensures PushClient is initialized and returns it /// - Returns: Initialized PushClient instance /// - Throws: Error if initialization fails @@ -93,6 +111,17 @@ class AppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUserNotifi print("Failed to update device token: \(error.localizedDescription)") } } + + // Register raw APNS token with PingOneMFA + Task { + do { + try await ensurePingOneMFAInitialized() + try await PingOneMFA.setDeviceToken(deviceToken) + print("PingOneMFA device token registered successfully") + } catch { + print("Failed to register PingOneMFA device token: \(error.localizedDescription)") + } + } } func application( @@ -113,25 +142,47 @@ class AppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUserNotifi print("Received push notification in foreground") print("Raw notification userInfo: \(userInfo)") - // Process the notification through PushClient nonisolated(unsafe) let userInfoCopy = userInfo + + // Call immediately so the system knows how to present the banner. + completionHandler([.banner, .sound, .badge]) + + // Process in background — hold an assertion so the system doesn't suspend + // before the async work completes. + let bgTask = UIApplication.shared.beginBackgroundTask(withName: "willPresent-processing") + Task { + defer { UIApplication.shared.endBackgroundTask(bgTask) } do { - let client = try await getInitializedPushClient() - - // Process the notification - PushClient automatically extracts APNs payload - if let pushNotification = try await client.processNotification(userInfo: userInfoCopy) { - print("Processed foreground push notification - ID: \(pushNotification.id), MessageID: \(pushNotification.messageId)") - } else { - print("Foreground notification was not processed (may be unsupported type)") + try await ensurePingOneMFAInitialized() + let pingOneMFANotification: MFAPushNotification? = try await PingOneMFA.processRemoteNotification(userInfo: userInfoCopy) + if let pingOneMFANotification = pingOneMFANotification { + print("Processed PingOneMFA foreground push notification") + if pingOneMFANotification.pushType != .dry { + NotificationCenter.default.post( + name: NSNotification.Name("ShowPingOneMFANotification"), + object: nil, + userInfo: ["notification": pingOneMFANotification] + ) + } } } catch { - print("Failed to process foreground push notification: \(error.localizedDescription)") + // PingOne SDK throws when the notification isn't a PingOne MFA push — fall through to PushClient. + print("Failed to process PingOneMFA foreground push notification: \(error.localizedDescription)") + do { + let client = try await getInitializedPushClient() + + // Process the notification - PushClient automatically extracts APNs payload + if let pushNotification = try await client.processNotification(userInfo: userInfoCopy) { + print("Processed foreground push notification - ID: \(pushNotification.id), MessageID: \(pushNotification.messageId)") + } else { + print("Foreground notification was not processed (may be unsupported type)") + } + } catch { + print("Failed to process foreground push notification: \(error.localizedDescription)") + } } } - - // Show notification even when app is in foreground - completionHandler([.banner, .sound, .badge]) } func userNotificationCenter( @@ -140,32 +191,68 @@ class AppDelegate: NSObject, UIApplicationDelegate, @preconcurrency UNUserNotifi withCompletionHandler completionHandler: @escaping @Sendable () -> Void ) { let userInfo = response.notification.request.content.userInfo + let categoryIdentifier = response.notification.request.content.categoryIdentifier print("Received push notification tap") print("Raw notification userInfo: \(userInfo)") - // Process the notification through PushClient nonisolated(unsafe) let userInfoCopy = userInfo - Task { - do { - let client = try await getInitializedPushClient() + let actionIdentifier = response.actionIdentifier - // Process the notification - PushClient automatically extracts APNs payload - if let notification = try await client.processNotification(userInfo: userInfoCopy) { - print("Processed push notification successfully - ID: \(notification.id), MessageID: \(notification.messageId)") - - // Navigate to Push Notifications view - NotificationCenter.default.post( - name: NSNotification.Name("NavigateToPushNotifications"), - object: nil - ) - } else { - print("Notification was not processed (may be unsupported type)") + let bgTask = UIApplication.shared.beginBackgroundTask(withName: "didReceive-processing") + + // Route to PingOneMFA if the category matches one registered by PingOneMFA. + if pingOneMFACategoryIdentifiers.contains(categoryIdentifier) { + Task { + defer { + completionHandler() + UIApplication.shared.endBackgroundTask(bgTask) + } + do { + try await ensurePingOneMFAInitialized() + if let pingOneMFANotification: MFAPushNotification = try await PingOneMFA.processRemoteNotificationAction( + identifier: actionIdentifier, + authenticationMethod: "user", + userInfo: userInfoCopy + ) { + print("Processed PingOneMFA banner action: \(actionIdentifier)") + NotificationCenter.default.post( + name: NSNotification.Name("ShowPingOneMFANotification"), + object: nil, + userInfo: ["notification": pingOneMFANotification] + ) + } else { + print("PingOneMFA handled notification action internally: \(actionIdentifier)") + } + } catch { + print("Failed to process PingOneMFA notification action: \(error.localizedDescription)") } - } catch { - print("Failed to process push notification: \(error.localizedDescription)") } - } + } else { + // Process the notification through PushClient (existing PingPush flow) + Task { + defer { + completionHandler() + UIApplication.shared.endBackgroundTask(bgTask) + } + do { + let client = try await getInitializedPushClient() - completionHandler() + // Process the notification - PushClient automatically extracts APNs payload + if let notification = try await client.processNotification(userInfo: userInfoCopy) { + print("Processed push notification successfully - ID: \(notification.id), MessageID: \(notification.messageId)") + + // Navigate to Push Notifications view + NotificationCenter.default.post( + name: NSNotification.Name("NavigateToPushNotifications"), + object: nil + ) + } else { + print("Notification was not processed (may be unsupported type)") + } + } catch { + print("Failed to process push notification: \(error.localizedDescription)") + } + } + } } } diff --git a/SampleApps/PingExample/PingExample/ConfigurationManager.swift b/SampleApps/PingExample/PingExample/ConfigurationManager.swift index 71801e12..6e101406 100644 --- a/SampleApps/PingExample/PingExample/ConfigurationManager.swift +++ b/SampleApps/PingExample/PingExample/ConfigurationManager.swift @@ -20,6 +20,7 @@ import PingStorage import PingOath import PingPush import PingLogger +import PingOneMFA //The ConfigurationManager class is used to manage the configuration settings for the SDK. //The class provides the following functionality: @@ -63,6 +64,7 @@ class ConfigurationManager: ObservableObject { // MFA Clients public var oathClient: OathClient? public var pushClient: PushClient? + public var isPingOneMFAInitialized: Bool = false // MFA Services public var oathTimerService: OathTimerService? @@ -450,11 +452,19 @@ class ConfigurationManager: ObservableObject { config.logger = LogManager.logger } } - + if let client = client { self.pushClient = client } } + + /// Initialize the PingOne MFA SDK + public func initializePingOneMFAClient() async throws { + guard !isPingOneMFAInitialized else { return } + + try await PingOneMFA.initialize(geo: .northAmerica) + isPingOneMFAInitialized = true + } } // MARK: - Actor for Thread-Safe Initialization @@ -464,24 +474,24 @@ private actor ClientInitializationActor { private var isPushInitializing = false private var oathInitialized = false private var pushInitialized = false - + func initializeOath(factory: @Sendable () async throws -> OathClient) async throws -> OathClient? { guard !oathInitialized && !isOathInitializing else { return nil } - + isOathInitializing = true defer { isOathInitializing = false } - + let client = try await factory() oathInitialized = true return client } - + func initializePush(factory: @Sendable () async throws -> PushClient) async throws -> PushClient? { guard !pushInitialized && !isPushInitializing else { return nil } - + isPushInitializing = true defer { isPushInitializing = false } - + let client = try await factory() pushInitialized = true return client diff --git a/SampleApps/PingExample/PingExample/ContentView.swift b/SampleApps/PingExample/PingExample/ContentView.swift index 6c2a7e5a..9bfee97b 100644 --- a/SampleApps/PingExample/PingExample/ContentView.swift +++ b/SampleApps/PingExample/PingExample/ContentView.swift @@ -20,6 +20,7 @@ import PingProtect import PingBinding import PingOath import PingPush +import PingOneMFA /// The main application entry point. @main @@ -52,6 +53,7 @@ enum MenuSection: CaseIterable, Identifiable { case authentication case userManagement case mfa + case pingOneMFA case developerTools var id: String { title } @@ -61,6 +63,7 @@ enum MenuSection: CaseIterable, Identifiable { case .authentication: return "Authentication" case .userManagement: return "User Management" case .mfa: return "MFA" + case .pingOneMFA: return "PingOne MFA" case .developerTools: return "Developer Tools" } } @@ -73,6 +76,8 @@ enum MenuSection: CaseIterable, Identifiable { return [.token, .user, .deviceManagement, .logout] case .mfa: return [.qrScanner, .oathAccounts, .pushAccounts, .pushNotifications] + case .pingOneMFA: + return [.pingOneMFAScanner, .pingOneMFAAccounts, .pingOneMFAOtp, .pingOneMFAPayload] case .developerTools: return [.deviceInfo, .logger, .storage, .bindingKeys, .migration, .configuration] } @@ -106,6 +111,10 @@ enum MenuItem: String, CaseIterable, Identifiable { case davinciDeviceApprove = "Approve with DaVinci" case journeyDeviceApprove = "Approve with Journey" case approveDevice = "Approve Device" + case pingOneMFAScanner = "QR Code Registration" + case pingOneMFAAccounts = "PingOne MFA Accounts" + case pingOneMFAOtp = "PingOne MFA OTP" + case pingOneMFAPayload = "PingOne MFA Payload" var id: String { rawValue } @@ -136,9 +145,13 @@ enum MenuItem: String, CaseIterable, Identifiable { case .davinciDeviceApprove: return "key.fill" case .journeyDeviceApprove: return "map.fill" case .approveDevice: return "checkmark.shield.fill" + case .pingOneMFAScanner: return "qrcode.viewfinder" + case .pingOneMFAAccounts: return "person.2.fill" + case .pingOneMFAOtp: return "number.square.fill" + case .pingOneMFAPayload: return "doc.badge.gearshape.fill" } } - + var title: String { switch self { case .davinci: return "DaVinci Flow" @@ -166,9 +179,13 @@ enum MenuItem: String, CaseIterable, Identifiable { case .davinciDeviceApprove: return "Approve with DaVinci" case .journeyDeviceApprove: return "Approve with Journey" case .approveDevice: return "Approve Device" + case .pingOneMFAScanner: return "QR Code Registration" + case .pingOneMFAAccounts: return "MFA Accounts" + case .pingOneMFAOtp: return "One-Time Passcode" + case .pingOneMFAPayload: return "Mobile Payload" } } - + var subtitle: String { switch self { case .davinci: return "Test DaVinci authentication" @@ -196,9 +213,13 @@ enum MenuItem: String, CaseIterable, Identifiable { case .davinciDeviceApprove: return "Approve device flow via DaVinci" case .journeyDeviceApprove: return "Approve device flow via Journey" case .approveDevice: return "Approve a device's verification URL" + case .pingOneMFAScanner: return "Scan QR code to pair with PingOne MFA" + case .pingOneMFAAccounts: return "View paired MFA accounts" + case .pingOneMFAOtp: return "OTP for your paired accounts" + case .pingOneMFAPayload: return "Generate mobile payload for authentication and registration" } } - + /// The config type required to use this menu item, or nil if none required. var requiredConfigType: ConfigType? { switch self { @@ -220,7 +241,8 @@ struct ContentView: View { @State private var navigateToPushNotifications = false @State private var showNoConfigAlert = false @State private var noConfigTypeName = "" - + @State private var pingOneMFANotification: MFAPushNotification? = nil + var body: some View { NavigationStack(path: $path) { ScrollView { @@ -252,6 +274,14 @@ struct ContentView: View { path.append(.pushNotifications) } } + .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("ShowPingOneMFANotification"))) { notification in + if let pushNotification = notification.userInfo?["notification"] as? MFAPushNotification { + pingOneMFANotification = pushNotification + } + } + .sheet(item: $pingOneMFANotification) { pushNotification in + PingOneMFANotificationView(notification: pushNotification) + } .navigationDestination(for: MenuItem.self) { item in switch item { case .configuration: @@ -304,6 +334,14 @@ struct ContentView: View { JourneyView(path: $path, verificationUriComplete: DeviceApproval.pendingVerificationUri) case .approveDevice: ApproveDeviceView(path: $path) + case .pingOneMFAScanner: + PingOneMFAScannerContainerView(path: $path) + case .pingOneMFAAccounts: + PingOneMFAAccountsView(path: $path) + case .pingOneMFAOtp: + PingOneMFAOtpView(path: $path) + case .pingOneMFAPayload: + PingOneMFAPayloadView(path: $path) } } .task { diff --git a/SampleApps/PingExample/PingExample/PingOneMFAAccountsView.swift b/SampleApps/PingExample/PingExample/PingOneMFAAccountsView.swift new file mode 100644 index 00000000..7f6e0eee --- /dev/null +++ b/SampleApps/PingExample/PingExample/PingOneMFAAccountsView.swift @@ -0,0 +1,159 @@ +// +// PingOneMFAAccountsView.swift +// PingExample +// +// Copyright (c) 2026 Ping Identity Corporation. All rights reserved. +// +// This software may be modified and distributed under the terms +// of the MIT license. See the LICENSE file for details. +// + +import SwiftUI +import PingOneMFA + +/// View to display and manage paired PingOne MFA accounts. +/// Shows each account's name and environment in a styled card. +struct PingOneMFAAccountsView: View { + @Binding var path: [MenuItem] + @StateObject private var viewModel = PingOneMFAAccountsViewModel() + + var body: some View { + ZStack { + Color(.systemGroupedBackground) + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 20) { + if viewModel.isLoading && viewModel.accounts.isEmpty { + ProgressView() + .scaleEffect(1.5) + .padding() + } else if viewModel.accounts.isEmpty { + emptyStateView + } else { + accountsList + } + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 30) + } + + if viewModel.isLoading && !viewModel.accounts.isEmpty { + ZStack { + Color.black.opacity(0.4) + .ignoresSafeArea() + ProgressView() + .scaleEffect(2.0) + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } + } + } + .navigationTitle("MFA Accounts") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + path.append(.pingOneMFAScanner) + } label: { + Image(systemName: "qrcode.viewfinder") + } + } + } + .task { + await viewModel.initialize() + await viewModel.loadAccounts() + } + .refreshable { + await viewModel.loadAccounts() + } + .alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) { + Button("OK") { + viewModel.errorMessage = nil + } + } message: { + if let error = viewModel.errorMessage { + Text(error) + } + } + } + + private var emptyStateView: some View { + EmptyStateView( + icon: "person.2.fill", + title: "No MFA Accounts", + subtitle: "Scan a QR code to pair your first PingOne MFA account" + ) { + Button { + path.append(.pingOneMFAScanner) + } label: { + VStack(spacing: 8) { + Image(systemName: "qrcode.viewfinder") + .font(.system(size: 24)) + Text("Scan QR Code") + .font(.system(size: 14, weight: .medium)) + } + .frame(width: 140, height: 100) + .background(Color(.secondarySystemGroupedBackground)) + .cornerRadius(12) + } + .buttonStyle(PlainButtonStyle()) + .padding(.top, 20) + } + .padding() + } + + private var accountsList: some View { + VStack(spacing: 16) { + ForEach(viewModel.accounts, id: \.id) { account in + PingOneMFAAccountCardView(account: account) + } + } + } +} + +// MARK: - Account Card View + +/// Inline card row for a single PingOneMfaAccount. +/// Displays the user ID (primary label), environment ID, and region. +private struct PingOneMFAAccountCardView: View { + let account: PingOneMfaAccount + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 12) { + Image(systemName: "person.2.fill") + .font(.system(size: 20)) + .foregroundColor(.white) + .frame(width: 40, height: 40) + .background( + LinearGradient( + colors: [.themeButtonBackground, Color(red: 0.6, green: 0.1, blue: 0.1)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + VStack(alignment: .leading, spacing: 2) { + Text("\(account.name) \(account.family)") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.primary) + + Text("Region: \(account.region)") + .font(.system(size: 12)) + .foregroundColor(.secondary) + + Text("ID: \(account.id)") + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + } + .padding(16) + } + .background(Color(.secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: .black.opacity(0.05), radius: 4, x: 0, y: 2) + } +} diff --git a/SampleApps/PingExample/PingExample/PingOneMFAAccountsViewModel.swift b/SampleApps/PingExample/PingExample/PingOneMFAAccountsViewModel.swift new file mode 100644 index 00000000..e219c612 --- /dev/null +++ b/SampleApps/PingExample/PingExample/PingOneMFAAccountsViewModel.swift @@ -0,0 +1,57 @@ +// +// PingOneMFAAccountsViewModel.swift +// PingExample +// +// Copyright (c) 2026 Ping Identity Corporation. All rights reserved. +// +// This software may be modified and distributed under the terms +// of the MIT license. See the LICENSE file for details. +// + +import Foundation +import SwiftUI +import PingOneMFA + +/// ViewModel to manage PingOne MFA accounts. +/// Handles lazy SDK initialization, account loading, and error states. +@MainActor +class PingOneMFAAccountsViewModel: ObservableObject { + @Published var accounts: [PingOneMfaAccount] = [] + @Published var isLoading = false + @Published var errorMessage: String? + + func initialize() async { + guard !ConfigurationManager.shared.isPingOneMFAInitialized else { return } + + isLoading = true + do { + try await ConfigurationManager.shared.initializePingOneMFAClient() + } catch { + errorMessage = "Failed to initialize PingOne MFA: \(error.localizedDescription)" + } + isLoading = false + } + + func loadAccounts() async { + guard ConfigurationManager.shared.isPingOneMFAInitialized else { + // SDK not initialized — silently return so the initialization error + // surfaced by `initialize()` remains visible to the user. + return + } + + isLoading = true + errorMessage = nil + + do { + let result = try await PingOneMFA.getDeviceInfo() + accounts = result.accounts + if let errors = result.errors, !errors.isEmpty { + errorMessage = "Failed to load accounts: " + errors.map { $0.localizedDescription }.joined(separator: "; ") + } + } catch { + errorMessage = "Failed to load accounts: \(error.localizedDescription)" + } + + isLoading = false + } +} diff --git a/SampleApps/PingExample/PingExample/PingOneMFANotificationView.swift b/SampleApps/PingExample/PingExample/PingOneMFANotificationView.swift new file mode 100644 index 00000000..3ba2a7f8 --- /dev/null +++ b/SampleApps/PingExample/PingExample/PingOneMFANotificationView.swift @@ -0,0 +1,299 @@ +// +// PingOneMFANotificationView.swift +// PingExample +// +// Copyright (c) 2026 Ping Identity Corporation. All rights reserved. +// +// This software may be modified and distributed under the terms +// of the MIT license. See the LICENSE file for details. +// + +import SwiftUI +import PingOneMFA + +// MARK: - ViewModel + +/// ViewModel backing `PingOneMFANotificationView`. Holds the notification value and async call state. +@MainActor +final class PingOneMFANotificationViewModel: ObservableObject { + let notification: PushNotification + + @Published var isLoading: Bool = false + @Published var errorMessage: String? = nil + @Published var showSuccessAlert: Bool = false + @Published var isDenied: Bool = false + + init(notification: PushNotification) { + self.notification = notification + } + + /// Approves the authentication request with an optional number-matching challenge. + func approve(numberChallenge: Int? = nil) { + Task { + isLoading = true + errorMessage = nil + do { + try await notification.approveNotification(authMethod: "user", numberChallenge: numberChallenge) + showSuccessAlert = true + } catch { + errorMessage = error.localizedDescription + } + isLoading = false + } + } + + /// Denies the authentication request. + func deny() { + Task { + isLoading = true + errorMessage = nil + do { + try await notification.denyNotification() + isDenied = true + showSuccessAlert = true + } catch { + errorMessage = error.localizedDescription + } + isLoading = false + } + } +} + +// MARK: - View + +/// A modal sheet that presents a PingOneMFA push notification and lets the user approve or deny it. +/// +/// Supports three layouts based on `notification.pushType`: +/// - `.challenge` with non-empty `getNumbersChallenge`: displays tappable number buttons. +/// - `.challenge` with empty `getNumbersChallenge`: displays a `.numberPad` text field. +/// - `.default`: displays plain Approve / Deny buttons with no number-matching UI. +struct PingOneMFANotificationView: View { + @StateObject private var viewModel: PingOneMFANotificationViewModel + @Environment(\.dismiss) private var dismiss + + /// Text entry state for the ENTER_MANUALLY path. + @State private var enteredText: String = "" + + init(notification: PushNotification) { + _viewModel = StateObject(wrappedValue: PingOneMFANotificationViewModel(notification: notification)) + } + + var body: some View { + VStack(spacing: 24) { + // Header + header + + // Title / Message + notificationContent + + // Number-matching UI (conditional) + if viewModel.notification.pushType == .challenge { + if !viewModel.notification.getNumbersChallenge.isEmpty { + selectNumberSection + } else { + enterManuallySection + } + } + + // Buttons or loading indicator + if viewModel.isLoading { + loadingIndicator + } else { + actionButtons + } + + Spacer() + } + .padding() + .background(Color(.secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: .black.opacity(0.05), radius: 4, x: 0, y: 2) + .padding() + .onAppear { + if viewModel.notification.isCancelAuthentication { + dismiss() + } + } + .alert(viewModel.isDenied ? "Denied" : "Approved", isPresented: $viewModel.showSuccessAlert) { + Button("OK") { dismiss() } + } message: { + Text(viewModel.isDenied ? "Authentication denied successfully" : "Authentication approved successfully") + } + .alert("Error", isPresented: Binding( + get: { viewModel.errorMessage != nil }, + set: { if !$0 { viewModel.errorMessage = nil } } + )) { + Button("OK", role: .cancel) {} + } message: { + Text(viewModel.errorMessage ?? "") + } + } + + // MARK: - Subviews + + private var header: some View { + HStack { + Image(systemName: "bell.badge.fill") + .font(.system(size: 20)) + .foregroundColor(.white) + .frame(width: 40, height: 40) + .background( + LinearGradient( + colors: [.themeButtonBackground, Color(red: 0.6, green: 0.1, blue: 0.1)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + VStack(alignment: .leading, spacing: 2) { + Text("PingOne MFA Authentication") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.primary) + + Text("Approve or deny this request") + .font(.system(size: 13)) + .foregroundColor(.secondary) + } + + Spacer() + } + } + + private var notificationContent: some View { + VStack(alignment: .leading, spacing: 8) { + if let title = viewModel.notification.title { + Text(title) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + } + + if let message = viewModel.notification.message { + Text(message) + .font(.system(size: 14)) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + + /// SELECT_NUMBER path: tappable number buttons, one per option. + private var selectNumberSection: some View { + VStack(spacing: 16) { + Text("Select the number shown on your other device") + .font(.system(size: 15)) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + let options = viewModel.notification.getNumbersChallenge + if options.isEmpty { + Text("No options available") + .font(.system(size: 14)) + .foregroundColor(.red) + } else { + HStack(spacing: 16) { + ForEach(options, id: \.self) { number in + Button { + viewModel.approve(numberChallenge: number) + } label: { + Text("\(number)") + .font(.system(size: 24, weight: .bold)) + .foregroundColor(.themeButtonBackground) + .frame(width: 80, height: 80) + .background(Color.clear) + .overlay( + Circle() + .stroke(Color.themeButtonBackground, lineWidth: 2) + ) + } + } + } + } + } + } + + /// ENTER_MANUALLY (or any non-empty non-SELECT_NUMBER) path: numeric text field. + private var enterManuallySection: some View { + VStack(spacing: 12) { + Text("Enter the number shown on your other device") + .font(.system(size: 15)) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + + TextField("Number", text: $enteredText) + .keyboardType(.numberPad) + .textFieldStyle(.roundedBorder) + .font(.system(size: 20, design: .monospaced)) + .multilineTextAlignment(.center) + .frame(maxWidth: 160) + + if !viewModel.isLoading { + Button { + if let number = Int(enteredText) { + viewModel.approve(numberChallenge: number) + } + } label: { + HStack { + Image(systemName: "checkmark.circle.fill") + Text("Confirm Number") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(enteredText.isEmpty || Int(enteredText) == nil ? Color.gray : Color.green) + .foregroundColor(.white) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + .disabled(enteredText.isEmpty || Int(enteredText) == nil) + } + } + } + + private var loadingIndicator: some View { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .red)) + .scaleEffect(1.5) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + + /// Approve (green) and Deny (red) action buttons — always shown. + private var actionButtons: some View { + HStack(spacing: 12) { + // Deny button + Button { + viewModel.deny() + } label: { + HStack { + Image(systemName: "xmark.circle.fill") + Text("Deny") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(Color.red) + .foregroundColor(.white) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + + // Approve button (only shown when no number-matching UI is active) + if viewModel.notification.pushType == .default { + Button { + viewModel.approve() + } label: { + HStack { + Image(systemName: "checkmark.circle.fill") + Text("Approve") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(Color.green) + .foregroundColor(.white) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + } + } + } +} diff --git a/SampleApps/PingExample/PingExample/PingOneMFAOtpView.swift b/SampleApps/PingExample/PingExample/PingOneMFAOtpView.swift new file mode 100644 index 00000000..94570f9c --- /dev/null +++ b/SampleApps/PingExample/PingExample/PingOneMFAOtpView.swift @@ -0,0 +1,106 @@ +// +// PingOneMFAOtpView.swift +// PingExample +// +// Copyright (c) 2026 Ping Identity Corporation. All rights reserved. +// +// This software may be modified and distributed under the terms +// of the MIT license. See the LICENSE file for details. +// + +import SwiftUI +import PingOneMFA + +struct PingOneMFAOtpView: View { + @Binding var path: [MenuItem] + @StateObject private var viewModel = PingOneMFAOtpViewModel() + + var body: some View { + ZStack { + Color(.systemGroupedBackground) + .ignoresSafeArea() + + if viewModel.isLoading && viewModel.otpInfo == nil { + ProgressView() + .scaleEffect(1.5) + } else { + ScrollView { + VStack(spacing: 32) { + otpCard + } + .padding(.horizontal, 20) + .padding(.top, 40) + .padding(.bottom, 30) + } + } + + // Loading overlay while refreshing an already-displayed code. + if viewModel.isLoading && viewModel.otpInfo != nil { + Color.black.opacity(0.4) + .ignoresSafeArea() + ProgressView() + .scaleEffect(2.0) + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } + } + .navigationTitle("One-Time Passcode") + .navigationBarTitleDisplayMode(.inline) + .task { + await viewModel.loadOtp() + } + .onDisappear { + viewModel.stopRefreshing() + } + .alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) { + Button("OK") { + viewModel.errorMessage = nil + } + } message: { + if let error = viewModel.errorMessage { + Text(error) + } + } + } + + // MARK: - OTP Card + + private var otpCard: some View { + VStack(spacing: 24) { + // Gradient icon + Image(systemName: "number.square.fill") + .font(.system(size: 48)) + .foregroundColor(.white) + .frame(width: 80, height: 80) + .background( + LinearGradient( + colors: [.themeButtonBackground, Color(red: 0.6, green: 0.1, blue: 0.1)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 20)) + + if let info = viewModel.otpInfo { + // OTP code — large, prominent, monospaced + Text(info.code) + .font(.system(size: 48, weight: .bold, design: .monospaced)) + .foregroundColor(.primary) + .tracking(8) + + // Live countdown + Text(viewModel.countdown > 0 ? "Refreshes in \(viewModel.countdown)s" : "Expired") + .font(.system(size: 15, weight: .medium)) + .foregroundColor(.secondary) + } else if !viewModel.isLoading { + Text("—") + .font(.system(size: 48, weight: .bold, design: .monospaced)) + .foregroundColor(.secondary) + } + } + .padding(32) + .frame(maxWidth: .infinity) + .background(Color(.secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .shadow(color: .black.opacity(0.05), radius: 6, x: 0, y: 3) + } +} diff --git a/SampleApps/PingExample/PingExample/PingOneMFAOtpViewModel.swift b/SampleApps/PingExample/PingExample/PingOneMFAOtpViewModel.swift new file mode 100644 index 00000000..46e82218 --- /dev/null +++ b/SampleApps/PingExample/PingExample/PingOneMFAOtpViewModel.swift @@ -0,0 +1,103 @@ +// +// PingOneMFAOtpViewModel.swift +// PingExample +// +// Copyright (c) 2026 Ping Identity Corporation. All rights reserved. +// +// This software may be modified and distributed under the terms +// of the MIT license. See the LICENSE file for details. +// + +import Foundation +import SwiftUI +import PingOneMFA + +/// ViewModel for the PingOne MFA OTP screen. +/// Fetches the current one-time passcode, displays a live countdown, and auto-refreshes +/// when the passcode expires. +@MainActor +class PingOneMFAOtpViewModel: ObservableObject { + @Published var otpInfo: OtpCodeInfo? + @Published var isLoading = false + @Published var errorMessage: String? + @Published var countdown: Int = 0 + + // One-shot task that sleeps until the current OTP expires, then calls loadOtp() again. + private var refreshTask: Task? + // 1-Hz timer that decrements `countdown` for live display. + private var countdownTimer: Timer? + + func loadOtp() async { + guard !ConfigurationManager.shared.isPingOneMFAInitialized else { + await fetchOtp() + return + } + + isLoading = true + do { + try await ConfigurationManager.shared.initializePingOneMFAClient() + } catch { + errorMessage = "Failed to initialize PingOne MFA: \(error.localizedDescription)" + isLoading = false + return + } + await fetchOtp() + } + + /// Stops timers and tasks. Call from the view's `onDisappear` to prevent resource leaks. + func stopRefreshing() { + refreshTask?.cancel() + refreshTask = nil + countdownTimer?.invalidate() + countdownTimer = nil + } + + // MARK: - Private + + private func fetchOtp() async { + // Cancel any in-flight refresh task before starting a new one. + refreshTask?.cancel() + refreshTask = nil + countdownTimer?.invalidate() + countdownTimer = nil + + isLoading = true + errorMessage = nil + + do { + let info = try await PingOneMFA.getOneTimePasscode() + otpInfo = info + countdown = info.secondsRemaining + isLoading = false + + guard countdown > 0 else { + return + } + + // Start 1-Hz countdown timer for live display. + startCountdownTimer() + + // Schedule one-shot refresh task that fires when the OTP expires. + let sleepNanoseconds = UInt64(countdown) * 1_000_000_000 + refreshTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: sleepNanoseconds) + guard let self, !Task.isCancelled else { return } + await self.loadOtp() + } + } catch { + errorMessage = "Failed to fetch OTP: \(error.localizedDescription)" + isLoading = false + } + } + + private func startCountdownTimer() { + countdownTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self else { return } + if self.countdown > 0 { + self.countdown -= 1 + } + } + } + } +} diff --git a/SampleApps/PingExample/PingExample/PingOneMFAPayloadView.swift b/SampleApps/PingExample/PingExample/PingOneMFAPayloadView.swift new file mode 100644 index 00000000..7e97ca4c --- /dev/null +++ b/SampleApps/PingExample/PingExample/PingOneMFAPayloadView.swift @@ -0,0 +1,135 @@ +// +// PingOneMFAPayloadView.swift +// PingExample +// +// Copyright (c) 2026 Ping Identity Corporation. All rights reserved. +// +// This software may be modified and distributed under the terms +// of the MIT license. See the LICENSE file for details. +// + +import SwiftUI +import UIKit +import PingOneMFA + +struct PingOneMFAPayloadView: View { + @Binding var path: [MenuItem] + @StateObject private var viewModel = PingOneMFAPayloadViewModel() + + var body: some View { + ZStack { + Color(.systemGroupedBackground) + .ignoresSafeArea() + + if viewModel.isLoading { + ProgressView() + .scaleEffect(1.5) + } else { + ScrollView { + VStack(spacing: 20) { + payloadCard + copyButton + } + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 30) + } + } + } + .navigationTitle("Mobile Payload") + .navigationBarTitleDisplayMode(.inline) + .task { + await viewModel.loadPayload() + } + .alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) { + Button("OK") { + viewModel.errorMessage = nil + } + } message: { + if let error = viewModel.errorMessage { + Text(error) + } + } + } + + // MARK: - Payload Card + + private var payloadCard: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 12) { + Image(systemName: "doc.badge.gearshape.fill") + .font(.system(size: 20)) + .foregroundColor(.white) + .frame(width: 40, height: 40) + .background( + LinearGradient( + colors: [.themeButtonBackground, Color(red: 0.6, green: 0.1, blue: 0.1)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + Text("Payload") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.primary) + + Spacer() + } + + Divider() + + if let payload = viewModel.payload { + Text(payload) + .font(.system(size: 13, design: .monospaced)) + .foregroundColor(.primary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + Text("No payload available.") + .font(.system(size: 13)) + .foregroundColor(.secondary) + } + } + .padding(16) + .background(Color(.secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: .black.opacity(0.05), radius: 4, x: 0, y: 2) + } + + // MARK: - Copy Button + + private var copyButton: some View { + Button { + if let payload = viewModel.payload { + UIPasteboard.general.string = payload + } + } label: { + HStack(spacing: 8) { + Image(systemName: "doc.on.doc") + .font(.system(size: 16)) + Text("Copy") + .font(.system(size: 16, weight: .medium)) + } + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background( + viewModel.payload != nil + ? LinearGradient( + colors: [.themeButtonBackground, Color(red: 0.6, green: 0.1, blue: 0.1)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + : LinearGradient( + colors: [Color.gray, Color.gray], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .disabled(viewModel.payload == nil) + .accessibilityIdentifier("copyPayloadButton") + } +} diff --git a/SampleApps/PingExample/PingExample/PingOneMFAPayloadViewModel.swift b/SampleApps/PingExample/PingExample/PingOneMFAPayloadViewModel.swift new file mode 100644 index 00000000..c5384e2f --- /dev/null +++ b/SampleApps/PingExample/PingExample/PingOneMFAPayloadViewModel.swift @@ -0,0 +1,56 @@ +// +// PingOneMFAPayloadViewModel.swift +// PingExample +// +// Copyright (c) 2026 Ping Identity Corporation. All rights reserved. +// +// This software may be modified and distributed under the terms +// of the MIT license. See the LICENSE file for details. +// + +import Foundation +import SwiftUI +import PingOneMFA + +/// ViewModel for the PingOne MFA Payload screen. +/// Lazily initializes the SDK, then fetches the mobile payload string once on load. +@MainActor +class PingOneMFAPayloadViewModel: ObservableObject { + @Published var payload: String? + @Published var isLoading = false + @Published var errorMessage: String? + + /// Loads the mobile payload from the SDK. + /// Lazily initializes the PingOne MFA SDK if it has not been initialized yet. + func loadPayload() async { + guard !ConfigurationManager.shared.isPingOneMFAInitialized else { + await fetchPayload() + return + } + + isLoading = true + do { + try await ConfigurationManager.shared.initializePingOneMFAClient() + } catch { + errorMessage = "Failed to initialize PingOne MFA: \(error.localizedDescription)" + isLoading = false + return + } + await fetchPayload() + } + + // MARK: - Private + + private func fetchPayload() async { + isLoading = true + errorMessage = nil + + do { + payload = try await PingOneMFA.generateMobilePayload() + } catch { + errorMessage = "Failed to collect mobile payload: \(error.localizedDescription)" + } + + isLoading = false + } +} diff --git a/SampleApps/PingExample/PingExample/PingOneMFAScannerContainerView.swift b/SampleApps/PingExample/PingExample/PingOneMFAScannerContainerView.swift new file mode 100644 index 00000000..14e1765a --- /dev/null +++ b/SampleApps/PingExample/PingExample/PingOneMFAScannerContainerView.swift @@ -0,0 +1,127 @@ +// +// PingOneMFAScannerContainerView.swift +// PingExample +// +// Copyright (c) 2026 Ping Identity Corporation. All rights reserved. +// +// This software may be modified and distributed under the terms +// of the MIT license. See the LICENSE file for details. +// + +import SwiftUI + + +struct PingOneMFAScannerContainerView: View { + @Binding var path: [MenuItem] + @StateObject private var viewModel = PingOneMFAScannerViewModel() + @State private var scannerDelegate: PingOneMFAScannerDelegate? + @State private var showAlert = false + @State private var alertTitle = "" + @State private var alertMessage = "" + @State private var manualKey = "" + @FocusState private var isTextFieldFocused: Bool + + var body: some View { + ZStack { + QRScannerView(delegate: scannerDelegate) + .ignoresSafeArea() + + VStack { + Spacer() + + // Manual pairing key entry + VStack(spacing: 12) { + TextField("Enter pairing key manually", text: $manualKey) + .textFieldStyle(.roundedBorder) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .focused($isTextFieldFocused) + + Button { + let key = manualKey.trimmingCharacters(in: .whitespaces) + guard !key.isEmpty else { return } + isTextFieldFocused = false + Task { await viewModel.handleScannedCode(key) } + } label: { + Text("Pair") + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + } + .buttonStyle(.borderedProminent) + .disabled(manualKey.trimmingCharacters(in: .whitespaces).isEmpty || viewModel.isLoading) + } + .padding() + .background(.regularMaterial) + .cornerRadius(12) + .padding(.horizontal, 20) + .padding(.bottom, 30) + + if viewModel.isLoading { + ProgressView() + .scaleEffect(2.0) + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + .padding() + .background(Color.black.opacity(0.7)) + .cornerRadius(12) + .padding(.bottom, 16) + } + } + } + .navigationTitle("PingOne MFA Scanner") + .navigationBarTitleDisplayMode(.inline) + .onAppear { + if scannerDelegate == nil { + scannerDelegate = PingOneMFAScannerDelegate(viewModel: viewModel) + } + } + .alert(alertTitle, isPresented: $showAlert) { + Button("OK") { + if viewModel.registrationSuccess { + path.removeLast() + } + } + } message: { + Text(alertMessage) + } + .onChange(of: viewModel.errorMessage) { newValue in + if let error = newValue { + alertTitle = "Error" + alertMessage = error + showAlert = true + } + } + .onChange(of: viewModel.successMessage) { newValue in + if let success = newValue { + alertTitle = "Success" + alertMessage = success + showAlert = true + } + } + .onChange(of: viewModel.registrationSuccess) { success in + if success { manualKey = "" } + } + } +} + +/// Delegate that bridges `QRScannerDelegate` callbacks to `PingOneMFAScannerViewModel`. +/// Defined in the same file to avoid modifying the shared `ScannerDelegate` class. +@MainActor +class PingOneMFAScannerDelegate: NSObject, QRScannerDelegate { + let viewModel: PingOneMFAScannerViewModel + + init(viewModel: PingOneMFAScannerViewModel) { + self.viewModel = viewModel + } + + nonisolated func didScan(code: String) { + Task { @MainActor in + await viewModel.handleScannedCode(code) + } + } + + nonisolated func didFailWithError(error: Error) { + Task { @MainActor in + viewModel.errorMessage = error.localizedDescription + } + } +} diff --git a/SampleApps/PingExample/PingExample/PingOneMFAScannerViewModel.swift b/SampleApps/PingExample/PingExample/PingOneMFAScannerViewModel.swift new file mode 100644 index 00000000..5f0033a4 --- /dev/null +++ b/SampleApps/PingExample/PingExample/PingOneMFAScannerViewModel.swift @@ -0,0 +1,42 @@ +// +// PingOneMFAScannerViewModel.swift +// PingExample +// +// Copyright (c) 2026 Ping Identity Corporation. All rights reserved. +// +// This software may be modified and distributed under the terms +// of the MIT license. See the LICENSE file for details. +// + +import Foundation +import PingOneMFA + +/// ViewModel to handle QR code scanning and pairing for PingOne MFA. +/// Processes the raw scanned string and passes it directly to `PingOneMFA.pair(pairingKey:)`. +@MainActor +class PingOneMFAScannerViewModel: ObservableObject { + @Published var isLoading = false + @Published var errorMessage: String? + @Published var successMessage: String? + @Published var registrationSuccess = false + + func handleScannedCode(_ code: String) async { + guard !isLoading else { return } + + isLoading = true + errorMessage = nil + successMessage = nil + registrationSuccess = false + + do { + try await ConfigurationManager.shared.initializePingOneMFAClient() + try await PingOneMFA.pair(pairingKey: code) + successMessage = "Successfully paired with PingOne MFA." + registrationSuccess = true + } catch { + errorMessage = error.localizedDescription + } + + isLoading = false + } +}