Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Identity Platform accounts API support #58

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions IdentityToolkit/Sources/IdentityToolKitClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import Core
import Foundation
import AsyncHTTPClient
import NIO

public final class GoogleCloudIdentityToolkitClient {

public var account: AccountsAPI
var identityToolkitRequest: GoogleCloudIdentityToolkitRequest

/// Initialize a client for interacting with the Google Cloud Identity Toolkit API
/// - Parameter credentials: The Credentials to use when authenticating with the APIs
/// - Parameter httpClient: An `HTTPClient` used for making API requests
/// - Parameter eventLoop: The EventLoop used to perform the work on
/// - Parameter base: The base URL to use for the Identity Toolkit API
public init(credentials: GoogleCloudCredentialsConfiguration,
config: GoogleCloudIdentityToolkitConfiguration,
httpClient: HTTPClient,
eventLoop: EventLoop,
base: String = "https://identitytoolkit.googleapis.com") throws {

// Get OAuth token for authentication
let refreshableToken = OAuthCredentialLoader.getRefreshableToken(
credentials: credentials,
withConfig: config,
andClient: httpClient,
eventLoop: eventLoop
)

/// Set the projectId to use for this client. In order of priority:
/// - Environment Variable (GOOGLE_PROJECT_ID)
/// - Environment Variable (PROJECT_ID)
/// - Service Account's projectID
/// - `GoogleCloudDatastoreConfigurations` `project` property (optionally configured).
/// - `GoogleCloudCredentialsConfiguration's` `project` property (optionally configured).

guard let projectId = ProcessInfo.processInfo.environment["GOOGLE_PROJECT_ID"] ??
ProcessInfo.processInfo.environment["PROJECT_ID"] ??
(refreshableToken as? OAuthServiceAccount)?.credentials.projectId ??
config.project ?? credentials.project else {
throw GoogleCloudIdentityToolkitError.projectIdMissing
}

guard let apiKey = ProcessInfo.processInfo.environment["GOOGLE_API_KEY"] ??
ProcessInfo.processInfo.environment["API_KEY"] else {
throw GoogleCloudIdentityToolkitError.apiKeyMissing
}

identityToolkitRequest = GoogleCloudIdentityToolkitRequest(
httpClient: httpClient,
eventLoop: eventLoop,
oauth: refreshableToken,
project: projectId,
apiKey: apiKey
)

account = GoogleCloudIdentityToolkitAccountsAPI(request: identityToolkitRequest, endpoint: base)
}
}
37 changes: 37 additions & 0 deletions IdentityToolkit/Sources/IdentityToolkitConfig.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import Core

public struct GoogleCloudIdentityToolkitConfiguration: GoogleCloudAPIConfiguration {
public var scope: [GoogleCloudAPIScope]
public let serviceAccount: String
public let project: String?
public let subscription: String? = nil

public init(scope: [GoogleCloudIdentityToolkitScope], serviceAccount: String, project: String?) {
self.scope = scope
self.serviceAccount = serviceAccount
self.project = project
}

/// Create a new `IdentityToolkitConfiguration` with default scope and service account
public static func `default`() -> GoogleCloudIdentityToolkitConfiguration {
return GoogleCloudIdentityToolkitConfiguration(
scope: [.identityToolkit],
serviceAccount: "default",
project: nil
)
}
}

public enum GoogleCloudIdentityToolkitScope: GoogleCloudAPIScope {
case identityToolkit
case firebase
case cloudPlatform

public var value: String {
return switch self {
case .identityToolkit: "https://www.googleapis.com/auth/identitytoolkit"
case .firebase: "https://www.googleapis.com/auth/firebase"
case .cloudPlatform: "https://www.googleapis.com/auth/cloud-platform"
}
}
}
99 changes: 99 additions & 0 deletions IdentityToolkit/Sources/IdentityToolkitError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import Core
import Foundation

public enum GoogleCloudIdentityToolkitError: GoogleCloudError {
case projectIdMissing
case apiKeyMissing
case unknownError(String)

var localizedDescription: String {
switch self {
case .projectIdMissing:
return "Missing project id for GoogleCloudIdentityToolkit API. Did you forget to set your project id?"
case .apiKeyMissing:
return "Missing API key for GoogleCloudIdentityToolkit API. Did you forget to set your API key?"
case .unknownError(let reason):
return "An unknown error occured: \(reason)"
}
}
}

/// [Reference](https://cloud.google.com/identity-platform/docs/error-codes)
public struct IdentityToolkitAPIError: GoogleCloudError, GoogleCloudModel {
/// A container for the error information
public var error: IdentityToolkitAPIErrorBody
}

