diff --git a/CHANGELOG.md b/CHANGELOG.md index d86a0f705..d2a92ce23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +# Unreleased +- Add SwiftUI + Swift Package Manager sample app under `Examples/Example_Swift-SPM`. + # 2.0.0 - Raise minimum supported iOS version to iOS 12. ([#918](https://github.com/openid/AppAuth-iOS/pull/918)) - Remove deprecated `[UIApplication openURL:]` method to compile with Xcode 16. ([#911](https://github.com/openid/AppAuth-iOS/pull/911)) diff --git a/Examples/Example_Swift-SPM/.gitignore b/Examples/Example_Swift-SPM/.gitignore new file mode 100644 index 000000000..e3680cef6 --- /dev/null +++ b/Examples/Example_Swift-SPM/.gitignore @@ -0,0 +1 @@ +Config/Example.local.xcconfig diff --git a/Examples/Example_Swift-SPM/Config/Example.xcconfig b/Examples/Example_Swift-SPM/Config/Example.xcconfig new file mode 100644 index 000000000..0c33ad3b0 --- /dev/null +++ b/Examples/Example_Swift-SPM/Config/Example.xcconfig @@ -0,0 +1,14 @@ +// This file holds public placeholder defaults for OAuth configuration. +// Real OAuth client values belong in the sibling, gitignored Config/Example.local.xcconfig, +// which overrides these defaults via the optional-include directive below. + +OIDC_ISSUER = https:/$()/issuer.example.com +OIDC_CLIENT_ID = YOUR_CLIENT_ID +OIDC_REDIRECT_URI = com.example.app:/oauth2redirect/example-provider +OIDC_REDIRECT_URI_SCHEME = com.example.app + +// Code signing. Defaults to Automatic with no team. Override in +// Example.local.xcconfig if you need Manual signing with a specific provisioning profile. +CODE_SIGN_STYLE = Automatic + +#include? "Example.local.xcconfig" diff --git a/Examples/Example_Swift-SPM/Example.xcodeproj/project.pbxproj b/Examples/Example_Swift-SPM/Example.xcodeproj/project.pbxproj new file mode 100644 index 000000000..da3b94da0 --- /dev/null +++ b/Examples/Example_Swift-SPM/Example.xcodeproj/project.pbxproj @@ -0,0 +1,375 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 09BB0F002F91B157004C0D4B /* AppAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 09BB0E002F91B157004C0D4B /* AppAuth */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 09BB0B542F91B157004C0D4B /* Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Example.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 09BB0F012F91B157004C0D4B /* Config/Example.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Config/Example.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 09BB0C002F91B157004C0D4B /* Exceptions for "Example" folder in "Example" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 09BB0B532F91B157004C0D4B /* Example */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 09BB0B562F91B157004C0D4B /* Example */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 09BB0C002F91B157004C0D4B /* Exceptions for "Example" folder in "Example" target */, + ); + path = Example; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 09BB0B512F91B157004C0D4B /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 09BB0F002F91B157004C0D4B /* AppAuth in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 09BB0B4B2F91B157004C0D4B = { + isa = PBXGroup; + children = ( + 09BB0F022F91B157004C0D4B /* Config */, + 09BB0B562F91B157004C0D4B /* Example */, + 09BB0B552F91B157004C0D4B /* Products */, + ); + sourceTree = ""; + }; + 09BB0B552F91B157004C0D4B /* Products */ = { + isa = PBXGroup; + children = ( + 09BB0B542F91B157004C0D4B /* Example.app */, + ); + name = Products; + sourceTree = ""; + }; + 09BB0F022F91B157004C0D4B /* Config */ = { + isa = PBXGroup; + children = ( + 09BB0F012F91B157004C0D4B /* Config/Example.xcconfig */, + ); + name = Config; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 09BB0B532F91B157004C0D4B /* Example */ = { + isa = PBXNativeTarget; + buildConfigurationList = 09BB0B5F2F91B158004C0D4B /* Build configuration list for PBXNativeTarget "Example" */; + buildPhases = ( + 09BB0B502F91B157004C0D4B /* Sources */, + 09BB0B512F91B157004C0D4B /* Frameworks */, + 09BB0B522F91B157004C0D4B /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 09BB0B562F91B157004C0D4B /* Example */, + ); + name = Example; + packageProductDependencies = ( + 09BB0E002F91B157004C0D4B /* AppAuth */, + ); + productName = Example; + productReference = 09BB0B542F91B157004C0D4B /* Example.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 09BB0B4C2F91B157004C0D4B /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2620; + LastUpgradeCheck = 2620; + TargetAttributes = { + 09BB0B532F91B157004C0D4B = { + CreatedOnToolsVersion = 26.2; + }; + }; + }; + buildConfigurationList = 09BB0B4F2F91B157004C0D4B /* Build configuration list for PBXProject "Example" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 09BB0B4B2F91B157004C0D4B; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 09BB0D002F91B157004C0D4B /* XCLocalSwiftPackageReference "../.." */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 09BB0B552F91B157004C0D4B /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 09BB0B532F91B157004C0D4B /* Example */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 09BB0B522F91B157004C0D4B /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 09BB0B502F91B157004C0D4B /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 09BB0B5D2F91B158004C0D4B /* 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; + DEBUG_INFORMATION_FORMAT = dwarf; + 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 = 26.2; + 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"; + }; + name = Debug; + }; + 09BB0B5E2F91B158004C0D4B /* 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; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + 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 = 26.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 09BB0B602F91B158004C0D4B /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 09BB0F012F91B157004C0D4B /* Config/Example.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Example/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "AppAuth iOS Demo"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.google.experimental0.dev; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 09BB0B612F91B158004C0D4B /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 09BB0F012F91B157004C0D4B /* Config/Example.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Example/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "AppAuth iOS Demo"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.google.experimental0.dev; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 09BB0B4F2F91B157004C0D4B /* Build configuration list for PBXProject "Example" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 09BB0B5D2F91B158004C0D4B /* Debug */, + 09BB0B5E2F91B158004C0D4B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 09BB0B5F2F91B158004C0D4B /* Build configuration list for PBXNativeTarget "Example" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 09BB0B602F91B158004C0D4B /* Debug */, + 09BB0B612F91B158004C0D4B /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 09BB0D002F91B157004C0D4B /* XCLocalSwiftPackageReference "../.." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../..; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 09BB0E002F91B157004C0D4B /* AppAuth */ = { + isa = XCSwiftPackageProductDependency; + productName = AppAuth; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 09BB0B4C2F91B157004C0D4B /* Project object */; +} diff --git a/Examples/Example_Swift-SPM/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Examples/Example_Swift-SPM/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/Examples/Example_Swift-SPM/Example.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Examples/Example_Swift-SPM/Example/AppDelegate.swift b/Examples/Example_Swift-SPM/Example/AppDelegate.swift new file mode 100644 index 000000000..a1838b2cd --- /dev/null +++ b/Examples/Example_Swift-SPM/Example/AppDelegate.swift @@ -0,0 +1,34 @@ +// +// AppDelegate.swift +// +// Copyright (c) 2026 The AppAuth Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UIKit +import AppAuth + +class AppDelegate: NSObject, UIApplicationDelegate { + + var currentAuthorizationFlow: OIDExternalUserAgentSession? + + func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { + if let authorizationFlow = self.currentAuthorizationFlow, authorizationFlow.resumeExternalUserAgentFlow(with: url) { + self.currentAuthorizationFlow = nil + return true + } + + return false + } +} diff --git a/Examples/Example_Swift-SPM/Example/Assets.xcassets/AccentColor.colorset/Contents.json b/Examples/Example_Swift-SPM/Example/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/Examples/Example_Swift-SPM/Example/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/Example_Swift-SPM/Example/Assets.xcassets/AppIcon.appiconset/Contents.json b/Examples/Example_Swift-SPM/Example/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..230588010 --- /dev/null +++ b/Examples/Example_Swift-SPM/Example/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/Example_Swift-SPM/Example/Assets.xcassets/Contents.json b/Examples/Example_Swift-SPM/Example/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Examples/Example_Swift-SPM/Example/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/Example_Swift-SPM/Example/AuthManager.swift b/Examples/Example_Swift-SPM/Example/AuthManager.swift new file mode 100644 index 000000000..fa1b0588e --- /dev/null +++ b/Examples/Example_Swift-SPM/Example/AuthManager.swift @@ -0,0 +1,460 @@ +// +// AuthManager.swift +// +// Copyright (c) 2026 The AppAuth Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import AppAuth +import Combine +import SwiftUI +import UIKit + +typealias PostRegistrationCallback = (OIDServiceConfiguration?, OIDRegistrationResponse?) -> Void + +let kIssuer: String = { + guard let issuer = Bundle.main.object(forInfoDictionaryKey: "OIDCIssuer") as? String, + !issuer.isEmpty, + issuer != "https://issuer.example.com" else { + preconditionFailure("Please configure OIDC_ISSUER in Example.local.xcconfig") + } + return issuer +}() +let kClientID: String? = { + let clientID = Bundle.main.object(forInfoDictionaryKey: "OIDCClientID") as? String + if clientID == "YOUR_CLIENT_ID" || clientID?.isEmpty ?? true { + return nil + } + return clientID +}() +let kRedirectURI: String = { + guard let redirectURI = Bundle.main.object(forInfoDictionaryKey: "OIDCRedirectURI") as? String, + !redirectURI.isEmpty, + redirectURI != "com.example.app:/oauth2redirect/example-provider" else { + preconditionFailure("Please configure OIDC_REDIRECT_URI in Example.local.xcconfig") + } + return redirectURI +}() +let kAppAuthExampleAuthStateKey: String = "authState" + +final class AuthManager: NSObject, ObservableObject { + @Published private(set) var authState: OIDAuthState? + @Published private(set) var logText: String = "" + + var isAuthorized: Bool { authState?.isAuthorized ?? false } + var hasAuthorizationCode: Bool { authState?.lastAuthorizationResponse.authorizationCode != nil && authState?.lastTokenResponse == nil } + var hasAuthState: Bool { authState != nil } + + weak var appDelegate: AppDelegate? + + override init() { + super.init() + self.validateOAuthConfiguration() + self.loadState() + } + + // MARK: Public Methods + + func validateOAuthConfiguration() { + assert(kClientID != nil, "Register your OIDC Client ID in Example.local.xcconfig (OIDC_CLIENT_ID).") + assert(kRedirectURI != "com.example.app:/oauth2redirect/example-provider", "Register your OIDC Redirect URI in Example.local.xcconfig (OIDC_REDIRECT_URI).") + + guard let urlTypes = Bundle.main.object(forInfoDictionaryKey: "CFBundleURLTypes") as? [[String: Any]], + let urlSchemes = urlTypes.first?["CFBundleURLSchemes"] as? [String], + let urlScheme = urlSchemes.first else { + assertionFailure("CFBundleURLSchemes not configured") + return + } + + assert(urlScheme != "com.example.app", "Register your OIDC Redirect URI scheme in Example.local.xcconfig (OIDC_REDIRECT_URI_SCHEME).") + assert(kIssuer != "https://issuer.example.com", "Register your OIDC Issuer in Example.local.xcconfig (OIDC_ISSUER).") + } + + func authWithAutoCodeExchange() { + guard let issuer = URL(string: kIssuer) else { + self.logMessage("Error creating URL for : \(kIssuer)") + return + } + + self.logMessage("Fetching configuration for issuer: \(issuer)") + + // discovers endpoints + OIDAuthorizationService.discoverConfiguration(forIssuer: issuer) { configuration, error in + guard let config = configuration else { + self.logMessage("Error retrieving discovery document: \(error?.localizedDescription ?? "DEFAULT_ERROR")") + self.setAuthState(nil) + return + } + + self.logMessage("Got configuration: \(config)") + + if let clientId = kClientID { + self.doAuthWithAutoCodeExchange(configuration: config, clientID: clientId, clientSecret: nil) + } else { + self.doClientRegistration(configuration: config) { configuration, response in + guard let configuration = configuration, let clientID = response?.clientID else { + self.logMessage("Error retrieving configuration OR clientID") + return + } + + self.doAuthWithAutoCodeExchange(configuration: configuration, + clientID: clientID, + clientSecret: response?.clientSecret) + } + } + } + } + + func authNoCodeExchange() { + guard let issuer = URL(string: kIssuer) else { + self.logMessage("Error creating URL for : \(kIssuer)") + return + } + + self.logMessage("Fetching configuration for issuer: \(issuer)") + + OIDAuthorizationService.discoverConfiguration(forIssuer: issuer) { configuration, error in + if let error = error { + self.logMessage("Error retrieving discovery document: \(error.localizedDescription)") + return + } + + guard let configuration = configuration else { + self.logMessage("Error retrieving discovery document. Error & Configuration both are NIL!") + return + } + + self.logMessage("Got configuration: \(configuration)") + + if let clientId = kClientID { + self.doAuthWithoutCodeExchange(configuration: configuration, clientID: clientId, clientSecret: nil) + } else { + self.doClientRegistration(configuration: configuration) { configuration, response in + guard let configuration = configuration, let response = response else { + return + } + + self.doAuthWithoutCodeExchange(configuration: configuration, + clientID: response.clientID, + clientSecret: response.clientSecret) + } + } + } + } + + func codeExchange() { + guard let tokenExchangeRequest = self.authState?.lastAuthorizationResponse.tokenExchangeRequest() else { + self.logMessage("Error creating authorization code exchange request") + return + } + + self.logMessage("Performing authorization code exchange with request \(tokenExchangeRequest)") + + OIDAuthorizationService.perform(tokenExchangeRequest) { response, error in + if let tokenResponse = response { + self.logMessage("Received token response with accessToken: \(tokenResponse.accessToken ?? "DEFAULT_TOKEN")") + } else { + self.logMessage("Token exchange error: \(error?.localizedDescription ?? "DEFAULT_ERROR")") + } + self.authState?.update(with: response, error: error) + } + } + + func userinfo() { + guard let userinfoEndpoint = self.authState?.lastAuthorizationResponse.request.configuration.discoveryDocument?.userinfoEndpoint else { + self.logMessage("Userinfo endpoint not declared in discovery document") + return + } + + self.logMessage("Performing userinfo request") + + let currentAccessToken: String? = self.authState?.lastTokenResponse?.accessToken + + self.authState?.performAction() { (accessToken, idToken, error) in + if error != nil { + self.logMessage("Error fetching fresh tokens: \(error?.localizedDescription ?? "ERROR")") + return + } + + guard let accessToken = accessToken else { + self.logMessage("Error getting accessToken") + return + } + + if currentAccessToken != accessToken { + self.logMessage("Access token was refreshed automatically (\(currentAccessToken ?? "CURRENT_ACCESS_TOKEN") to \(accessToken))") + } else { + self.logMessage("Access token was fresh and not updated \(accessToken)") + } + + var urlRequest = URLRequest(url: userinfoEndpoint) + urlRequest.allHTTPHeaderFields = ["Authorization":"Bearer \(accessToken)"] + + let task = URLSession.shared.dataTask(with: urlRequest) { data, response, error in + DispatchQueue.main.async { + guard error == nil else { + self.logMessage("HTTP request failed \(error?.localizedDescription ?? "ERROR")") + return + } + + guard let response = response as? HTTPURLResponse else { + self.logMessage("Non-HTTP response") + return + } + + guard let data = data else { + self.logMessage("HTTP response data is empty") + return + } + + var json: [AnyHashable: Any]? + + do { + json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + } catch { + self.logMessage("JSON Serialization Error") + } + + if response.statusCode != 200 { + // server replied with an error + let responseText: String? = String(data: data, encoding: String.Encoding.utf8) + + if response.statusCode == 401 { + // "401 Unauthorized" generally indicates there is an issue with the authorization + // grant. Puts OIDAuthState into an error state. + let oauthError = OIDErrorUtilities.resourceServerAuthorizationError(withCode: 0, + errorResponse: json, + underlyingError: error) + self.authState?.update(withAuthorizationError: oauthError) + self.logMessage("Authorization Error (\(oauthError)). Response: \(responseText ?? "RESPONSE_TEXT")") + } else { + self.logMessage("HTTP: \(response.statusCode), Response: \(responseText ?? "RESPONSE_TEXT")") + } + + return + } + + if let json = json { + self.logMessage("Success: \(json)") + } + } + } + + task.resume() + } + } + + func clearAuthState() { + setAuthState(nil) + } + + func clearLogs() { + DispatchQueue.main.async { + self.logText = "" + } + } + + // MARK: Private Methods + + private func doClientRegistration(configuration: OIDServiceConfiguration, callback: @escaping PostRegistrationCallback) { + guard let redirectURI = URL(string: kRedirectURI) else { + self.logMessage("Error creating URL for : \(kRedirectURI)") + return + } + + let request: OIDRegistrationRequest = OIDRegistrationRequest(configuration: configuration, + redirectURIs: [redirectURI], + responseTypes: nil, + grantTypes: nil, + subjectType: nil, + tokenEndpointAuthMethod: "client_secret_post", + additionalParameters: nil) + + // performs registration request + self.logMessage("Initiating registration request") + + OIDAuthorizationService.perform(request) { response, error in + if let regResponse = response { + self.setAuthState(OIDAuthState(registrationResponse: regResponse)) + self.logMessage("Got registration response: \(regResponse)") + callback(configuration, regResponse) + } else { + self.logMessage("Registration error: \(error?.localizedDescription ?? "DEFAULT_ERROR")") + self.setAuthState(nil) + } + } + } + + private func doAuthWithAutoCodeExchange(configuration: OIDServiceConfiguration, clientID: String, clientSecret: String?) { + guard let redirectURI = URL(string: kRedirectURI) else { + self.logMessage("Error creating URL for : \(kRedirectURI)") + return + } + + guard let appDelegate = self.appDelegate else { + self.logMessage("Error accessing AppDelegate") + return + } + + guard let presentingVC = self.presentingViewController() else { + return + } + + // builds authentication request + let request = OIDAuthorizationRequest(configuration: configuration, + clientId: clientID, + clientSecret: clientSecret, + scopes: [OIDScopeOpenID, OIDScopeProfile], + redirectURL: redirectURI, + responseType: OIDResponseTypeCode, + additionalParameters: nil) + + // performs authentication request + logMessage("Initiating authorization request with scope: \(request.scope ?? "DEFAULT_SCOPE")") + + appDelegate.currentAuthorizationFlow = OIDAuthState.authState(byPresenting: request, presenting: presentingVC) { authState, error in + if let authState = authState { + self.setAuthState(authState) + self.logMessage("Got authorization tokens. Access token: \(authState.lastTokenResponse?.accessToken ?? "DEFAULT_TOKEN")") + } else { + self.logMessage("Authorization error: \(error?.localizedDescription ?? "DEFAULT_ERROR")") + self.setAuthState(nil) + } + } + } + + private func doAuthWithoutCodeExchange(configuration: OIDServiceConfiguration, clientID: String, clientSecret: String?) { + guard let redirectURI = URL(string: kRedirectURI) else { + self.logMessage("Error creating URL for : \(kRedirectURI)") + return + } + + guard let appDelegate = self.appDelegate else { + self.logMessage("Error accessing AppDelegate") + return + } + + guard let presentingVC = self.presentingViewController() else { + return + } + + // builds authentication request + let request = OIDAuthorizationRequest(configuration: configuration, + clientId: clientID, + clientSecret: clientSecret, + scopes: [OIDScopeOpenID, OIDScopeProfile], + redirectURL: redirectURI, + responseType: OIDResponseTypeCode, + additionalParameters: nil) + + // performs authentication request + logMessage("Initiating authorization request with scope: \(request.scope ?? "DEFAULT_SCOPE")") + + appDelegate.currentAuthorizationFlow = OIDAuthorizationService.present(request, presenting: presentingVC) { (response, error) in + if let response = response { + let authState = OIDAuthState(authorizationResponse: response) + self.setAuthState(authState) + self.logMessage("Authorization response with code: \(response.authorizationCode ?? "DEFAULT_CODE")") + // could just call [self tokenExchange:nil] directly, but will let the user initiate it. + } else { + self.logMessage("Authorization error: \(error?.localizedDescription ?? "DEFAULT_ERROR")") + } + } + } + + private func setAuthState(_ authState: OIDAuthState?) { + if (self.authState == authState) { + return + } + self.authState = authState + self.authState?.stateChangeDelegate = self + self.authState?.errorDelegate = self + self.stateChanged() + } + + private func stateChanged() { + self.saveState() + } + + private func saveState() { + var data: Data? = nil + + if let authState = self.authState { + do { + data = try NSKeyedArchiver.archivedData(withRootObject: authState, requiringSecureCoding: true) + } catch { + logMessage("Error archiving authState: \(error.localizedDescription)") + return + } + } + + UserDefaults.standard.set(data, forKey: kAppAuthExampleAuthStateKey) + } + + private func loadState() { + guard let data = UserDefaults.standard.data(forKey: kAppAuthExampleAuthStateKey) else { + return + } + + do { + if let authState = try NSKeyedUnarchiver.unarchivedObject(ofClass: OIDAuthState.self, from: data) { + self.setAuthState(authState) + } + } catch { + logMessage("Error unarchiving authState: \(error.localizedDescription)") + } + } + + private func logMessage(_ message: String?) { + guard let message = message else { + return + } + + print(message) + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "hh:mm:ss" + let dateString = dateFormatter.string(from: Date()) + + // appends to output log + DispatchQueue.main.async { + let logText = "\(self.logText)\n\(dateString): \(message)" + self.logText = logText + } + } + + private func presentingViewController() -> UIViewController? { + let viewController = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first?.windows + .first(where: { $0.isKeyWindow })? + .rootViewController + if viewController == nil { + logMessage("Error: no presenting view controller available") + } + return viewController + } +} + +// MARK: OIDAuthState Delegate + +extension AuthManager: OIDAuthStateChangeDelegate, OIDAuthStateErrorDelegate { + func didChange(_ state: OIDAuthState) { + self.stateChanged() + } + + func authState(_ state: OIDAuthState, didEncounterAuthorizationError error: Error) { + self.logMessage("Received authorization error: \(error)") + } +} diff --git a/Examples/Example_Swift-SPM/Example/ContentView.swift b/Examples/Example_Swift-SPM/Example/ContentView.swift new file mode 100644 index 000000000..7ddcbcac4 --- /dev/null +++ b/Examples/Example_Swift-SPM/Example/ContentView.swift @@ -0,0 +1,98 @@ +// +// ContentView.swift +// +// Copyright (c) 2026 The AppAuth Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct ContentView: View { + @EnvironmentObject var authManager: AuthManager + + var body: some View { + NavigationStack { + VStack(spacing: 12) { + HStack { + Button(authManager.hasAuthState ? "Re-Auth (Auto)" : "Auto") { + authManager.authWithAutoCodeExchange() + } + .buttonStyle(.borderedProminent) + .frame(maxWidth: .infinity) + + Button(authManager.hasAuthState ? "Re-Auth (Manual)" : "Manual") { + authManager.authNoCodeExchange() + } + .buttonStyle(.borderedProminent) + .frame(maxWidth: .infinity) + } + + HStack { + Button("Code Exchange") { + authManager.codeExchange() + } + .buttonStyle(.borderedProminent) + .disabled(!authManager.hasAuthorizationCode) + .frame(maxWidth: .infinity) + + Button("User Info") { + authManager.userinfo() + } + .buttonStyle(.borderedProminent) + .disabled(!authManager.isAuthorized) + .frame(maxWidth: .infinity) + } + + ScrollViewReader { proxy in + ScrollView { + Text(authManager.logText) + .frame(maxWidth: .infinity, alignment: .leading) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .padding(8) + .id("bottom") + } + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(Color.secondary.opacity(0.4)) + ) + .onChange(of: authManager.logText) { _ in + proxy.scrollTo("bottom", anchor: .bottom) + } + } + } + .padding() + .navigationTitle("AppAuth Example") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button(role: .destructive) { + authManager.clearAuthState() + } label: { + Text("Clear OAuth State") + } + + Button { + authManager.clearLogs() + } label: { + Text("Clear Logs") + } + } label: { + Image(systemName: "trash") + } + } + } + } + } +} diff --git a/Examples/Example_Swift-SPM/Example/ExampleApp.swift b/Examples/Example_Swift-SPM/Example/ExampleApp.swift new file mode 100644 index 000000000..462e81fa2 --- /dev/null +++ b/Examples/Example_Swift-SPM/Example/ExampleApp.swift @@ -0,0 +1,35 @@ +// +// ExampleApp.swift +// +// Copyright (c) 2026 The AppAuth Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +@main +struct ExampleApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + @StateObject private var authManager = AuthManager() + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(authManager) + .onAppear { + authManager.appDelegate = appDelegate + } + } + } +} diff --git a/Examples/Example_Swift-SPM/Example/Info.plist b/Examples/Example_Swift-SPM/Example/Info.plist new file mode 100644 index 000000000..aaf558068 --- /dev/null +++ b/Examples/Example_Swift-SPM/Example/Info.plist @@ -0,0 +1,63 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + $(OIDC_REDIRECT_URI_SCHEME) + + + + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + OIDCClientID + $(OIDC_CLIENT_ID) + OIDCIssuer + $(OIDC_ISSUER) + OIDCRedirectURI + $(OIDC_REDIRECT_URI) + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/Examples/Example_Swift-SPM/README.md b/Examples/Example_Swift-SPM/README.md new file mode 100644 index 000000000..ac35609f4 --- /dev/null +++ b/Examples/Example_Swift-SPM/README.md @@ -0,0 +1,48 @@ +# Example Project (SwiftUI + Swift Package Manager) + +## Setup & Open the Project + +This sample uses the local AppAuth Swift Package via a relative path (`../..`), so Xcode resolves it automatically. + +Just open `Example.xcodeproj` to get started, and complete the configuration. + +## Configuration + +The example doesn't work out of the box — you need to configure it with your own OpenID Connect client. + +### Information You'll Need + +* Issuer +* Client ID +* Redirect URI + +How to get this information varies by IdP, but we have [instructions](../README.md#openid-certified-providers) for some OpenID Certified providers. + +### Configure the Example + +This sample reads them from an xcconfig file. Create your local override file by copying the committed defaults: + + cp Config/Example.xcconfig Config/Example.local.xcconfig + +Then edit `Config/Example.local.xcconfig` and set: + + OIDC_ISSUER = + OIDC_CLIENT_ID = + OIDC_REDIRECT_URI = + OIDC_REDIRECT_URI_SCHEME = + +The scheme is everything before the colon (:) of your redirect URI. For example, if the redirect URI is `com.example.app:/oauth2redirect/example-provider`, the scheme is `com.example.app`. + +Note that the local version you create, `Config/Example.local.xcconfig`, is gitignored. + +The same file can also override code-signing. By default the committed xcconfig sets CODE_SIGN_STYLE = Automatic, which causes Xcode to prompt for your team on first build — the same experience as the sibling samples. To use Manual signing with a specific provisioning profile, add these lines to Config/Example.local.xcconfig: + + CODE_SIGN_STYLE = Manual + DEVELOPMENT_TEAM = + PROVISIONING_PROFILE_SPECIFIER = + +Note: Xcode may cache Info.plist substitutions — after editing the xcconfig, run **Product > Clean Build Folder**. + +### Running the Example + +Now your example should be ready to run. diff --git a/Examples/README.md b/Examples/README.md index 820c0d5bb..87f79c43d 100644 --- a/Examples/README.md +++ b/Examples/README.md @@ -10,6 +10,8 @@ Each example has docs on how to configure: * [Example for iOS (Objective-C)](Example-iOS_ObjC/README.md) * [Example for iOS w/ Carthage (Objective-C)](Example-iOS_ObjC-Carthage/README.md) +* [Example for iOS w/ Carthage (Swift)](Example-iOS_Swift-Carthage/README.md) +* [Example for iOS w/ SPM (SwiftUI)](Example_Swift-SPM/README.md) * [Example for macOS](Example-macOS/README.md) * [Example for tvOS](Example-tvOS/README.md)