diff --git a/README.md b/README.md index 53a6bdc..e5b4473 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ There's also built-in support for services to streamline integration: - GitHub - Mastodon +- Google API If you'd like to contribute a similar thing for another service, please open a PR! @@ -134,6 +135,62 @@ let request = URLRequest(url: url) let (data, response) = try await authenticator.response(for: request) ``` +### Google API +OAuthenticator also comes with pre-packaged configuration for Google APIs (access to Google Drive, Google People, Google Calendar, ...) according to the application requested scopes. + +More info about those at [Google Workspace](https://developers.google.com/workspace). The Google OAuth process is described in [Google Identity](https://developers.google.com/identity) + +Integration example below: +```swift +// Configuration for Google API + +// Define how to store and retrieve the Google Access and Refresh Token +let storage = LoginStorage { + // Fetch token and return them as a Login object + return LoginFromSecureStorage(...) +} storeLogin: { login in + // Store access and refresh token in Secure storage + MySecureStorage(login: login) +} + +let appCreds = AppCredentials(clientId: googleClientApp.client_id, + clientPassword: googleClientApp.client_secret, + scopes: googleClientApp.scopes, + callbackURL: googleClient.callbackURL) + +let config = Authenticator.Configuration(appCredentials: Self.oceanCredentials, + loginStorage: storage, + tokenHandling: tokenHandling, + mode: .automatic) + +let authenticator = Authenticator(config: config) + +// If you just want the user to authenticate his account and get the tokens, do 1: +// If you want to access a secure Google endpoint with the proper access token, do 2: + +// 1: Only Authenticate +try await authenticator.authenticate() + +// 2: Access secure Google endpoint (ie: Google Drive: upload a file) with access token +var urlBuilder = URLComponents() +urlBuilder.scheme = GoogleAPI.scheme // https: +urlBuilder.host = GoogleAPI.host // www.googleapis.com +urlBuilder.path = GoogleAPI.path // /upload/drive/v3/files +urlBuilder.queryItems = [ + URLQueryItem(name: GoogleDrive.uploadType, value: "media"), +] + +guard let url = urlBuilder.url else { + throw AuthenticatorError.missingScheme +} + +let request = URLRequest(url: url) +request.httpMethod = "POST" +request.httpBody = ... // File data to upload + +let (data, response) = try await authenticator.response(for: request) +``` + ## Contributing and Collaboration I prefer collaboration, and would love to find ways to work together if you have a similar project. diff --git a/Sources/OAuthenticator/Authenticator.swift b/Sources/OAuthenticator/Authenticator.swift index 3faf48c..223b3a6 100644 --- a/Sources/OAuthenticator/Authenticator.swift +++ b/Sources/OAuthenticator/Authenticator.swift @@ -12,6 +12,8 @@ public enum AuthenticatorError: Error { case httpResponseExpected case unauthorizedRefreshFailed case missingRedirectURI + case missingRefreshToken + case missingScope case failingAuthenticatorUsed } diff --git a/Sources/OAuthenticator/Services/GoogleAPI.swift b/Sources/OAuthenticator/Services/GoogleAPI.swift new file mode 100644 index 0000000..e97d51f --- /dev/null +++ b/Sources/OAuthenticator/Services/GoogleAPI.swift @@ -0,0 +1,206 @@ +import Foundation +import OSLog + +public struct GoogleAPI { + // Define scheme, host and query item names + public static let scheme: String = "https" + static let authorizeHost: String = "accounts.google.com" + static let authorizePath: String = "/o/oauth2/auth" + 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 scopeKey: String = "scope" + static let includeGrantedScopeKey: String = "include_granted_scopes" + + static let codeKey: String = "code" + static let refreshTokenKey: String = "refresh_token" + + static let grantTypeKey: String = "grant_type" + static let grantTypeAuthorizationCode: String = "authorization_code" + 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 scope: String + let tokenType: String + let expiresIn: Int // Access Token validity in seconds + + enum CodingKeys: String, CodingKey { + case accessToken = "access_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) + } + + return login + } + } + + public static func googleAPITokenHandling(with parameters: AppCredentials) -> TokenHandling { + TokenHandling(authorizationURLProvider: Self.authorizationURLProvider(with: parameters), + loginProvider: Self.loginProvider(with: parameters), + refreshProvider: Self.refreshProvider(with: parameters)) + } + + /// 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: AppCredentials) -> TokenHandling.AuthorizationURLProvider { + return { credentials in + var urlBuilder = URLComponents() + + urlBuilder.scheme = GoogleAPI.scheme + urlBuilder.host = GoogleAPI.authorizeHost + urlBuilder.path = GoogleAPI.authorizePath + urlBuilder.queryItems = [ + URLQueryItem(name: GoogleAPI.clientIDKey, value: credentials.clientId), + 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: "true") // Will include previously granted scoped for this user + ] + + guard let url = urlBuilder.url else { + throw AuthenticatorError.missingAuthorizationURL + } + + return url + } + } + + /// 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 + let grantedScope = try url.grantedScope + + // It's possible the user will decide to grant less scopes than requested by the app. + // We should have a mechanism to tell us which scopes were authorized and decide if we + // can continue forward or not. + let grantedScopeItems = grantedScope.components(separatedBy: " ") + if appCredentials.scopes.count > grantedScopeItems.count { + // For now, just log that less scope was authorized + 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 + var urlBuilder = URLComponents() + urlBuilder.scheme = GoogleAPI.scheme + urlBuilder.host = GoogleAPI.tokenHost + urlBuilder.path = GoogleAPI.tokenPath + urlBuilder.queryItems = [ + URLQueryItem(name: GoogleAPI.grantTypeKey, value: GoogleAPI.grantTypeAuthorizationCode), + URLQueryItem(name: GoogleAPI.clientIDKey, value: appCredentials.clientId), + URLQueryItem(name: GoogleAPI.redirectURIKey, value: appCredentials.callbackURL.absoluteString), + 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)) + } + + 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 loginProvider(with parameters: AppCredentials) -> TokenHandling.LoginProvider { + return { url, appCredentials, tokenURL, urlLoader in + let request = try authenticationRequest(url: url, appCredentials: appCredentials) + + let (data, _) = try await urlLoader(request) + + do { + + let jsonString = String(data: data, encoding: .utf8) ?? "" + os_log(.debug, "%s", jsonString) + + let response = try JSONDecoder().decode(GoogleAPI.OAuthResponse.self, from: data) + return response.login + } + catch let decodingError as DecodingError { + os_log(.fault, "Reponse from AuthenticationProvider is not conformed to provided response format. %s", decodingError.failureReason ?? decodingError.localizedDescription) + throw decodingError + } + } + } + + /// 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(with parameters: AppCredentials) -> TokenHandling.RefreshProvider { + return { login, appCredentials, urlLoader in + let request = try authenticationRefreshRequest(login: login, appCredentials: appCredentials) + let (data, _) = try await urlLoader(request) + + let jsonString = String(data: data, encoding: .utf8) ?? "" + os_log(.debug, "[Authentication Refresh JSON Result] %s", jsonString) + + do { + let response = try JSONDecoder().decode(GoogleAPI.OAuthResponse.self, from: data) + return response.login + } + catch let decodingError as DecodingError { + os_log(.fault, "Non-conformant response from AuthenticationProvider: %s", decodingError.failureReason ?? decodingError.localizedDescription) + throw decodingError + } + } + } +} diff --git a/Sources/OAuthenticator/URL+Code.swift b/Sources/OAuthenticator/URL+QueryParams.swift similarity index 56% rename from Sources/OAuthenticator/URL+Code.swift rename to Sources/OAuthenticator/URL+QueryParams.swift index 60cf12e..01492d9 100644 --- a/Sources/OAuthenticator/URL+Code.swift +++ b/Sources/OAuthenticator/URL+QueryParams.swift @@ -16,4 +16,17 @@ public extension URL { return value } } + + /// + /// The scope query parameter contains the authorized scopes by the user + /// Typically used for the GoogleAPI + var grantedScope: String { + get throws { + guard let value = queryValues(named: "scope").first else { + throw AuthenticatorError.missingScope + } + + return value + } + } } diff --git a/Tests/OAuthenticatorTests/GoogleTests.swift b/Tests/OAuthenticatorTests/GoogleTests.swift new file mode 100644 index 0000000..153e3cd --- /dev/null +++ b/Tests/OAuthenticatorTests/GoogleTests.swift @@ -0,0 +1,35 @@ +// +// GoogleTests.swift +// +import XCTest +import OSLog +@testable import OAuthenticator + +final class GoogleTests: XCTestCase { + private func compatFulfillment(of expectations: [XCTestExpectation], timeout: TimeInterval, enforceOrder: Bool) async { +#if compiler(>=5.8) + await fulfillment(of: expectations, timeout: timeout, enforceOrder: enforceOrder) +#else + await Task { + wait(for: expectations, timeout: timeout, enforceOrder: enforceOrder) + }.value +#endif + } + + 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) + } +}