public struct IdentityToolkitAPIErrorBody: Codable {
/// A container for the error details
public var status: Status
/// An HTTP status code value, without the textual description
public var code: Int
/// Description of the error
public var message: String

public enum Status: String, RawRepresentable, Codable {
// Account Management Errors
case requiresRecentLogin = "ERROR_REQUIRES_RECENT_LOGIN"

// Authorization Errors
case appNotAuthorized = "ERROR_APP_NOT_AUTHORIZED"

// Multi-factor Authentication Errors
case missingMultiFactorSession = "ERROR_MISSING_MULTI_FACTOR_SESSION"
case missingMultiFactorInfo = "ERROR_MISSING_MULTI_FACTOR_INFO"
case invalidMultiFactorSession = "ERROR_INVALID_MULTI_FACTOR_SESSION"
case multiFactorInfoNotFound = "ERROR_MULTI_FACTOR_INFO_NOT_FOUND"
case secondFactorRequired = "ERROR_SECOND_FACTOR_REQUIRED"
case secondFactorAlreadyEnrolled = "ERROR_SECOND_FACTOR_ALREADY_ENROLLED"
case maximumSecondFactorCountExceeded = "ERROR_MAXIMUM_SECOND_FACTOR_COUNT_EXCEEDED"
case unsupportedFirstFactor = "ERROR_UNSUPPORTED_FIRST_FACTOR"
case emailChangeNeedsVerification = "ERROR_EMAIL_CHANGE_NEEDS_VERIFICATION"

// Phone Authentication Errors
case missingPhoneNumber = "ERROR_MISSING_PHONE_NUMBER"
case invalidPhoneNumber = "ERROR_INVALID_PHONE_NUMBER"
case missingVerificationCode = "ERROR_MISSING_VERIFICATION_CODE"
case invalidVerificationCode = "ERROR_INVALID_VERIFICATION_CODE"
case missingVerificationId = "ERROR_MISSING_VERIFICATION_ID"
case invalidVerificationId = "ERROR_INVALID_VERIFICATION_ID"
case sessionExpired = "ERROR_SESSION_EXPIRED"
case quotaExceeded = "ERROR_QUOTA_EXCEEDED"
case appNotVerified = "ERROR_APP_NOT_VERIFIED"

// General Errors
case captchaCheckFailed = "ERROR_CAPTCHA_CHECK_FAILED"
case unknownError = "UNKNOWN_ERROR"

// Standard Google Cloud API Errors
case invalidArgument = "INVALID_ARGUMENT"
case failedPrecondition = "FAILED_PRECONDITION"
case outOfRange = "OUT_OF_RANGE"
case unauthenticated = "UNAUTHENTICATED"
case permissionDenied = "PERMISSION_DENIED"
case notFound = "NOT_FOUND"
case aborted = "ABORTED"
case alreadyExists = "ALREADY_EXISTS"
case resourceExhausted = "RESOURCE_EXHAUSTED"
case cancelled = "CANCELLED"
case dataLoss = "DATA_LOSS"
case unknown = "UNKNOWN"
case `internal` = "INTERNAL"
case unavailable = "UNAVAILABLE"
case deadlineExceeded = "DEADLINE_EXCEEDED"
}
}

public struct IdentityToolkitError: Codable {
/// The scope of the error. Example values include: global, push and usageLimits
public var domain: String?
/// Example values include invalid, invalidParameter, and required
public var reason: String?
/// Description of the error
/// Example values include Invalid argument, Login required, and Required parameter: project
public var message: String?
/// The location or part of the request that caused the error
public var locationType: String?
/// The specific item within the locationType that caused the error
public var location: String?
}
92 changes: 92 additions & 0 deletions IdentityToolkit/Sources/IdentityToolkitRequest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import Core
import Foundation
import NIO
import NIOFoundationCompat
import NIOHTTP1
import AsyncHTTPClient

class GoogleCloudIdentityToolkitRequest: GoogleCloudAPIRequest {
let refreshableToken: OAuthRefreshable
let project: String
let apiKey: String
let httpClient: HTTPClient
let responseDecoder: JSONDecoder = JSONDecoder()
var currentToken: OAuthAccessToken?
var tokenCreatedTime: Date?
let eventLoop: EventLoop

init(httpClient: HTTPClient, eventLoop: EventLoop, oauth: OAuthRefreshable, project: String, apiKey: String) {
self.refreshableToken = oauth
self.httpClient = httpClient
self.project = project
self.apiKey = apiKey
self.eventLoop = eventLoop

let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"
self.responseDecoder.dateDecodingStrategy = .formatted(dateFormatter)
}

public func send<GCM: GoogleCloudModel>(
method: HTTPMethod,
headers: HTTPHeaders = [:],
path: String,
query: String = "",
body: HTTPClient.Body = .data(Data())
) -> EventLoopFuture<GCM> {
return withToken { token in
return self._send(
method: method,
headers: headers,
path: path,
query: query,
body: body,
accessToken: token.accessToken
).flatMap { response in
do {
let model = try self.responseDecoder.decode(GCM.self, from: response)
return self.eventLoop.makeSucceededFuture(model)
} catch {
return self.eventLoop.makeFailedFuture(error)
}
}
}
}

private func _send(method: HTTPMethod, headers: HTTPHeaders, path: String, query: String, body: HTTPClient.Body, accessToken: String) -> EventLoopFuture<Data> {
var _headers: HTTPHeaders = ["Authorization": "Bearer \(accessToken)",
"Content-Type": "application/json"]
headers.forEach { _headers.replaceOrAdd(name: $0.name, value: $0.value) }

do {
let request = try HTTPClient.Request(url: "\(path)?\(query)", method: method, headers: _headers, body: body)

return httpClient.execute(request: request, eventLoop: .delegate(on: self.eventLoop)).flatMap { response in
// If we get a 204 for example in the delete api call just return an empty body to decode.
if response.status == .noContent {
return self.eventLoop.makeSucceededFuture("{}".data(using: .utf8)!)
}

guard var byteBuffer = response.body else {
fatalError("Response body from Google is missing! This should never happen.")
}
let responseData = byteBuffer.readData(length: byteBuffer.readableBytes)!

guard (200...299).contains(response.status.code) else {
let error: Error
if let jsonError = try? self.responseDecoder.decode(IdentityToolkitAPIError.self, from: responseData) {
error = jsonError
} else {
let body = response.body?.getString(at: response.body?.readerIndex ?? 0, length: response.body?.readableBytes ?? 0) ?? ""
error = IdentityToolkitAPIError(error: IdentityToolkitAPIErrorBody(status: .unknownError, code: Int(response.status.code), message: body))
}

return self.eventLoop.makeFailedFuture(error)
}
return self.eventLoop.makeSucceededFuture(responseData)
}
} catch {
return self.eventLoop.makeFailedFuture(error)
}
}
}
Loading