diff --git a/Podfile b/Podfile index 444d2bc..cce30cf 100644 --- a/Podfile +++ b/Podfile @@ -5,6 +5,6 @@ platform :ios, '9.0' use_frameworks! target 'SingleSignOn' do - pod 'Alamofire' - pod 'SwiftKeychainWrapper' + pod 'AppAuth', '1.6.0' + pod 'SwiftKeychainWrapper', '3.0.1' end diff --git a/Podfile.lock b/Podfile.lock index bba6114..820d336 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -1,20 +1,25 @@ PODS: - - Alamofire (4.7.3) + - AppAuth (1.6.0): + - AppAuth/Core (= 1.6.0) + - AppAuth/ExternalUserAgent (= 1.6.0) + - AppAuth/Core (1.6.0) + - AppAuth/ExternalUserAgent (1.6.0): + - AppAuth/Core - SwiftKeychainWrapper (3.0.1) DEPENDENCIES: - - Alamofire - - SwiftKeychainWrapper + - AppAuth (= 1.6.0) + - SwiftKeychainWrapper (= 3.0.1) SPEC REPOS: - https://github.com/cocoapods/specs.git: - - Alamofire + https://github.com/CocoaPods/Specs.git: + - AppAuth - SwiftKeychainWrapper SPEC CHECKSUMS: - Alamofire: c7287b6e5d7da964a70935e5db17046b7fde6568 + AppAuth: 8fca6b5563a5baef2c04bee27538025e4ceb2add SwiftKeychainWrapper: 38952a3636320ae61bad3513cadd870929de7a4a -PODFILE CHECKSUM: 5b9df332a135fcc090d04042e541929ed53aa5d4 +PODFILE CHECKSUM: 5b873ee44bd67706d8f252c520a9c79759b9f275 -COCOAPODS: 1.5.3 +COCOAPODS: 1.11.3 diff --git a/SingleSignOn.podspec b/SingleSignOn.podspec index c44094a..fedb35b 100644 --- a/SingleSignOn.podspec +++ b/SingleSignOn.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "SingleSignOn" - s.version = "1.0.6" + s.version = "1.1.0" s.summary = "Library to interface with RedHat SSO" s.description = "This pod contains various components to support authentication and credential managment" s.homepage = "http://pathfinder.gov.bc.ca" @@ -12,5 +12,5 @@ Pod::Spec.new do |s| s.resources = 'SingleSignOn/**/*.{storyboard,xib,xcassets}' s.requires_arc = true s.dependency 'SwiftKeychainWrapper', '~> 3.0.1' - s.dependency 'Alamofire', '~> 4.7.3' + s.dependency 'AppAuth', '1.6.0' end diff --git a/SingleSignOn.xcodeproj/project.pbxproj b/SingleSignOn.xcodeproj/project.pbxproj index 83ffce7..7e0a8ee 100644 --- a/SingleSignOn.xcodeproj/project.pbxproj +++ b/SingleSignOn.xcodeproj/project.pbxproj @@ -11,7 +11,6 @@ 5610F074202D13E7004CD2AC /* SingleSignOnTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5610F073202D13E7004CD2AC /* SingleSignOnTests.swift */; }; 5610F076202D13E7004CD2AC /* SingleSignOn.h in Headers */ = {isa = PBXBuildFile; fileRef = 5610F068202D13E7004CD2AC /* SingleSignOn.h */; settings = {ATTRIBUTES = (Public, ); }; }; 5610F080202D14E3004CD2AC /* Podfile in Resources */ = {isa = PBXBuildFile; fileRef = 5610F07F202D14E3004CD2AC /* Podfile */; }; - 56FC88DB202D1846008F7642 /* KeycloakAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56FC88CB202D1845008F7642 /* KeycloakAPI.swift */; }; 56FC88DC202D1846008F7642 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56FC88CC202D1845008F7642 /* Constants.swift */; }; 56FC88DD202D1846008F7642 /* AuthViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 56FC88CE202D1845008F7642 /* AuthViewController.swift */; }; 56FC88DE202D1846008F7642 /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 56FC88CF202D1845008F7642 /* Media.xcassets */; }; @@ -26,6 +25,8 @@ 56FC88E8202D1871008F7642 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 56FC88E7202D1871008F7642 /* README.md */; }; 56FC88EA202E0E04008F7642 /* SingleSignOn.podspec in Resources */ = {isa = PBXBuildFile; fileRef = 56FC88E9202E0E04008F7642 /* SingleSignOn.podspec */; }; BBF776BE94BDB4E00ABA2247 /* Pods_SingleSignOn.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 133211EAD64B6E7010491C5B /* Pods_SingleSignOn.framework */; }; + DA2D1D8B2965E3FC000FC010 /* OIDTokenResponseExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2D1D8A2965E3FC000FC010 /* OIDTokenResponseExtension.swift */; }; + DA2D1D912971D219000FC010 /* Endpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA2D1D902971D219000FC010 /* Endpoint.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -46,8 +47,7 @@ 5610F06E202D13E7004CD2AC /* SingleSignOnTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SingleSignOnTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5610F073202D13E7004CD2AC /* SingleSignOnTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleSignOnTests.swift; sourceTree = ""; }; 5610F075202D13E7004CD2AC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 5610F07F202D14E3004CD2AC /* Podfile */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = Podfile; sourceTree = SOURCE_ROOT; }; - 56FC88CB202D1845008F7642 /* KeycloakAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeycloakAPI.swift; sourceTree = ""; }; + 5610F07F202D14E3004CD2AC /* Podfile */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = Podfile; sourceTree = SOURCE_ROOT; xcLanguageSpecificationIdentifier = xcode.lang.ruby; }; 56FC88CC202D1845008F7642 /* Constants.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; 56FC88CE202D1845008F7642 /* AuthViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthViewController.swift; sourceTree = ""; }; 56FC88CF202D1845008F7642 /* Media.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Media.xcassets; sourceTree = ""; }; @@ -62,6 +62,8 @@ 56FC88E7202D1871008F7642 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 56FC88E9202E0E04008F7642 /* SingleSignOn.podspec */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = SingleSignOn.podspec; sourceTree = SOURCE_ROOT; }; 9DBB25529445D12D3C9FEEA5 /* Pods-SingleSignOn.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SingleSignOn.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SingleSignOn/Pods-SingleSignOn.debug.xcconfig"; sourceTree = ""; }; + DA2D1D8A2965E3FC000FC010 /* OIDTokenResponseExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OIDTokenResponseExtension.swift; sourceTree = ""; }; + DA2D1D902971D219000FC010 /* Endpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Endpoint.swift; sourceTree = ""; }; EBE0D3BF6EC9DFD049322169 /* Pods-SingleSignOn.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SingleSignOn.release.xcconfig"; path = "Pods/Target Support Files/Pods-SingleSignOn/Pods-SingleSignOn.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -124,7 +126,8 @@ 5610F069202D13E7004CD2AC /* Info.plist */, 5610F07F202D14E3004CD2AC /* Podfile */, 56FC88E9202E0E04008F7642 /* SingleSignOn.podspec */, - 56FC88CA202D1845008F7642 /* API */, + DA2D1D8F2971D1E2000FC010 /* API */, + DA2D1D8E2971CE68000FC010 /* Extensions */, 56FC88D6202D1845008F7642 /* Model */, 56FC88D8202D1845008F7642 /* Services */, 56FC88CD202D1845008F7642 /* UI */, @@ -141,14 +144,6 @@ path = SingleSignOnTests; sourceTree = ""; }; - 56FC88CA202D1845008F7642 /* API */ = { - isa = PBXGroup; - children = ( - 56FC88CB202D1845008F7642 /* KeycloakAPI.swift */, - ); - path = API; - sourceTree = ""; - }; 56FC88CD202D1845008F7642 /* UI */ = { isa = PBXGroup; children = ( @@ -195,6 +190,22 @@ name = Frameworks; sourceTree = ""; }; + DA2D1D8E2971CE68000FC010 /* Extensions */ = { + isa = PBXGroup; + children = ( + DA2D1D8A2965E3FC000FC010 /* OIDTokenResponseExtension.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + DA2D1D8F2971D1E2000FC010 /* API */ = { + isa = PBXGroup; + children = ( + DA2D1D902971D219000FC010 /* Endpoint.swift */, + ); + path = API; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -336,10 +347,11 @@ buildActionMask = 2147483647; files = ( 56FC88E6202D1846008F7642 /* Theme.swift in Sources */, - 56FC88DB202D1846008F7642 /* KeycloakAPI.swift in Sources */, 56FC88DD202D1846008F7642 /* AuthViewController.swift in Sources */, + DA2D1D912971D219000FC010 /* Endpoint.swift in Sources */, 56FC88E1202D1846008F7642 /* AuthenticationDelegate.swift in Sources */, 56FC88E5202D1846008F7642 /* AuthServices.swift in Sources */, + DA2D1D8B2965E3FC000FC010 /* OIDTokenResponseExtension.swift in Sources */, 56FC88E4202D1846008F7642 /* Credentials.swift in Sources */, 56FC88DF202D1846008F7642 /* AuthenticationError.swift in Sources */, 56FC88E2202D1846008F7642 /* WebHeaderView.swift in Sources */, @@ -399,7 +411,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Developer"; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -417,7 +429,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.2; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -461,7 +473,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Developer"; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -473,7 +485,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.2; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; diff --git a/SingleSignOn/API/Endpoint.swift b/SingleSignOn/API/Endpoint.swift new file mode 100644 index 0000000..b85c084 --- /dev/null +++ b/SingleSignOn/API/Endpoint.swift @@ -0,0 +1,61 @@ +// +// EndpointInfo.swift +// SingleSignOn +// +// Created by Scharien, Todd SDPR:EX on 2023-01-13. +// Copyright © 2023 Jason Leach. All rights reserved. +// + +import Foundation + +public struct Endpoint { + public let realmName: String + public let clientId: String + public let redirectUri: String + public let baseUrl: String + public let responseType: String + + public let hint: String? + + var baseOidcUrl: String { + return baseUrl + "/auth/realms/\(realmName)/protocol/openid-connect" + } + + public var authUrl: String { + return baseOidcUrl + "/auth" + } + + public var tokenUrl: String { + return baseOidcUrl + "/token" + } + + public var logoutUrl: String { + return baseOidcUrl + "/logout" + } + + public var oidcQuery: String { + var query = "response_type=\(responseType)&client_id=\(clientId)&redirect_uri=\(redirectUri)" + + if let hint = hint { + query += "&kc_idp_hint=\(hint)" + } + + return query + } + + init(realmName: String, + clientId: String, + redirectUri: String, + baseUrl: String, + responseType: String = Constants.API.authenticationResponseType, + hint: String? = nil) { + + self.realmName = realmName + self.clientId = clientId + self.redirectUri = redirectUri + self.baseUrl = baseUrl + self.responseType = responseType + + self.hint = hint + } +} diff --git a/SingleSignOn/API/KeycloakAPI.swift b/SingleSignOn/API/KeycloakAPI.swift deleted file mode 100644 index a26d0c9..0000000 --- a/SingleSignOn/API/KeycloakAPI.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// SecureImage -// -// Copyright © 2018 Province of British Columbia -// -// 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. -// -// Created by Jason Leach on 2018-02-01. -// - -import Foundation -import Alamofire - -class KeycloakAPI { - - static let refreshTokenExpiredMessage = "Refresh token expired" - - class func exchange(oneTimeCode code: String, url: URL, grantType: String, redirectUri: String, clientId: String, completionHandler: @escaping (_ response: Credentials?, _ error: Error?) -> ()) { - - let params = ["grant_type": grantType, "redirect_uri": redirectUri, "client_id": clientId, "code": code] - - Alamofire.request(url, method: .post, parameters: params, encoding: URLEncoding.default) - .responseJSON { response in - - guard response.result.isSuccess else { - completionHandler(nil, AuthenticationError.unknownError) - return - } - - guard let json = response.result.value as? [String: Any] else { - print("No JSON returned in response.") - print("Error: \(String(describing: response.result.error))") - - completionHandler(nil, AuthenticationError.unknownError) - return - } - - let model = Credentials(withJSON: json) - completionHandler(model, nil) - } - } - - class func refresh(credentials: Credentials, url: URL, grantType: String, redirectUri: String, clientId: String, completionHandler: @escaping (_ response: Credentials?, _ error: Error?) -> ()) { - - let params = ["grant_type": grantType, "redirect_uri": redirectUri, "client_id": clientId, "refresh_token": credentials.refreshToken] - - Alamofire.request(url, method: .post, parameters: params, encoding: URLEncoding.default) - .responseJSON { response in - - guard response.result.isSuccess else { - completionHandler(nil, AuthenticationError.unknownError) - return - } - - guard let json = response.result.value as? [String: Any], json["error"] == nil else { - print("result error: \(String(describing: response.result.error))") - if let json = response.result.value as? [String: Any] { - let errorMessage = String(describing: json["error_description"] ?? "No message supplied") - print("result message: \(errorMessage)") - - if errorMessage == KeycloakAPI.refreshTokenExpiredMessage { - completionHandler(nil, AuthenticationError.expired) - return - } - } - - completionHandler(nil, AuthenticationError.unknownError) - return - } - - let model = Credentials(withJSON: json) - completionHandler(model, nil) - } - } -} diff --git a/SingleSignOn/Constants.swift b/SingleSignOn/Constants.swift index d30b1a3..78774cb 100644 --- a/SingleSignOn/Constants.swift +++ b/SingleSignOn/Constants.swift @@ -32,15 +32,9 @@ struct Constants { } struct API { - // The token {{REALM_NAME}} will be replaced with the correct value - // as needed. - static let auth = "/auth/realms/{{REALM_NAME}}/protocol/openid-connect/auth" - static let token = "/auth/realms/{{REALM_NAME}}/protocol/openid-connect/token" - static let logout = "/auth/realms/{{REALM_NAME}}/protocol/openid-connect/logout" static let authenticationResponseType = "code" static let allowedWebDomain = "gov.bc.ca" static let secureScheme = "https" - static let realmToken = "{{REALM_NAME}}" } enum GrantType: String { diff --git a/SingleSignOn/Extensions/CredentialsExtensions.swift b/SingleSignOn/Extensions/CredentialsExtensions.swift new file mode 100644 index 0000000..257c6e6 --- /dev/null +++ b/SingleSignOn/Extensions/CredentialsExtensions.swift @@ -0,0 +1,34 @@ +// +// CredentialsExtensions.swift +// SingleSignOn +// +// Created by Scharien, Todd SDPR:EX on 2023-01-04. +// Copyright © 2023 Jason Leach. All rights reserved. +// + +import Foundation +import AppAuth + +extension OIDTokenResponse { + + func toCredentials() -> Credentials { + let currentDate = Date() + let expiresIn = accessTokenExpirationDate!.timeIntervalSince(currentDate) // in seconds + let refreshExpiresIn = additionalParameters?[Credentials.Key.RefreshExpiresIn] as! Double // in seconds + let refreshExpiresAt = currentDate.addingTimeInterval(refreshExpiresIn) + + return Credentials(withJSON: [ + Credentials.Key.TokenType: tokenType!, + Credentials.Key.RefreshToken: refreshToken!, + Credentials.Key.AccessToken: accessToken!, + Credentials.Key.SessionState: String(describing: additionalParameters?[Credentials.Key.SessionState]), + Credentials.Key.RefreshExpiresIn: Int(refreshExpiresIn), + Credentials.Key.RefreshExpiresAt: refreshExpiresAt, + Credentials.Key.NotBeforePolicy: additionalParameters?[Credentials.Key.NotBeforePolicy] as! Int, + Credentials.Key.ExpiresIn: Int(expiresIn), + Credentials.Key.ExpiresAt: accessTokenExpirationDate! + ]) + + } + +} diff --git a/SingleSignOn/Extensions/OIDAuthStateExtensions.swift b/SingleSignOn/Extensions/OIDAuthStateExtensions.swift new file mode 100644 index 0000000..050f0b6 --- /dev/null +++ b/SingleSignOn/Extensions/OIDAuthStateExtensions.swift @@ -0,0 +1,41 @@ +// +// OIDAuthStateExtensions.swift +// SingleSignOn +// +// Created by Scharien, Todd SDPR:EX on 2023-01-18. +// + +import Foundation +import AppAuth +import SwiftKeychainWrapper + +internal extension OIDAuthState { + + static let AuthStateKey = "Serialized.AppAuth.OIDAuthState" + + private func saveToStorage(data: Data) { + KeychainWrapper.standard.set(data, forKey: OIDAuthState.AuthStateKey) + } + + private static func loadFromStorage() -> Data? { + return KeychainWrapper.standard.data(forKey: OIDAuthState.AuthStateKey) + } + + static func removeFromStorage() { + KeychainWrapper.standard.remove(key: OIDAuthState.AuthStateKey) + } + + func saveAsSerialized() throws { + let data = try NSKeyedArchiver.archivedData(withRootObject: self, requiringSecureCoding: true) + saveToStorage(data: data) + } + + static func loadFromSerialized() throws -> OIDAuthState { + if let data = loadFromStorage() { + return try NSKeyedUnarchiver.unarchivedObject(ofClass: OIDAuthState.self, from: data)! + } else { + throw AuthenticationError.credentialsUnavailable + } + } + +} diff --git a/SingleSignOn/Info.plist b/SingleSignOn/Info.plist index 1007fd9..a5ae6f9 100644 --- a/SingleSignOn/Info.plist +++ b/SingleSignOn/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 1.0 + 1.1.0 CFBundleVersion $(CURRENT_PROJECT_VERSION) NSPrincipalClass diff --git a/SingleSignOn/Model/Credentials.swift b/SingleSignOn/Model/Credentials.swift index 2a7df2d..eee7342 100644 --- a/SingleSignOn/Model/Credentials.swift +++ b/SingleSignOn/Model/Credentials.swift @@ -20,18 +20,29 @@ import Foundation import SwiftKeychainWrapper -import SwiftKeychainWrapper public struct Credentials { + public struct Key { + public static let AccessToken = "access_token" + public static let TokenType = "token_type" + public static let RefreshToken = "refresh_token" + public static let SessionState = "session_state" + public static let RefreshExpiresIn = "refresh_expires_in" + public static let RefreshExpiresAt = "refreshExpiresAt" + public static let NotBeforePolicy = "not-before-policy" + public static let ExpiresIn = "expires_in" + public static let ExpiresAt = "expiresAt" + } + public let accessToken: String internal let tokenType: String internal let refreshToken: String internal let sessionState: String - internal let refreshExpiresIn: Int + internal let refreshExpiresIn: Int // in seconds internal let refreshExpiresAt: Date internal let notBeforePolicy: Int - internal let expiresIn: Int + internal let expiresIn: Int // in seconds internal let expiresAt: Date internal let props: [String : Any] @@ -65,17 +76,21 @@ public struct Credentials { init(withJSON data: [String: Any]) { - tokenType = data["token_type"] as! String - refreshToken = data["refresh_token"] as! String - accessToken = data["access_token"] as! String - sessionState = data["session_state"] as! String - refreshExpiresIn = data["refresh_expires_in"] as! Int // in sec - notBeforePolicy = data["not-before-policy"] as! Int - expiresIn = data["expires_in"] as! Int // in sec + tokenType = data[Key.TokenType] as! String + refreshToken = data[Key.RefreshToken] as! String + accessToken = data[Key.AccessToken] as! String + sessionState = data[Key.SessionState] as! String + refreshExpiresIn = data[Key.RefreshExpiresIn] as! Int + notBeforePolicy = data[Key.NotBeforePolicy] as! Int + expiresIn = data[Key.ExpiresIn] as! Int // If we are loading credentials from the keychain we will have two additional fields representing when the // tokens will expire. Otherwise they need to be created - if let refreshExpiresAtString = data["refreshExpiresAt"] as? String, let refreshExpiresAt = Credentials.toDate(string: refreshExpiresAtString), let expiresAtString = data["expiresAt"] as? String, let expiresAt = Credentials.toDate(string: expiresAtString) { + if let refreshExpiresAtString = data[Key.RefreshExpiresAt] as? String, + let refreshExpiresAt = Credentials.toDate(string: refreshExpiresAtString), + let expiresAtString = data[Key.ExpiresAt] as? String, + let expiresAt = Credentials.toDate(string: expiresAtString) { + self.refreshExpiresAt = refreshExpiresAt self.expiresAt = expiresAt } else { @@ -84,7 +99,17 @@ public struct Credentials { } // Used to serialize this object so it can be stored in the keychian - props = ["token_type": tokenType, "refresh_token": refreshToken, "access_token": accessToken, "session_state": sessionState, "refresh_expires_in": refreshExpiresIn, "not-before-policy": notBeforePolicy, "expires_in": expiresIn, "refreshExpiresAt": Credentials.dateToString(date: refreshExpiresAt), "expiresAt": Credentials.dateToString(date: expiresAt)] + props = [ + Key.TokenType: tokenType, + Key.RefreshToken: refreshToken, + Key.AccessToken: accessToken, + Key.SessionState: sessionState, + Key.RefreshExpiresIn: refreshExpiresIn, + Key.NotBeforePolicy: notBeforePolicy, + Key.ExpiresIn: expiresIn, + Key.RefreshExpiresAt: Credentials.dateToString(date: refreshExpiresAt), + Key.ExpiresAt: Credentials.dateToString(date: expiresAt) + ] save() } @@ -94,9 +119,14 @@ public struct Credentials { KeychainWrapper.standard.removeObject(forKey: Constants.Keychain.KeycloakCredentials) } - public func isExpired() -> Bool { - - return isAuthTokenExpired() && isRefreshTokenExpired() + public func isValid() -> Bool { + + return !isAuthTokenExpired() && !isRefreshTokenExpired() + } + + public func canRefresh() -> Bool { + + return isAuthTokenExpired() && !isRefreshTokenExpired() } public func isAuthTokenExpired() -> Bool { @@ -115,7 +145,7 @@ public struct Credentials { do { return try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any] } catch let error { - print("error converting to json: \(error)") + print("Error converting to json: \(error)") } } @@ -128,10 +158,10 @@ public struct Credentials { let data = try JSONSerialization.data(withJSONObject: props, options: .prettyPrinted) // Securley store the credentials guard KeychainWrapper.standard.set(data.base64EncodedString(), forKey: Constants.Keychain.KeycloakCredentials) else { - fatalError("Unalbe to store auth credentials") + fatalError("Unable to store auth credentials") } } catch let error { - print("error converting to json: \(error)") + print("Error converting to json: \(error)") } } diff --git a/SingleSignOn/Services/AuthServices.swift b/SingleSignOn/Services/AuthServices.swift index 5a887b7..9b0dc34 100644 --- a/SingleSignOn/Services/AuthServices.swift +++ b/SingleSignOn/Services/AuthServices.swift @@ -5,7 +5,7 @@ // // 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 +// You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // @@ -19,66 +19,116 @@ // import Foundation - -public typealias AuthenticationCompleted = (_ credentials: Credentials?, _ error: Error?) -> Void +import AppAuth public class AuthServices: NSObject { - private var baseUrl: URL - private var redirectUri: String - private var clientId: String - private var realm: String - private var idpHint: String? + private let endpoint: Endpoint + private let authConfig: OIDServiceConfiguration + + private var authRequest: OIDAuthorizationRequest { + return OIDAuthorizationRequest( + configuration: authConfig, + clientId: endpoint.clientId, + scopes: [OIDScopeOpenID, OIDScopeProfile], + redirectURL: URL(string: endpoint.redirectUri)!, + responseType: OIDResponseTypeCode, + additionalParameters: nil + ) + } + public private(set) var credentials: Credentials? = { return Credentials.loadFromStoredCredentials() }() - public var onAuthenticationCompleted: AuthenticationCompleted? + + private var _authState: OIDAuthState? + var authState: OIDAuthState? { + get { + if _authState == nil { + do { + _authState = try OIDAuthState.loadFromSerialized() + } catch { + _authState = nil + } + } + return _authState + } + set { + _authState = newValue + } + } + var currentAuthorizationFlow: OIDExternalUserAgentSession? public init(baseUrl: URL, redirectUri: String, clientId: String, realm: String, idpHint: String? = nil) { - self.baseUrl = baseUrl - self.redirectUri = redirectUri - self.clientId = clientId - self.realm = realm - self.idpHint = idpHint - + endpoint = Endpoint( + realmName: realm, + clientId: clientId, + redirectUri: redirectUri, + baseUrl: baseUrl.absoluteString, + hint: idpHint + ) + + authConfig = OIDServiceConfiguration( + authorizationEndpoint: URL(string: endpoint.authUrl)!, + tokenEndpoint: URL(string: endpoint.tokenUrl)! + ) + super.init() } public func isAuthenticated() -> Bool { - - guard let credentials = credentials, !credentials.isExpired() else { - return false + if let credentials { + return credentials.isValid() } - - return true + return false } - public func viewController(completion: AuthenticationCompleted? = nil) -> AuthViewController { - - let endpoint = Constants.API.auth.replacingOccurrences(of: Constants.API.realmToken, with: realm) - let url = baseUrl.appendingPathComponent(endpoint) - let avc = AuthViewController(authUrl: url, redirectUri: redirectUri, clientId: clientId, responseType: Constants.API.authenticationResponseType, idpHint: idpHint) - avc.delegate = self - onAuthenticationCompleted = completion - - return avc + public func canRefresh() -> Bool { + if let credentials { + return credentials.canRefresh() && authState != nil + } + return false } - - public func exchange(_ oneTimeCode: String, completion: @escaping (Credentials?, Error?) -> Void) { + + public func doWithAuthentication(presenting: UIViewController, completion: @escaping (Credentials?, Error?) -> Void) { + if isAuthenticated() { + completion(credentials, nil) + } else if canRefresh() { + refreshCredientials(completion: completion) + } else { + // no credentials or all tokens expired + authenticate(presenting: presenting, completion: completion) + } + } + + private func authenticate(presenting: UIViewController, completion: @escaping (Credentials?, Error?) -> Void) { - let endpoint = Constants.API.token.replacingOccurrences(of: Constants.API.realmToken, with: realm) - let url = baseUrl.appendingPathComponent(endpoint) - KeycloakAPI.exchange(oneTimeCode: oneTimeCode, url: url, grantType: Constants.GrantType.authorizationCode.rawValue, redirectUri: redirectUri, clientId: clientId) { (credentials: Credentials?, error: Error?) in - - self.credentials = credentials - completion(credentials, error) + OIDAuthState.removeFromStorage() + + currentAuthorizationFlow = OIDAuthState.authState(byPresenting: authRequest, presenting: presenting) + { authState, error in + + self.authState = authState ?? nil + self.credentials = authState?.lastTokenResponse?.toCredentials() + + if let authState, error == nil { + do { + try authState.saveAsSerialized() + } catch let savingError { + self.logout() + completion(nil, savingError) + return + } + } + + completion(self.credentials, error) } } - public func refreshCredientials(completion: @escaping (Credentials?, Error?) -> Void) { + private func refreshCredientials(completion: @escaping (Credentials?, Error?) -> Void) { - guard let credentials = credentials else { + guard let credentials else { completion(nil, AuthenticationError.credentialsUnavailable) return } @@ -87,45 +137,42 @@ public class AuthServices: NSObject { completion(nil, AuthenticationError.expired) return } - - let endpoint = Constants.API.token.replacingOccurrences(of: Constants.API.realmToken, with: realm) - let url = baseUrl.appendingPathComponent(endpoint) - KeycloakAPI.refresh(credentials: credentials, url: url, grantType: Constants.GrantType.refreshToken.rawValue, redirectUri: redirectUri, clientId: clientId) { (credentials: Credentials?, error: Error?) in - - self.credentials = credentials - completion(credentials, error) + + guard let authState else { + completion(nil, AuthenticationError.credentialsUnavailable) + return } - } - - public func logout() { - guard let credentials = credentials else { + guard let tokenRefreshRequest = authState.tokenRefreshRequest() else { + completion(nil, AuthenticationError.unableToCreateTokenRefreshRequest) return } - - credentials.remove(); - self.credentials = nil - } -} - -// MARK: AuthenticationDelegate -extension AuthServices: AuthenticationDelegate { - - public func authenticationSucceded(oneTimeCode: String) { - exchange(oneTimeCode) { (credentials: Credentials?, error: Error?) in - - guard let credentials = credentials else { - - self.onAuthenticationCompleted?(nil, AuthenticationError.unableToExchangeOneTimeCodeForToken) - return + OIDAuthorizationService.perform(tokenRefreshRequest) { tokenResponse, error in + let credentials = tokenResponse?.toCredentials() + + if let _ = credentials, error == nil { + do { + try authState.saveAsSerialized() + } catch let savingError { + self.logout() + completion(nil, savingError) + return + } } - self.onAuthenticationCompleted?(credentials, nil) + self.credentials = credentials + completion(credentials, error) } } - public func authenticationFailed(error: Error) { - onAuthenticationCompleted?(nil, error) + public func logout() { + + if let credentials { + credentials.remove(); + self.credentials = nil + } + + OIDAuthState.removeFromStorage() } } diff --git a/SingleSignOn/UI/AuthViewController.swift b/SingleSignOn/UI/AuthViewController.swift index 7b154dc..69a5fb5 100644 --- a/SingleSignOn/UI/AuthViewController.swift +++ b/SingleSignOn/UI/AuthViewController.swift @@ -23,11 +23,7 @@ import WebKit public class AuthViewController: UIViewController { - private var authUrl: URL - private var redirectUri: String - private var clientId: String - private var responseType: String - private var idpHint: String? + private var endpoint: Endpoint private let headerViewHeight: CGFloat = { return 88.0 }() @@ -39,7 +35,7 @@ public class AuthViewController: UIViewController { }() private let headerView: WebHeaderView = { let bundle = Bundle(for: WebHeaderView.self) - let v = bundle.loadNibNamed("WebHeaderView", owner: self, options: nil)?.first as! WebHeaderView + let v = bundle.loadNibNamed("WebHeaderView", owner: AuthViewController.self, options: nil)?.first as! WebHeaderView v.translatesAutoresizingMaskIntoConstraints = false return v @@ -47,13 +43,8 @@ public class AuthViewController: UIViewController { private var recievedCustomRedirectUrl = false public weak var delegate: AuthenticationDelegate? - public init(authUrl: URL, redirectUri: String, clientId: String, responseType: String, idpHint: String? = nil) { - - self.redirectUri = redirectUri - self.clientId = clientId - self.authUrl = authUrl - self.responseType = responseType - self.idpHint = idpHint + public init(endpoint: Endpoint) { + self.endpoint = endpoint super.init(nibName: nil, bundle: nil) @@ -100,14 +91,8 @@ public class AuthViewController: UIViewController { private func buildAuthenticationURL() -> URL? { - var components = URLComponents(url: authUrl, resolvingAgainstBaseURL: true) - var query = "response_type=\(responseType)&client_id=\(clientId)&redirect_uri=\(redirectUri)" - if let idpHint = idpHint { - query = query + "&kc_idp_hint=\(idpHint)" - } - - components?.query = query - + var components = URLComponents(url: URL(string: endpoint.authUrl)!, resolvingAgainstBaseURL: true) + components?.query = endpoint.oidcQuery return components?.url } @@ -149,7 +134,7 @@ public class AuthViewController: UIViewController { // Only the scheme is important in determining if the URL is our // custom redirect URL. let redirComponents = URLComponents(url: url, resolvingAgainstBaseURL: true) - let customComponents = URLComponents(string: redirectUri) + let customComponents = URLComponents(string: endpoint.redirectUri) return redirComponents?.scheme == customComponents?.scheme } diff --git a/SingleSignOn/UI/AuthenticationError.swift b/SingleSignOn/UI/AuthenticationError.swift index 45456d6..cda913a 100644 --- a/SingleSignOn/UI/AuthenticationError.swift +++ b/SingleSignOn/UI/AuthenticationError.swift @@ -29,4 +29,5 @@ public enum AuthenticationError: Error { case credentialsUnavailable case expired case webRequestFailed(error: Error) + case unableToCreateTokenRefreshRequest }