Skip to content

Commit

Permalink
Add support for GoogleAPI OAuth service (#9)
Browse files Browse the repository at this point in the history
* Add support for GoogleAPI OAuth service

* Update documentation (Readme) to include reference to Google API.

* Change visibility of accessor for Login information within the Authenticator

* Update documentation (Readme) to include reference to Google API.

* Revert back accessor visibility

* Rename GoogleDrive example to GoogleAPI
Remove unneeded `print` statements
Merge scope parameter getter into a renamed `URL+QueryParams` file
Remove unneeded commments in GoogleTests.swift
  • Loading branch information
martindufort authored Aug 22, 2023
1 parent 372e9ae commit a10d848
Show file tree
Hide file tree
Showing 5 changed files with 313 additions and 0 deletions.
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!

Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions Sources/OAuthenticator/Authenticator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ public enum AuthenticatorError: Error {
case httpResponseExpected
case unauthorizedRefreshFailed
case missingRedirectURI
case missingRefreshToken
case missingScope
case failingAuthenticatorUsed
}

Expand Down
206 changes: 206 additions & 0 deletions Sources/OAuthenticator/Services/GoogleAPI.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
35 changes: 35 additions & 0 deletions Tests/OAuthenticatorTests/GoogleTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}

0 comments on commit a10d848

Please sign in to comment.