diff --git a/Sources/OAuthenticator/Authenticator.swift b/Sources/OAuthenticator/Authenticator.swift index 0ad0d38..2186382 100644 --- a/Sources/OAuthenticator/Authenticator.swift +++ b/Sources/OAuthenticator/Authenticator.swift @@ -18,8 +18,8 @@ public enum AuthenticatorError: Error, Hashable { case httpResponseExpected case unauthorizedRefreshFailed case missingRedirectURI - case missingRefreshToken - case missingScope + case missingRefreshToken + case missingScope case failingAuthenticatorUsed case dpopTokenExpected(String) case parRequestURIMissing @@ -31,7 +31,7 @@ public enum AuthenticatorError: Error, Hashable { /// Manage state required to executed authenticated URLRequests. public actor Authenticator { public typealias UserAuthenticator = @Sendable (URL, String) async throws -> URL - public typealias AuthenticationStatusHandler = @Sendable (Result) async -> Void + public typealias AuthenticationStatusHandler = @Sendable (Result) async -> Void /// A `UserAuthenticator` that always fails. Useful as a placeholder /// for testing and for doing manual authentication with an external @@ -73,8 +73,8 @@ public actor Authenticator { public let userAuthenticator: UserAuthenticator public let mode: UserAuthenticationMode - // Specify an authenticationResult closure to obtain result and grantedScope - public let authenticationStatusHandler: AuthenticationStatusHandler? + // Specify an authenticationResult closure to obtain result and grantedScope + public let authenticationStatusHandler: AuthenticationStatusHandler? #if canImport(AuthenticationServices) @available(tvOS 16.0, macCatalyst 13.0, *) @@ -94,7 +94,7 @@ public actor Authenticator { // ASWebAuthenticationSession.userAuthenticator directly here // with GlobalActorIsolatedTypesUsability, but it isn't working self.userAuthenticator = { try await ASWebAuthenticationSession.userAuthenticator(url: $0, scheme: $1) } - self.authenticationStatusHandler = authenticationStatusHandler + self.authenticationStatusHandler = authenticationStatusHandler } #endif @@ -111,7 +111,7 @@ public actor Authenticator { self.tokenHandling = tokenHandling self.mode = mode self.userAuthenticator = userAuthenticator - self.authenticationStatusHandler = authenticationStatusHandler + self.authenticationStatusHandler = authenticationStatusHandler } } @@ -214,17 +214,14 @@ extension Authenticator { try await storage.storeLogin(login) } - - private func clearLogin() async { - guard let storage = config.loginStorage else { return } - - let invalidLogin = Login(token: "invalid", validUntilDate: .distantPast) - - do { - try await storage.storeLogin(invalidLogin) - } catch { - print("failed to store an invalid login, possibly stuck", error) + + private func clearLogin() async throws { + guard let storage = config.loginStorage else { + self.localLogin = nil + return } + + try await storage.clearLogin() } } @@ -250,25 +247,25 @@ extension Authenticator { private func loginTaskResult(manual: Bool, userAuthenticator: @escaping UserAuthenticator) async throws -> Login { let task = activeTokenTask ?? makeLoginTask(manual: manual, userAuthenticator: userAuthenticator) - var login: Login - do { - do { - login = try await loginFromTask(task: task) - } catch AuthenticatorError.tokenInvalid { - let newTask = makeLoginTask(manual: manual, userAuthenticator: userAuthenticator) - login = try await loginFromTask(task: newTask) - } - - // Inform authenticationResult closure of new login information - await self.config.authenticationStatusHandler?(.success(login)) - } - catch let authenticatorError as AuthenticatorError { - await self.config.authenticationStatusHandler?(.failure(authenticatorError)) - - // Rethrow error - throw authenticatorError - } - + var login: Login + do { + do { + login = try await loginFromTask(task: task) + } catch AuthenticatorError.tokenInvalid { + let newTask = makeLoginTask(manual: manual, userAuthenticator: userAuthenticator) + login = try await loginFromTask(task: newTask) + } + + // Inform authenticationResult closure of new login information + await self.config.authenticationStatusHandler?(.success(login)) + } + catch let authenticatorError as AuthenticatorError { + await self.config.authenticationStatusHandler?(.failure(authenticatorError)) + + // Rethrow error + throw authenticatorError + } + return login } @@ -347,13 +344,13 @@ extension Authenticator { do { let login = try await refreshProvider(login, config.appCredentials, { try await self.dpopResponse(for: $0, login: nil) }) - + try await storeLogin(login) - + return login } catch { - await clearLogin() - + try await clearLogin() + throw error } } @@ -362,7 +359,7 @@ extension Authenticator { guard let pkce = config.tokenHandling.pkce else { throw AuthenticatorError.pkceRequired } - + let challenge = pkce.challenge let scopes = config.appCredentials.scopes.joined(separator: " ") let callbackURI = config.appCredentials.callbackURL @@ -415,7 +412,7 @@ extension Authenticator { guard let generator = config.tokenHandling.dpopJWTGenerator else { return try await urlLoader(request) } - + guard let pkce = config.tokenHandling.pkce else { throw AuthenticatorError.pkceRequired } diff --git a/Sources/OAuthenticator/Models.swift b/Sources/OAuthenticator/Models.swift index ed5a690..2c50251 100644 --- a/Sources/OAuthenticator/Models.swift +++ b/Sources/OAuthenticator/Models.swift @@ -41,11 +41,11 @@ public struct Login: Codable, Hashable, Sendable { public var accessToken: Token public var refreshToken: Token? - // User authorized scopes - public var scopes: String? + // User authorized scopes + public var scopes: String? public var issuingServer: String? - public init(accessToken: Token, refreshToken: Token? = nil, scopes: String? = nil, issuingServer: String? = nil) { + public init(accessToken: Token, refreshToken: Token? = nil, scopes: String? = nil, issuingServer: String? = nil) { self.accessToken = accessToken self.refreshToken = refreshToken self.scopes = scopes @@ -88,13 +88,20 @@ public struct AppCredentials: Codable, Hashable, Sendable { public struct LoginStorage: Sendable { public typealias RetrieveLogin = @Sendable () async throws -> Login? public typealias StoreLogin = @Sendable (Login) async throws -> Void + public typealias ClearLogin = @Sendable () async throws -> Void public let retrieveLogin: RetrieveLogin public let storeLogin: StoreLogin + public let clearLogin: ClearLogin - public init(retrieveLogin: @escaping RetrieveLogin, storeLogin: @escaping StoreLogin) { + public init( + retrieveLogin: @escaping RetrieveLogin, + storeLogin: @escaping StoreLogin, + clearLogin: @escaping ClearLogin + ) { self.retrieveLogin = retrieveLogin self.storeLogin = storeLogin + self.clearLogin = clearLogin } } diff --git a/Sources/OAuthenticator/Services/GitHub.swift b/Sources/OAuthenticator/Services/GitHub.swift index 92629df..4fc71ae 100644 --- a/Sources/OAuthenticator/Services/GitHub.swift +++ b/Sources/OAuthenticator/Services/GitHub.swift @@ -91,7 +91,7 @@ public enum GitHub { urlBuilder.queryItems = [ URLQueryItem(name: "client_id", value: credentials.clientId), URLQueryItem(name: "redirect_uri", value: credentials.callbackURL.absoluteString), - URLQueryItem(name: "scope", value: credentials.scopeString), + URLQueryItem(name: "scope", value: credentials.scopeString), ] if let state = parameters.state { diff --git a/Sources/OAuthenticator/Services/GoogleAPI.swift b/Sources/OAuthenticator/Services/GoogleAPI.swift index b1e57fe..19df9c1 100644 --- a/Sources/OAuthenticator/Services/GoogleAPI.swift +++ b/Sources/OAuthenticator/Services/GoogleAPI.swift @@ -4,89 +4,89 @@ import FoundationNetworking #endif public struct GoogleAPI { - // Define scheme, host and query item names + // Define scheme, host and query item names public static let scheme: String = "https" - static let authorizeHost: String = "accounts.google.com" + static let authorizeHost: String = "accounts.google.com" static let authorizePath: String = "/o/oauth2/auth" - static let tokenHost: String = "accounts.google.com" + static let tokenHost: String = "accounts.google.com" static let tokenPath: String = "/o/oauth2/token" static let clientIDKey: String = "client_id" static let clientSecretKey: String = "client_secret" static let redirectURIKey: String = "redirect_uri" - static let responseTypeKey: String = "response_type" - static let responseTypeCode: String = "code" + static let responseTypeKey: String = "response_type" + static let responseTypeCode: String = "code" - static let scopeKey: String = "scope" - static let includeGrantedScopeKey: String = "include_granted_scopes" - static let loginHint: String = "login_hint" - + static let scopeKey: String = "scope" + static let includeGrantedScopeKey: String = "include_granted_scopes" + static let loginHint: String = "login_hint" + static let codeKey: String = "code" - static let refreshTokenKey: String = "refresh_token" + static let refreshTokenKey: String = "refresh_token" - static let grantTypeKey: String = "grant_type" + static let grantTypeKey: String = "grant_type" static let grantTypeAuthorizationCode: String = "authorization_code" - static let grantTypeRefreshToken: String = "refresh_token" + static let grantTypeRefreshToken: String = "refresh_token" struct OAuthResponse: Codable, Hashable, Sendable { let accessToken: String - let refreshToken: String? // When not using offline mode, no refreshToken is provided + let refreshToken: String? // When not using offline mode, no refreshToken is provided let scope: String let tokenType: String - let expiresIn: Int // Access Token validity in seconds + let expiresIn: Int // Access Token validity in seconds enum CodingKeys: String, CodingKey { case accessToken = "access_token" - case refreshToken = "refresh_token" + case refreshToken = "refresh_token" case scope case tokenType = "token_type" case expiresIn = "expires_in" } var login: Login { - var login = Login(accessToken: .init(value: accessToken, expiresIn: expiresIn)) - - // Set the refresh token if we have one - if let refreshToken = refreshToken { - login.refreshToken = .init(value: refreshToken) - } - - // Set the authorized scopes from the OAuthResponse if present - if !self.scope.isEmpty { - login.scopes = self.scope - } - - return login + var login = Login(accessToken: .init(value: accessToken, expiresIn: expiresIn)) + + // Set the refresh token if we have one + if let refreshToken = refreshToken { + login.refreshToken = .init(value: refreshToken) + } + + // Set the authorized scopes from the OAuthResponse if present + if !self.scope.isEmpty { + login.scopes = self.scope + } + + return login + } + } + + /// Optional Google API Parameters for authorization request + public struct GoogleAPIParameters: Sendable { + public var includeGrantedScopes: Bool + public var loginHint: String? + + public init() { + self.includeGrantedScopes = true + self.loginHint = nil + } + + public init(includeGrantedScopes: Bool, loginHint: String?) { + self.includeGrantedScopes = includeGrantedScopes + self.loginHint = loginHint } } - /// Optional Google API Parameters for authorization request - public struct GoogleAPIParameters: Sendable { - public var includeGrantedScopes: Bool - public var loginHint: String? - - public init() { - self.includeGrantedScopes = true - self.loginHint = nil - } - - public init(includeGrantedScopes: Bool, loginHint: String?) { - self.includeGrantedScopes = includeGrantedScopes - self.loginHint = loginHint - } - } - - public static func googleAPITokenHandling(with parameters: GoogleAPIParameters = .init()) -> TokenHandling { - TokenHandling(authorizationURLProvider: Self.authorizationURLProvider(with: parameters), - loginProvider: Self.loginProvider, - refreshProvider: Self.refreshProvider()) + public static func googleAPITokenHandling(with parameters: GoogleAPIParameters = .init()) -> TokenHandling { + TokenHandling(authorizationURLProvider: Self.authorizationURLProvider(with: parameters), + loginProvider: Self.loginProvider, + refreshProvider: Self.refreshProvider()) } - /// This is part 1 of the OAuth process - /// - /// Will request an authentication `code` based on the acceptance by the user - public static func authorizationURLProvider(with parameters: GoogleAPIParameters) -> TokenHandling.AuthorizationURLProvider { + /// This is part 1 of the OAuth process + /// + /// Will request an authentication `code` based on the acceptance by the user + public static func authorizationURLProvider(with parameters: GoogleAPIParameters) -> TokenHandling.AuthorizationURLProvider { return { params in let credentials = params.credentials @@ -100,13 +100,13 @@ public struct GoogleAPI { URLQueryItem(name: GoogleAPI.redirectURIKey, value: credentials.callbackURL.absoluteString), URLQueryItem(name: GoogleAPI.responseTypeKey, value: GoogleAPI.responseTypeCode), URLQueryItem(name: GoogleAPI.scopeKey, value: credentials.scopeString), - URLQueryItem(name: GoogleAPI.includeGrantedScopeKey, value: String(parameters.includeGrantedScopes)) - ] - - // Add login hint if provided - if let loginHint = parameters.loginHint { - urlBuilder.queryItems?.append(URLQueryItem(name: GoogleAPI.loginHint, value: loginHint)) - } + URLQueryItem(name: GoogleAPI.includeGrantedScopeKey, value: String(parameters.includeGrantedScopes)) + ] + + // Add login hint if provided + if let loginHint = parameters.loginHint { + urlBuilder.queryItems?.append(URLQueryItem(name: GoogleAPI.loginHint, value: loginHint)) + } guard let url = urlBuilder.url else { throw AuthenticatorError.missingAuthorizationURL @@ -116,30 +116,30 @@ public struct GoogleAPI { } } - /// This is part 2 of the OAuth process - /// - /// The `code` is exchanged for an access / refresh token pair using the granted scope in part 1 + /// This is part 2 of the OAuth process + /// + /// The `code` is exchanged for an access / refresh token pair using the granted scope in part 1 static func authenticationRequest(url: URL, appCredentials: AppCredentials) throws -> URLRequest { let code = try url.authorizationCode - // It's possible the user will decide to grant less scopes than requested by the app. - // The actual granted scopes will be recorded in the Login object upon code exchange... - let grantedScope = try url.grantedScope - - /* -- This is no longer necessary but kept as a reference -- - let grantedScopeItems = grantedScope.components(separatedBy: " ") - if appCredentials.scopes.count > grantedScopeItems.count { - // Here we just - os_log(.info, "[Authentication] Granted scopes less than requested scopes") - } - */ - - // Regardless if we want to move forward, we need to supply the granted scopes. - // If we don't, the tokens will not be issued and an error will occur - // The application can then later inspect the Login object and decide how to handle a reduce OAuth scope - var urlBuilder = URLComponents() + // It's possible the user will decide to grant less scopes than requested by the app. + // The actual granted scopes will be recorded in the Login object upon code exchange... + let grantedScope = try url.grantedScope + + /* -- This is no longer necessary but kept as a reference -- + let grantedScopeItems = grantedScope.components(separatedBy: " ") + if appCredentials.scopes.count > grantedScopeItems.count { + // Here we just + os_log(.info, "[Authentication] Granted scopes less than requested scopes") + } + */ + + // Regardless if we want to move forward, we need to supply the granted scopes. + // If we don't, the tokens will not be issued and an error will occur + // The application can then later inspect the Login object and decide how to handle a reduce OAuth scope + var urlBuilder = URLComponents() urlBuilder.scheme = GoogleAPI.scheme - urlBuilder.host = GoogleAPI.tokenHost + urlBuilder.host = GoogleAPI.tokenHost urlBuilder.path = GoogleAPI.tokenPath urlBuilder.queryItems = [ URLQueryItem(name: GoogleAPI.grantTypeKey, value: GoogleAPI.grantTypeAuthorizationCode), @@ -148,11 +148,11 @@ public struct GoogleAPI { URLQueryItem(name: GoogleAPI.codeKey, value: code), URLQueryItem(name: GoogleAPI.scopeKey, value: grantedScope) // See above for grantedScope explanation ] - - // Add clientSecret if supplied (not empty) - if !appCredentials.clientPassword.isEmpty { - urlBuilder.queryItems?.append(URLQueryItem(name: GoogleAPI.clientSecretKey, value: appCredentials.clientPassword)) - } + + // Add clientSecret if supplied (not empty) + if !appCredentials.clientPassword.isEmpty { + urlBuilder.queryItems?.append(URLQueryItem(name: GoogleAPI.clientSecretKey, value: appCredentials.clientPassword)) + } guard let url = urlBuilder.url else { throw AuthenticatorError.missingTokenURL @@ -182,54 +182,54 @@ public struct GoogleAPI { } } - /// Token Refreshing - /// - Create the request that will refresh the access token from the information in the Login - /// - /// - Parameters: - /// - login: The current Login object containing the refresh token - /// - appCredentials: The Application credentials - /// - Returns: The URLRequest to refresh the access token - static func authenticationRefreshRequest(login: Login, appCredentials: AppCredentials) throws -> URLRequest { - guard let refreshToken = login.refreshToken, - !refreshToken.value.isEmpty else { throw AuthenticatorError.missingRefreshToken } - - var urlBuilder = URLComponents() - - urlBuilder.scheme = GoogleAPI.scheme - urlBuilder.host = GoogleAPI.tokenHost - urlBuilder.path = GoogleAPI.tokenPath - urlBuilder.queryItems = [ - URLQueryItem(name: GoogleAPI.clientIDKey, value: appCredentials.clientId), - URLQueryItem(name: GoogleAPI.refreshTokenKey, value: refreshToken.value), - URLQueryItem(name: GoogleAPI.grantTypeKey, value: GoogleAPI.grantTypeRefreshToken), - ] - - guard let url = urlBuilder.url else { - throw AuthenticatorError.missingTokenURL - } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Accept") - - return request - } - + /// Token Refreshing + /// - Create the request that will refresh the access token from the information in the Login + /// + /// - Parameters: + /// - login: The current Login object containing the refresh token + /// - appCredentials: The Application credentials + /// - Returns: The URLRequest to refresh the access token + static func authenticationRefreshRequest(login: Login, appCredentials: AppCredentials) throws -> URLRequest { + guard let refreshToken = login.refreshToken, + !refreshToken.value.isEmpty else { throw AuthenticatorError.missingRefreshToken } + + var urlBuilder = URLComponents() + + urlBuilder.scheme = GoogleAPI.scheme + urlBuilder.host = GoogleAPI.tokenHost + urlBuilder.path = GoogleAPI.tokenPath + urlBuilder.queryItems = [ + URLQueryItem(name: GoogleAPI.clientIDKey, value: appCredentials.clientId), + URLQueryItem(name: GoogleAPI.refreshTokenKey, value: refreshToken.value), + URLQueryItem(name: GoogleAPI.grantTypeKey, value: GoogleAPI.grantTypeRefreshToken), + ] + + guard let url = urlBuilder.url else { + throw AuthenticatorError.missingTokenURL + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Accept") + + return request + } + static func refreshProvider() -> TokenHandling.RefreshProvider { return { login, appCredentials, urlLoader in - let request = try authenticationRefreshRequest(login: login, appCredentials: appCredentials) - let (data, _) = try await urlLoader(request) - - do { - let response = try JSONDecoder().decode(GoogleAPI.OAuthResponse.self, from: data) - return response.login - } - catch let decodingError as DecodingError { + let request = try authenticationRefreshRequest(login: login, appCredentials: appCredentials) + let (data, _) = try await urlLoader(request) + + do { + let response = try JSONDecoder().decode(GoogleAPI.OAuthResponse.self, from: data) + return response.login + } + catch let decodingError as DecodingError { let msg = decodingError.failureReason ?? decodingError.localizedDescription print("Non-conformant response from AuthenticationProvider:", msg) - throw decodingError - } - } + throw decodingError + } + } } } diff --git a/Sources/OAuthenticator/WellknownEndpoints.swift b/Sources/OAuthenticator/WellknownEndpoints.swift index bb47143..bf99ba3 100644 --- a/Sources/OAuthenticator/WellknownEndpoints.swift +++ b/Sources/OAuthenticator/WellknownEndpoints.swift @@ -64,7 +64,6 @@ public struct ServerMetadata: Codable, Hashable, Sendable { } // See: https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/ - public struct ClientMetadata: Hashable, Codable, Sendable { public let clientId: String public let scope: String diff --git a/Tests/OAuthenticatorTests/AuthenticatorTests.swift b/Tests/OAuthenticatorTests/AuthenticatorTests.swift index b14dc10..d445378 100644 --- a/Tests/OAuthenticatorTests/AuthenticatorTests.swift +++ b/Tests/OAuthenticatorTests/AuthenticatorTests.swift @@ -1,10 +1,10 @@ +import OAuthenticator import XCTest + #if canImport(FoundationNetworking) -import FoundationNetworking + import FoundationNetworking #endif -import OAuthenticator - enum AuthenticatorTestsError: Error { case disabled } @@ -14,8 +14,7 @@ final class MockURLResponseProvider: @unchecked Sendable { private(set) var requests: [URLRequest] = [] private let lock = NSLock() - init() { - } + init() {} func response(for request: URLRequest) throws -> (Data, URLResponse) { try lock.withLock { @@ -28,10 +27,15 @@ final class MockURLResponseProvider: @unchecked Sendable { var responseProvider: URLResponseProvider { return { try self.response(for: $0) } } - + static let dummyResponse: (Data, URLResponse) = ( "hello".data(using: .utf8)!, - URLResponse(url: URL(string: "https://test.com")!, mimeType: nil, expectedContentLength: 5, textEncodingName: nil) + URLResponse( + url: URL(string: "https://test.com")!, + mimeType: nil, + expectedContentLength: 5, + textEncodingName: nil + ) ) } @@ -49,12 +53,16 @@ final class AuthenticatorTests: XCTestCase { } @Sendable - private static func disabledAuthorizationURLProvider(parameters: TokenHandling.AuthorizationURLParameters) throws -> URL { + private static func disabledAuthorizationURLProvider( + parameters: TokenHandling.AuthorizationURLParameters + ) throws -> URL { throw AuthenticatorTestsError.disabled } @Sendable - private static func disabledLoginProvider(parameters: TokenHandling.LoginProviderParameters) throws -> Login { + private static func disabledLoginProvider(parameters: TokenHandling.LoginProviderParameters) + throws -> Login + { throw AuthenticatorTestsError.disabled } @@ -88,9 +96,11 @@ final class AuthenticatorTests: XCTestCase { return Login(token: "TOKEN") } - let tokenHandling = TokenHandling(authorizationURLProvider: urlProvider, - loginProvider: loginProvider, - responseStatusProvider: TokenHandling.allResponsesValid) + let tokenHandling = TokenHandling( + authorizationURLProvider: urlProvider, + loginProvider: loginProvider, + responseStatusProvider: TokenHandling.allResponsesValid + ) let retrieveTokenExp = expectation(description: "get token") let storeTokenExp = expectation(description: "save token") @@ -103,21 +113,14 @@ final class AuthenticatorTests: XCTestCase { XCTAssertEqual($0, Login(token: "TOKEN")) storeTokenExp.fulfill() + } clearLogin: { + XCTFail() } let config = Authenticator.Configuration( appCredentials: Self.mockCredentials, loginStorage: storage, -// loginStorage: nil, tokenHandling: tokenHandling, -// tokenHandling: TokenHandling( -// authorizationURLProvider: { _ in -// throw AuthenticatorTestsError.disabled -// }, -// loginProvider: { _, _, _, _ in -// throw AuthenticatorTestsError.disabled -// } -// ), userAuthenticator: mockUserAuthenticator ) @@ -125,7 +128,9 @@ final class AuthenticatorTests: XCTestCase { let (_, _) = try await auth.response(for: URLRequest(url: URL(string: "https://example.com")!)) - await fulfillment(of: [retrieveTokenExp, userAuthExp, storeTokenExp, authedLoadExp], timeout: 1.0, enforceOrder: true) + await fulfillment( + of: [retrieveTokenExp, userAuthExp, storeTokenExp, authedLoadExp], timeout: 1.0, + enforceOrder: true) } @MainActor @@ -152,12 +157,16 @@ final class AuthenticatorTests: XCTestCase { return Login(token: "TOKEN") } storeLogin: { _ in XCTFail() + } clearLogin: { + XCTFail() } - let config = Authenticator.Configuration(appCredentials: Self.mockCredentials, - loginStorage: storage, - tokenHandling: tokenHandling, - userAuthenticator: Self.disabledUserAuthenticator) + let config = Authenticator.Configuration( + appCredentials: Self.mockCredentials, + loginStorage: storage, + tokenHandling: tokenHandling, + userAuthenticator: Self.disabledUserAuthenticator + ) let auth = Authenticator(config: config, urlLoader: mockLoader) @@ -187,10 +196,12 @@ final class AuthenticatorTests: XCTestCase { return Login(token: "REFRESHED") } - let tokenHandling = TokenHandling(authorizationURLProvider: Self.disabledAuthorizationURLProvider, - loginProvider: Self.disabledLoginProvider, - refreshProvider: refreshProvider, - responseStatusProvider: TokenHandling.allResponsesValid) + let tokenHandling = TokenHandling( + authorizationURLProvider: Self.disabledAuthorizationURLProvider, + loginProvider: Self.disabledLoginProvider, + refreshProvider: refreshProvider, + responseStatusProvider: TokenHandling.allResponsesValid + ) let retrieveTokenExp = expectation(description: "get token") let storeTokenExp = expectation(description: "save token") @@ -198,24 +209,99 @@ final class AuthenticatorTests: XCTestCase { let storage = LoginStorage { retrieveTokenExp.fulfill() - return Login(accessToken: Token(value: "EXPIRED", expiry: .distantPast), - refreshToken: Token(value: "REFRESH")) + return Login( + accessToken: Token(value: "EXPIRED", expiry: .distantPast), + refreshToken: Token(value: "REFRESH") + ) } storeLogin: { login in storeTokenExp.fulfill() XCTAssertEqual(login.accessToken.value, "REFRESHED") + } clearLogin: { + XCTFail() } - let config = Authenticator.Configuration(appCredentials: Self.mockCredentials, - loginStorage: storage, - tokenHandling: tokenHandling, - userAuthenticator: Self.disabledUserAuthenticator) + let config = Authenticator.Configuration( + appCredentials: Self.mockCredentials, + loginStorage: storage, + tokenHandling: tokenHandling, + userAuthenticator: Self.disabledUserAuthenticator + ) let auth = Authenticator(config: config, urlLoader: mockLoader) let (_, _) = try await auth.response(for: URLRequest(url: URL(string: "https://example.com")!)) - await fulfillment(of: [retrieveTokenExp, refreshExp, storeTokenExp, authedLoadExp], timeout: 1.0, enforceOrder: true) + await fulfillment( + of: [retrieveTokenExp, refreshExp, storeTokenExp, authedLoadExp], timeout: 1.0, + enforceOrder: true) + } + + @MainActor + func testExpiredTokenRefreshFailing() async throws { + let mockLoader: URLResponseProvider = { request in + // We should never load the resource, since we failed to refresh the session: + XCTFail() + + return MockURLResponseProvider.dummyResponse + } + + let refreshExp = expectation(description: "refresh") + let refreshProvider: TokenHandling.RefreshProvider = { login, _, _ in + XCTAssertEqual(login.accessToken.value, "EXPIRED") + XCTAssertEqual(login.refreshToken?.value, "REFRESH") + + refreshExp.fulfill() + + // Fail the refresh attempt, e.g., the refresh token has expired: + throw AuthenticatorError.refreshNotPossible + } + + let tokenHandling = TokenHandling( + authorizationURLProvider: Self.disabledAuthorizationURLProvider, + loginProvider: Self.disabledLoginProvider, + refreshProvider: refreshProvider, + responseStatusProvider: TokenHandling.allResponsesValid + ) + + let retrieveTokenExp = expectation(description: "get token") + let clearTokenExp = expectation(description: "clear token") + + let storage = LoginStorage { + retrieveTokenExp.fulfill() + + return Login( + accessToken: Token(value: "EXPIRED", expiry: .distantPast), + refreshToken: Token(value: "REFRESH") + ) + } storeLogin: { login in + XCTFail() + } clearLogin: { + clearTokenExp.fulfill() + } + + let config = Authenticator.Configuration( + appCredentials: Self.mockCredentials, + loginStorage: storage, + tokenHandling: tokenHandling, + userAuthenticator: Self.disabledUserAuthenticator + ) + + let auth = Authenticator(config: config, urlLoader: mockLoader) + + do { + let (_, _) = try await auth.response( + for: URLRequest(url: URL(string: "https://example.com")!)) + + XCTFail() + } catch AuthenticatorError.refreshNotPossible { + // we expect this error to be thrown + } catch { + XCTFail() + } + + await fulfillment( + of: [retrieveTokenExp, refreshExp, clearTokenExp], timeout: 1.0, enforceOrder: true) } @MainActor @@ -230,9 +316,11 @@ final class AuthenticatorTests: XCTestCase { return Login(token: "TOKEN") } - let tokenHandling = TokenHandling(authorizationURLProvider: urlProvider, - loginProvider: loginProvider, - responseStatusProvider: TokenHandling.allResponsesValid) + let tokenHandling = TokenHandling( + authorizationURLProvider: urlProvider, + loginProvider: loginProvider, + responseStatusProvider: TokenHandling.allResponsesValid + ) let userAuthExp = expectation(description: "user auth") let mockUserAuthenticator: Authenticator.UserAuthenticator = { url, scheme in @@ -241,10 +329,12 @@ final class AuthenticatorTests: XCTestCase { return URL(string: "my://login")! } - let config = Authenticator.Configuration(appCredentials: Self.mockCredentials, - tokenHandling: tokenHandling, - mode: .manualOnly, - userAuthenticator: mockUserAuthenticator) + let config = Authenticator.Configuration( + appCredentials: Self.mockCredentials, + tokenHandling: tokenHandling, + mode: .manualOnly, + userAuthenticator: mockUserAuthenticator + ) let loadExp = expectation(description: "load url") let mockLoader: URLResponseProvider = { request in @@ -256,7 +346,9 @@ final class AuthenticatorTests: XCTestCase { let auth = Authenticator(config: config, urlLoader: mockLoader) do { - let (_, _) = try await auth.response(for: URLRequest(url: URL(string: "https://example.com")!)) + let (_, _) = try await auth.response( + for: URLRequest(url: URL(string: "https://example.com")!) + ) XCTFail() } catch AuthenticatorError.manualAuthenticationRequired { @@ -269,45 +361,47 @@ final class AuthenticatorTests: XCTestCase { try await auth.authenticate() let (_, _) = try await auth.response(for: URLRequest(url: URL(string: "https://example.com")!)) - + await fulfillment(of: [userAuthExp, loadExp], timeout: 1.0, enforceOrder: true) } @MainActor - func testManualAuthenticationWithSuccessResult() async throws { - let urlProvider: TokenHandling.AuthorizationURLProvider = { params in + func testManualAuthenticationWithSuccessResult() async throws { + let urlProvider: TokenHandling.AuthorizationURLProvider = { params in return URL(string: "my://auth?client_id=\(params.credentials.clientId)")! - } + } - let loginProvider: TokenHandling.LoginProvider = { params in + let loginProvider: TokenHandling.LoginProvider = { params in XCTAssertEqual(params.redirectURL, URL(string: "my://login")!) - return Login(token: "TOKEN") - } - - let tokenHandling = TokenHandling(authorizationURLProvider: urlProvider, - loginProvider: loginProvider, - responseStatusProvider: TokenHandling.allResponsesValid) - - let userAuthExp = expectation(description: "user auth") - let mockUserAuthenticator: Authenticator.UserAuthenticator = { url, scheme in - userAuthExp.fulfill() - - return URL(string: "my://login")! - } - - // This is the callback to obtain authentication results - var authenticatedLogin: Login? - let authenticationCallback: Authenticator.AuthenticationStatusHandler = { @MainActor result in - switch result { - case .failure(_): - XCTFail() - case .success(let login): - authenticatedLogin = login - } - } - - // Configure Authenticator with result callback + return Login(token: "TOKEN") + } + + let tokenHandling = TokenHandling( + authorizationURLProvider: urlProvider, + loginProvider: loginProvider, + responseStatusProvider: TokenHandling.allResponsesValid + ) + + let userAuthExp = expectation(description: "user auth") + let mockUserAuthenticator: Authenticator.UserAuthenticator = { url, scheme in + userAuthExp.fulfill() + + return URL(string: "my://login")! + } + + // This is the callback to obtain authentication results + var authenticatedLogin: Login? + let authenticationCallback: Authenticator.AuthenticationStatusHandler = { @MainActor result in + switch result { + case .failure(_): + XCTFail() + case .success(let login): + authenticatedLogin = login + } + } + + // Configure Authenticator with result callback let config = Authenticator.Configuration( appCredentials: Self.mockCredentials, tokenHandling: tokenHandling, @@ -316,57 +410,59 @@ final class AuthenticatorTests: XCTestCase { authenticationStatusHandler: authenticationCallback ) - let loadExp = expectation(description: "load url") - let mockLoader: URLResponseProvider = { request in - loadExp.fulfill() + let loadExp = expectation(description: "load url") + let mockLoader: URLResponseProvider = { request in + loadExp.fulfill() + + return MockURLResponseProvider.dummyResponse + } - return MockURLResponseProvider.dummyResponse - } + let auth = Authenticator(config: config, urlLoader: mockLoader) + // Explicitly authenticate and grab Login information after + try await auth.authenticate() - let auth = Authenticator(config: config, urlLoader: mockLoader) - // Explicitly authenticate and grab Login information after - try await auth.authenticate() - - // Ensure our authenticatedLogin objet is available and contains the proper Token - XCTAssertNotNil(authenticatedLogin) - XCTAssertEqual(authenticatedLogin!, Login(token:"TOKEN")) + // Ensure our authenticatedLogin objet is available and contains the proper Token + XCTAssertNotNil(authenticatedLogin) + XCTAssertEqual(authenticatedLogin!, Login(token: "TOKEN")) + + let (_, _) = try await auth.response(for: URLRequest(url: URL(string: "https://example.com")!)) - let (_, _) = try await auth.response(for: URLRequest(url: URL(string: "https://example.com")!)) - await fulfillment(of: [userAuthExp, loadExp], timeout: 1.0, enforceOrder: true) - } + } - // Test AuthenticationResultHandler with a failed UserAuthenticator + // Test AuthenticationResultHandler with a failed UserAuthenticator @MainActor - func testManualAuthenticationWithFailedResult() async throws { - let urlProvider: TokenHandling.AuthorizationURLProvider = { params in + func testManualAuthenticationWithFailedResult() async throws { + let urlProvider: TokenHandling.AuthorizationURLProvider = { params in return URL(string: "my://auth?client_id=\(params.credentials.clientId)")! - } + } - let loginProvider: TokenHandling.LoginProvider = { params in + let loginProvider: TokenHandling.LoginProvider = { params in XCTAssertEqual(params.redirectURL, URL(string: "my://login")!) - return Login(token: "TOKEN") - } - - let tokenHandling = TokenHandling(authorizationURLProvider: urlProvider, - loginProvider: loginProvider, - responseStatusProvider: TokenHandling.allResponsesValid) - - // This is the callback to obtain authentication results - var authenticatedLogin: Login? - let failureAuth = expectation(description: "auth failure") - let authenticationCallback: Authenticator.AuthenticationStatusHandler = { @MainActor result in - switch result { - case .failure(_): - failureAuth.fulfill() - authenticatedLogin = nil - case .success(_): - XCTFail() - } - } - - // Configure Authenticator with result callback + return Login(token: "TOKEN") + } + + let tokenHandling = TokenHandling( + authorizationURLProvider: urlProvider, + loginProvider: loginProvider, + responseStatusProvider: TokenHandling.allResponsesValid + ) + + // This is the callback to obtain authentication results + var authenticatedLogin: Login? + let failureAuth = expectation(description: "auth failure") + let authenticationCallback: Authenticator.AuthenticationStatusHandler = { @MainActor result in + switch result { + case .failure(_): + failureAuth.fulfill() + authenticatedLogin = nil + case .success(_): + XCTFail() + } + } + + // Configure Authenticator with result callback let config = Authenticator.Configuration( appCredentials: Self.mockCredentials, tokenHandling: tokenHandling, @@ -374,24 +470,22 @@ final class AuthenticatorTests: XCTestCase { userAuthenticator: Authenticator.failingUserAuthenticator, authenticationStatusHandler: authenticationCallback ) - - let auth = Authenticator(config: config, urlLoader: nil) - do { - // Explicitly authenticate and grab Login information after - try await auth.authenticate() - - // Ensure our authenticatedLogin objet is *not* available - XCTAssertNil(authenticatedLogin) - } - catch let error as AuthenticatorError { - XCTAssertEqual(error, AuthenticatorError.failingAuthenticatorUsed) - } - catch { - throw error - } + + let auth = Authenticator(config: config, urlLoader: nil) + do { + // Explicitly authenticate and grab Login information after + try await auth.authenticate() + + // Ensure our authenticatedLogin objet is *not* available + XCTAssertNil(authenticatedLogin) + } catch let error as AuthenticatorError { + XCTAssertEqual(error, AuthenticatorError.failingAuthenticatorUsed) + } catch { + throw error + } await fulfillment(of: [failureAuth], timeout: 1.0, enforceOrder: true) - } + } func testUnauthorizedRequestRefreshes() async throws { let requestedURL = URL(string: "https://example.com")! @@ -400,30 +494,46 @@ final class AuthenticatorTests: XCTestCase { let mockData = "hello".data(using: .utf8)! mockLoader.responses = [ - .success((Data(), HTTPURLResponse(url: requestedURL, statusCode: 401, httpVersion: nil, headerFields: nil)!)), - .success((mockData, HTTPURLResponse(url: requestedURL, statusCode: 200, httpVersion: nil, headerFields: nil)!)), + .success( + ( + Data(), + HTTPURLResponse(url: requestedURL, statusCode: 401, httpVersion: nil, headerFields: nil)! + )), + .success( + ( + mockData, + HTTPURLResponse(url: requestedURL, statusCode: 200, httpVersion: nil, headerFields: nil)! + )), ] let refreshProvider: TokenHandling.RefreshProvider = { login, _, _ in return Login(token: "REFRESHED") } - let tokenHandling = TokenHandling(authorizationURLProvider: Self.disabledAuthorizationURLProvider, - loginProvider: Self.disabledLoginProvider, - refreshProvider: refreshProvider) + let tokenHandling = TokenHandling( + authorizationURLProvider: Self.disabledAuthorizationURLProvider, + loginProvider: Self.disabledLoginProvider, + refreshProvider: refreshProvider + ) let storage = LoginStorage { // ensure we actually try this one - return Login(accessToken: Token(value: "EXPIRED", expiry: .distantFuture), - refreshToken: Token(value: "REFRESH")) + return Login( + accessToken: Token(value: "EXPIRED", expiry: .distantFuture), + refreshToken: Token(value: "REFRESH") + ) } storeLogin: { login in XCTAssertEqual(login.accessToken.value, "REFRESHED") + } clearLogin: { + XCTFail() } - let config = Authenticator.Configuration(appCredentials: Self.mockCredentials, - loginStorage: storage, - tokenHandling: tokenHandling, - userAuthenticator: Self.disabledUserAuthenticator) + let config = Authenticator.Configuration( + appCredentials: Self.mockCredentials, + loginStorage: storage, + tokenHandling: tokenHandling, + userAuthenticator: Self.disabledUserAuthenticator + ) let auth = Authenticator(config: config, urlLoader: mockLoader.responseProvider) @@ -445,81 +555,97 @@ final class AuthenticatorTests: XCTestCase { @available(macOS 13.0, macCatalyst 16.0, iOS 16.0, watchOS 9.0, tvOS 16.0, *) @MainActor - func testTokenExpiredAfterUseRefresh() async throws { + func testTokenExpiredAfterUseRefresh() async throws { var sentRequests: [URLRequest] = [] - - let mockLoader: URLResponseProvider = { @MainActor request in + let mockLoader: URLResponseProvider = { @MainActor request in sentRequests.append(request) - return MockURLResponseProvider.dummyResponse - } + return MockURLResponseProvider.dummyResponse + } - var refreshedLogins: [Login] = [] - let refreshProvider: TokenHandling.RefreshProvider = { @MainActor login, _, _ in + var refreshedLogins: [Login] = [] + let refreshProvider: TokenHandling.RefreshProvider = { @MainActor login, _, _ in refreshedLogins.append(login) - return Login(token: "REFRESHED") - } - - let tokenHandling = TokenHandling( - authorizationURLProvider: Self.disabledAuthorizationURLProvider, - loginProvider: Self.disabledLoginProvider, - refreshProvider: refreshProvider, - responseStatusProvider: TokenHandling.allResponsesValid - ) - - let storedLogin = Login( - accessToken: Token(value: "EXPIRE SOON", expiry: Date().addingTimeInterval(1)), - refreshToken: Token(value: "REFRESH") - ) - var loadLoginCount = 0 - var savedLogins: [Login] = [] + return Login(token: "REFRESHED") + } + + let tokenHandling = TokenHandling( + authorizationURLProvider: Self.disabledAuthorizationURLProvider, + loginProvider: Self.disabledLoginProvider, + refreshProvider: refreshProvider, + responseStatusProvider: TokenHandling.allResponsesValid + ) + + let storedLogin = Login( + accessToken: Token(value: "EXPIRE SOON", expiry: Date().addingTimeInterval(1)), + refreshToken: Token(value: "REFRESH") + ) + + var loadLoginCount = 0 + var savedLogins: [Login] = [] let storage = LoginStorage { @MainActor in loadLoginCount += 1 - return storedLogin - } storeLogin: { @MainActor login in - savedLogins.append(login) - } + return storedLogin + } storeLogin: { @MainActor login in + savedLogins.append(login) + } clearLogin: { + XCTFail() + } - let config = Authenticator.Configuration(appCredentials: Self.mockCredentials, - loginStorage: storage, - tokenHandling: tokenHandling, - userAuthenticator: Self.disabledUserAuthenticator) + let config = Authenticator.Configuration( + appCredentials: Self.mockCredentials, + loginStorage: storage, + tokenHandling: tokenHandling, + userAuthenticator: Self.disabledUserAuthenticator + ) - let auth = Authenticator(config: config, urlLoader: mockLoader) + let auth = Authenticator(config: config, urlLoader: mockLoader) - let (_, _) = try await auth.response(for: URLRequest(url: URL(string: "https://example.com")!)) + let (_, _) = try await auth.response(for: URLRequest(url: URL(string: "https://example.com")!)) let sentRequestsOne = sentRequests - XCTAssertEqual(sentRequestsOne.count, 1, "First request should be sent") - XCTAssertEqual(sentRequestsOne.first?.value(forHTTPHeaderField: "Authorization"), "Bearer EXPIRE SOON", "Non expired token should be used for first request") - XCTAssertTrue(refreshedLogins.isEmpty, "Token should not be refreshed after first request") - XCTAssertEqual(loadLoginCount, 1, "Login should be loaded from storage once") - XCTAssertTrue(savedLogins.isEmpty, "Login storage should not be updated after first request") + XCTAssertEqual(sentRequestsOne.count, 1, "First request should be sent") + XCTAssertEqual( + sentRequestsOne.first?.value(forHTTPHeaderField: "Authorization"), "Bearer EXPIRE SOON", + "Non expired token should be used for first request") + XCTAssertTrue(refreshedLogins.isEmpty, "Token should not be refreshed after first request") + XCTAssertEqual(loadLoginCount, 1, "Login should be loaded from storage once") + XCTAssertTrue(savedLogins.isEmpty, "Login storage should not be updated after first request") - // Let the token expire + // Let the token expire try await Task.sleep(for: .seconds(1)) - let (_, _) = try await auth.response(for: URLRequest(url: URL(string: "https://example.com")!)) + let (_, _) = try await auth.response(for: URLRequest(url: URL(string: "https://example.com")!)) let sentRequestsTwo = sentRequests - XCTAssertEqual(refreshedLogins.count, 1, "Token should be refreshed") - XCTAssertEqual(refreshedLogins.first?.accessToken.value, "EXPIRE SOON", "Expired token should be passed to refresh call") - XCTAssertEqual(refreshedLogins.first?.refreshToken?.value, "REFRESH", "Refresh token should be passed to refresh call") - XCTAssertEqual(loadLoginCount, 2, "New login should be loaded from storage") - XCTAssertEqual(sentRequestsTwo.count, 2, "Second request should be sent") - let secondRequest = sentRequestsTwo.dropFirst().first - XCTAssertEqual(secondRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer REFRESHED", "Refreshed token should be used for second request") - XCTAssertEqual(savedLogins.first?.accessToken.value, "REFRESHED", "Refreshed token should be saved to storage") + XCTAssertEqual(refreshedLogins.count, 1, "Token should be refreshed") + XCTAssertEqual( + refreshedLogins.first?.accessToken.value, "EXPIRE SOON", + "Expired token should be passed to refresh call") + XCTAssertEqual( + refreshedLogins.first?.refreshToken?.value, "REFRESH", + "Refresh token should be passed to refresh call") + XCTAssertEqual(loadLoginCount, 2, "New login should be loaded from storage") + XCTAssertEqual(sentRequestsTwo.count, 2, "Second request should be sent") + let secondRequest = sentRequestsTwo.dropFirst().first + XCTAssertEqual( + secondRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer REFRESHED", + "Refreshed token should be used for second request") + XCTAssertEqual( + savedLogins.first?.accessToken.value, "REFRESHED", + "Refreshed token should be saved to storage") - let (_, _) = try await auth.response(for: URLRequest(url: URL(string: "https://example.com")!)) + let (_, _) = try await auth.response(for: URLRequest(url: URL(string: "https://example.com")!)) let sentRequestsThree = sentRequests - XCTAssertEqual(refreshedLogins.count, 1, "No additional refreshes should happen") - XCTAssertEqual(loadLoginCount, 2, "No additional login loads should happen") - XCTAssertEqual(sentRequestsThree.count, 3, "Third request should be sent") - let thirdRequest = sentRequestsThree.dropFirst(2).first - XCTAssertEqual(thirdRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer REFRESHED", "Refreshed token should be used for third request") - XCTAssertEqual(savedLogins.count, 1, "No additional logins should be saved to storage") - } + XCTAssertEqual(refreshedLogins.count, 1, "No additional refreshes should happen") + XCTAssertEqual(loadLoginCount, 2, "No additional login loads should happen") + XCTAssertEqual(sentRequestsThree.count, 3, "Third request should be sent") + let thirdRequest = sentRequestsThree.dropFirst(2).first + XCTAssertEqual( + thirdRequest?.value(forHTTPHeaderField: "Authorization"), "Bearer REFRESHED", + "Refreshed token should be used for third request") + XCTAssertEqual(savedLogins.count, 1, "No additional logins should be saved to storage") + } } diff --git a/Tests/OAuthenticatorTests/GoogleTests.swift b/Tests/OAuthenticatorTests/GoogleTests.swift index 47ad42d..2d527f9 100644 --- a/Tests/OAuthenticatorTests/GoogleTests.swift +++ b/Tests/OAuthenticatorTests/GoogleTests.swift @@ -1,47 +1,51 @@ import XCTest -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif @testable import OAuthenticator +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + final class GoogleTests: XCTestCase { - func testOAuthResponseDecode() throws { - let content = """ -{"access_token": "abc", "expires_in": 3, "refresh_token": "def", "scope": "https://gmail.scope", "token_type": "bearer"} -""" - let data = try XCTUnwrap(content.data(using: .utf8)) - let response = try JSONDecoder().decode(GoogleAPI.OAuthResponse.self, from: data) - - XCTAssertEqual(response.accessToken, "abc") - - let login = response.login - XCTAssertEqual(login.accessToken.value, "abc") - - // Sleep until access token expires - sleep(5) - XCTAssert(!login.accessToken.valid) - } - - func testSuppliedParameters() async throws { - let googleParameters = GoogleAPI.GoogleAPIParameters(includeGrantedScopes: true, loginHint: "john@doe.com") - - XCTAssertNotNil(googleParameters.loginHint) - XCTAssertTrue(googleParameters.includeGrantedScopes) - - let callback = URL(string: "callback://google_api") - XCTAssertNotNil(callback) - - let creds = AppCredentials(clientId: "client_id", clientPassword: "client_pwd", scopes: ["scope1", "scope2"], callbackURL: callback!) - let tokenHandling = GoogleAPI.googleAPITokenHandling(with: googleParameters) - let config = Authenticator.Configuration( - appCredentials: creds, - tokenHandling: tokenHandling, - userAuthenticator: Authenticator.failingUserAuthenticator - ) + func testOAuthResponseDecode() throws { + let content = """ + {"access_token": "abc", "expires_in": 3, "refresh_token": "def", "scope": "https://gmail.scope", "token_type": "bearer"} + """ + let data = try XCTUnwrap(content.data(using: .utf8)) + let response = try JSONDecoder().decode(GoogleAPI.OAuthResponse.self, from: data) + + XCTAssertEqual(response.accessToken, "abc") + + let login = response.login + XCTAssertEqual(login.accessToken.value, "abc") + + // Sleep until access token expires + sleep(5) + XCTAssert(!login.accessToken.valid) + } + + func testSuppliedParameters() async throws { + let googleParameters = GoogleAPI.GoogleAPIParameters( + includeGrantedScopes: true, loginHint: "john@doe.com") + + XCTAssertNotNil(googleParameters.loginHint) + XCTAssertTrue(googleParameters.includeGrantedScopes) + + let callback = URL(string: "callback://google_api") + XCTAssertNotNil(callback) + + let creds = AppCredentials( + clientId: "client_id", clientPassword: "client_pwd", scopes: ["scope1", "scope2"], + callbackURL: callback!) + let tokenHandling = GoogleAPI.googleAPITokenHandling(with: googleParameters) + let config = Authenticator.Configuration( + appCredentials: creds, + tokenHandling: tokenHandling, + userAuthenticator: Authenticator.failingUserAuthenticator + ) let provider: URLResponseProvider = { _ in throw AuthenticatorError.httpResponseExpected } - // Validate URL is properly constructed + // Validate URL is properly constructed let params = TokenHandling.AuthorizationURLParameters( credentials: creds, pcke: nil, @@ -49,39 +53,42 @@ final class GoogleTests: XCTestCase { stateToken: "unused", responseProvider: provider ) - let googleURLProvider = try await config.tokenHandling.authorizationURLProvider(params) - - let urlComponent = URLComponents(url: googleURLProvider, resolvingAgainstBaseURL: true) - XCTAssertNotNil(urlComponent) - XCTAssertEqual(urlComponent!.scheme, GoogleAPI.scheme) - - // Validate query items inclusion and value - XCTAssertNotNil(urlComponent!.queryItems) - XCTAssertTrue(urlComponent!.queryItems!.contains(where: { $0.name == GoogleAPI.includeGrantedScopeKey })) - XCTAssertTrue(urlComponent!.queryItems!.contains(where: { $0.name == GoogleAPI.loginHint })) - XCTAssertTrue(urlComponent!.queryItems!.contains(where: { $0.value == String(true) })) - XCTAssertTrue(urlComponent!.queryItems!.contains(where: { $0.value == "john@doe.com" })) - } - - func testDefaultParameters() async throws { - let googleParameters = GoogleAPI.GoogleAPIParameters() - - XCTAssertNil(googleParameters.loginHint) - XCTAssertTrue(googleParameters.includeGrantedScopes) - - let callback = URL(string: "callback://google_api") - XCTAssertNotNil(callback) - - let creds = AppCredentials(clientId: "client_id", clientPassword: "client_pwd", scopes: ["scope1", "scope2"], callbackURL: callback!) - let tokenHandling = GoogleAPI.googleAPITokenHandling(with: googleParameters) - let config = Authenticator.Configuration( - appCredentials: creds, - tokenHandling: tokenHandling, - userAuthenticator: Authenticator.failingUserAuthenticator - ) + let googleURLProvider = try await config.tokenHandling.authorizationURLProvider(params) + + let urlComponent = URLComponents(url: googleURLProvider, resolvingAgainstBaseURL: true) + XCTAssertNotNil(urlComponent) + XCTAssertEqual(urlComponent!.scheme, GoogleAPI.scheme) + + // Validate query items inclusion and value + XCTAssertNotNil(urlComponent!.queryItems) + XCTAssertTrue( + urlComponent!.queryItems!.contains(where: { $0.name == GoogleAPI.includeGrantedScopeKey })) + XCTAssertTrue(urlComponent!.queryItems!.contains(where: { $0.name == GoogleAPI.loginHint })) + XCTAssertTrue(urlComponent!.queryItems!.contains(where: { $0.value == String(true) })) + XCTAssertTrue(urlComponent!.queryItems!.contains(where: { $0.value == "john@doe.com" })) + } + + func testDefaultParameters() async throws { + let googleParameters = GoogleAPI.GoogleAPIParameters() + + XCTAssertNil(googleParameters.loginHint) + XCTAssertTrue(googleParameters.includeGrantedScopes) + + let callback = URL(string: "callback://google_api") + XCTAssertNotNil(callback) + + let creds = AppCredentials( + clientId: "client_id", clientPassword: "client_pwd", scopes: ["scope1", "scope2"], + callbackURL: callback!) + let tokenHandling = GoogleAPI.googleAPITokenHandling(with: googleParameters) + let config = Authenticator.Configuration( + appCredentials: creds, + tokenHandling: tokenHandling, + userAuthenticator: Authenticator.failingUserAuthenticator + ) let provider: URLResponseProvider = { _ in throw AuthenticatorError.httpResponseExpected } - // Validate URL is properly constructed + // Validate URL is properly constructed let params = TokenHandling.AuthorizationURLParameters( credentials: creds, pcke: nil, @@ -89,17 +96,17 @@ final class GoogleTests: XCTestCase { stateToken: "unused", responseProvider: provider ) - let googleURLProvider = try await config.tokenHandling.authorizationURLProvider(params) - - let urlComponent = URLComponents(url: googleURLProvider, resolvingAgainstBaseURL: true) - XCTAssertNotNil(urlComponent) - XCTAssertEqual(urlComponent!.scheme, GoogleAPI.scheme) + let googleURLProvider = try await config.tokenHandling.authorizationURLProvider(params) - // Validate query items inclusion and value - XCTAssertNotNil(urlComponent!.queryItems) - XCTAssertTrue(urlComponent!.queryItems!.contains(where: { $0.name == GoogleAPI.includeGrantedScopeKey })) - XCTAssertFalse(urlComponent!.queryItems!.contains(where: { $0.name == GoogleAPI.loginHint })) - XCTAssertTrue(urlComponent!.queryItems!.contains(where: { $0.value == String(true) })) - } + let urlComponent = URLComponents(url: googleURLProvider, resolvingAgainstBaseURL: true) + XCTAssertNotNil(urlComponent) + XCTAssertEqual(urlComponent!.scheme, GoogleAPI.scheme) + // Validate query items inclusion and value + XCTAssertNotNil(urlComponent!.queryItems) + XCTAssertTrue( + urlComponent!.queryItems!.contains(where: { $0.name == GoogleAPI.includeGrantedScopeKey })) + XCTAssertFalse(urlComponent!.queryItems!.contains(where: { $0.name == GoogleAPI.loginHint })) + XCTAssertTrue(urlComponent!.queryItems!.contains(where: { $0.value == String(true) })) + } } diff --git a/Tests/OAuthenticatorTests/PKCETests.swift b/Tests/OAuthenticatorTests/PKCETests.swift index 91a05d0..bc28e55 100644 --- a/Tests/OAuthenticatorTests/PKCETests.swift +++ b/Tests/OAuthenticatorTests/PKCETests.swift @@ -1,26 +1,25 @@ -import Testing - import OAuthenticator +import Testing struct PKCETest { - @Test func customHashFunction() throws { + @Test func customHashFunction() throws { let pkce = PKCEVerifier(hash: "abc") { input in "abc" + input } - + let challenge = pkce.challenge - + #expect(challenge.method == "abc") #expect(challenge.value == "abc" + pkce.verifier) - } - -#if canImport(CryptoKit) - @Test func defaultHashFunction() throws { - let pkce = PKCEVerifier() - - let challenge = pkce.challenge - - #expect(challenge.method == "S256") } -#endif + + #if canImport(CryptoKit) + @Test func defaultHashFunction() throws { + let pkce = PKCEVerifier() + + let challenge = pkce.challenge + + #expect(challenge.method == "S256") + } + #endif }