Bloombox/Swift

View on GitHub
Sources/Client/AuthClient.swift

Summary

Maintainability
A
0 mins
Test Coverage
/**
* Copyright 2019, Momentum Ideas, Co. All rights reserved.
* Source and object computer code contained herein is the private intellectual
* property of Momentum Ideas Co., a Delaware Corporation. Use of this
* code in source form requires permission in writing before use or the
* assembly, distribution, or publishing of derivative works, for commercial
* purposes or any other purpose, from a duly authorized officer of Momentum
* Ideas Co.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/

import Foundation
import SwiftGRPC


// Type Aliases

/// One-time-use authorization nonce value.
public typealias NonceValue = String

/// User public key content, encoded in hex form.
public typealias UserPublicKey = String

/// Firebase Cloud Messaging (FCM) push token.
public typealias EncodedFCMToken = String

/// Apple Push Notification Service (APNS) push token.
public typealias EncodedAPNSToken = String

/// Verified user identity ID.
public typealias VerifiedIdentityToken = String

/// Auth API call context, including a partner code, location code, and API key.
public typealias AuthAPIContext = (partner: PartnerCode?, location: LocationCode?, apiKey: APIKey)


// Callback Types

/// Defines a callback interface for receiving an authorization nonce, once one is ready, or indicating a fatal
/// error retrieving an authorization nonce. Either way, a gRPC call result is provided, which contains the
/// final status of the call. No return value is expected.
///
/// - Parameters:
///    - `CallResult`: gRPC call result object, which includes a status code.
///    - `AuthNonce?`: Resulting authorization nonce, if once could be generated.
public typealias AuthNonceCallback = (CallResult, NonceValue?) -> ()

/// Defines a callback interface for completing an identity connection flow, wherein, a verified user identity
/// is asserted to the backend platform, verified, and used to initialize a consumer-side user account. Whether
/// the call terminates in a success or error state, a gRPC call result is provided, which contains the final
/// status of the call. No return value is expected.
///
/// - Parameters:
///    - `CallResult`: gRPC call result object, which includes a status code.
///    - `IdentityConnect.Response?`: Identity connection transaction response/result.
public typealias IdentityConnectCallback = (CallResult, IdentityConnect.Response?) -> ()


/// Defines a callback interface for retrieving a user's profile, via their user key. If the profile can be located and
/// the invoking user is duly authenticated and authorized to read it, it is provided in the callback here as the second
/// parameter, with the gRPC call result as the first. No return result is expected.
///
/// - Parameters:
///    - `CallResult`: gRPC call result object, which includes a status code.
///    - `User?`: User profile, if it could be located.
public typealias GetProfileCallback = (CallResult, User?) -> ()


/// Enumerates code-level errors in the auth client.
public enum AuthClientError: Error {
  /// No partner code could be resolved, or the given partner code was invalid.
  case invalidPartnerCode

  /// No location code could be resolved, or the given location code was invalid.
  case invalidLocationCode

  /// No API key could be resolved, or the given API key was invalid.
  case invalidApiKey

  /// An unknown client-side error occurred.
  case unknown
}


/// Describes the application and device performing identity connection, in an identity connection flow. This
/// includes both the software and hardware (in the form of the hardware's fingerprint UUID, and the software's
/// application ID and API key).
public struct ConnectApp {
  /// Software application ID, usually the bundle ID on Apple platforms.
  let client: String

  /// Device fingerprint. Generated and persisted on the device, but may change between app installs.
  let fingerprint: DeviceUUID

  /// Firebase Cloud Messaging (FCM) push token. Uniquely addresses the device for push notifications.
  let fcm: EncodedFCMToken

  /// Apple Push Notification Service (APNS) push token. Uniquely addresses the device for push notifications.
  let apns: EncodedAPNSToken?

  /// Advertising identifier for the user, if applicable.
  let adid: String?

  /// Initialize a new identity connect flow context object, which carries with it details about the client
  /// software and hardware agent performing the flow on behalf of the user.
  ///
  /// - Parameter client: Software application ID, usually the bundle ID on Apple platforms.
  /// - Parameter fingerprint: Device fingerprint. Generated and persisted on the device.
  /// - Parameter fcm: Firebase Cloud Messaging (FCM) push token, uniquely identifying the device.
  /// - Parameter apns: Apple Push Notification Service (APNS) push token, uniquely identifying the device.
  /// - Parameter adid: Advertising identifier for the user, if applicable.
  public init(client: String,
              fingerprint: DeviceUUID,
              fcm: EncodedFCMToken,
              apns: EncodedAPNSToken? = nil,
              adid: String? = nil) {
    self.client = client
    self.fingerprint = fingerprint
    self.fcm = fcm
    self.apns = apns
    self.adid = adid
  }
}


/// Specifies a client interface for the Auth API, which supports authentication and authorization flows in
/// various circumstances. The Auth API is also responsible for managing consent flows and OAuth2 parameters.
public final class AuthClient: RemoteService {
  /// Name of the Auth API, which is "auth".
  let name = "auth"

  /// Version of this service.
  let version = "v1beta1"

  // MARK: Internals

  /// Client-wide settings.
  internal let settings: Bloombox.Settings

  /// Low-level auth service.
  internal var svc: AuthService?

  /// Library-internal initializer.
  ///
  /// - Parameter settings: Client-wide settings to apply.
  internal init(settings: Bloombox.Settings) {
    self.settings = settings
  }

  /// Auth service. Retrieve an implementation of the auth service, capable of authenticating, authorizing,
  /// verifying and renewing user credentials in various circumstances.
  ///
  /// - Parameter apiKey: API Key to use.
  /// - Parameter settings: Combined settings to use.
  /// - Returns: Prepared Auth API service class.
  /// - Throws: Client-side errors. See: `AuthClientError`.
  private func service(_ apiKey: APIKey) throws -> AuthService {
    if let s = self.svc {
      return s
    }
    let svc = RPCServiceFactory<AuthService>.factory(
      forService: Transport.config.services.devices,
      withSettings: self.settings)

    try svc.metadata.add(key: "x-api-key", value: apiKey)
    self.svc = svc
    return svc
  }

  /// Resolve method context, throwing an error if it cannot be figured out. Where auth is concerned, this
  /// includes the subject partner and location code associated with the property (optionally), and an API
  /// key with which we should connect to the service to identify the software.
  ///
  /// - Parameter partner: Partner account code to use.
  /// - Parameter location: Location account code to use.
  /// - Parameter apiKey: API key to use with the service.
  /// - Returns: Tuple of partner, location, and API key to use.
  /// - Throws: `MenuClientError` codes if items cannot be resolved.
  private func resolveContext(_ partner: PartnerCode? = nil,
                              _ location: LocationCode? = nil,
                              _ apiKey: APIKey? = nil,
                              enforcePartnerLocation enforce: Bool = true) throws -> AuthAPIContext {
    let partnerCode: PartnerCode? = partner ?? settings.partner
    let locationCode: LocationCode? = location ?? settings.location
    let apiKey: APIKey? = apiKey ?? settings.apiKey

    guard apiKey != nil else {
      throw AuthClientError.invalidApiKey
    }

    // validate partner and location codes
    if enforce {
      guard partnerCode != nil, locationCode != nil else {
        // throw error: we require a partner or location code from somewhere
        if partnerCode == nil {
          throw MenuClientError.invalidPartnerCode
        }
        throw MenuClientError.invalidLocationCode
      }
    }
    return (partner: partnerCode, location: locationCode, apiKey: apiKey!)
  }

  /// Build a uniform request to connect a verified identity with a user account.
  ///
  /// - Parameter identity: Verified user identity ID.
  /// - Parameter publicKey: User's public key.
  /// - Parameter nonce: Authorization nonce.
  /// - Parameter client: Details about the client app and hardware.
  /// - Returns: Prepared identity connect request.
  private func buildConnectRequest(_ identity: VerifiedIdentityToken,
                                   _ publicKey: UserPublicKey,
                                   _ nonce: NonceValue,
                                   _ client: ConnectApp) -> IdentityConnect.Request {
    return IdentityConnect.Request.with { builder in
      builder.token = identity
      builder.key = publicKey
      builder.nonce = nonce
      builder.fcm = client.fcm
      builder.client = client.client
      builder.device = client.fingerprint

      if let apns = client.apns {
        builder.apns = apns
      }

      if let adid = client.adid {
        builder.adid = adid
      }
    }
  }

  // MARK: - Auth Nonce

  /// Generate and return an authorization nonce, which is required for certain secured auth flows. An auth
  /// nonce value is an opaque string, which, after being requested via an interface secured behind an API key,
  /// may be used once (and only once) as the precursor value to begin an authorization flow. If the flow fails
  /// or any other fatal error is encountered during the transaction without fully satisfying the nonce, it
  /// must be discarded and exchanged for a new one.
  ///
  /// - Parameter apiKey: API key to invoke the remote service with. If nil, the client-wide key will be used.
  /// - Returns: Resulting authorization nonce, which is a `String`, aliased to `NonceValue`.
  /// - Throws: Client-side and server-side errors, since this method is synchronous.
  public func nonce(apiKey: APIKey? = nil) throws -> NonceValue {
    let (_, _, apiKey) = try resolveContext(nil, nil, apiKey, enforcePartnerLocation: false)
    return try self.service(apiKey).nonce(Empty()).nonce
  }

  /// Generate and return an authorization nonce, asynchronously, which is required for certain secured auth
  /// flows. An auth nonce value is an opaque string, which, after being requested via an interface secured
  /// behind an API key, may be used once (and only once) as the precursor value to begin an authorization
  /// flow. If the flow fails or any other fatal error is encountered during the transaction without fully
  /// satisfying the nonce, it must be discarded and exchanged for a new one.
  ///
  /// - Parameter apiKey: API key to invoke the remote service with. If nil, the client-wide key will be used.
  /// - Parameter callback: Callback to dispatch once a nonce or a fatal error is available.
  /// - Returns: RPC call object, which may be used to observe and cancel the underlying RPC call.
  /// - Throws: Client-side errors only, since this method is async. Server side methods occur in the callback.
  @discardableResult
  public func nonce(apiKey: APIKey? = nil, _ callback: @escaping AuthNonceCallback) throws -> AuthNonceCall {
    let (_, _, apiKey) = try resolveContext(nil, nil, apiKey, enforcePartnerLocation: false)
    return try self.service(apiKey).nonce(Empty()) { response, callResult in
      callback(callResult, response?.nonce)
    }
  }

  // MARK: - Profile Fetch

  /// Retrieve a user's profile using their universal key. The user's profile key is resolved from the identity ID,
  /// which is resolved or generated during the authentication process (so, you must complete an auth flow to retrieve
  /// the user's profile via this method). Only the owning user, and any application authorized by the owning user, may
  /// read the user's full profile - for social connections and similar operations, profile results may return truncated
  /// data, omitting details to which the invoking user does not have access.
  ///
  /// - Parameter userKey: Key for the user profile we would like to fetch.
  /// - Parameter apiKey: API key to use when fetching the profile. If none is specified, use the client-wide default.
  /// - Returns: User profile, if one can be found. If one cannot be found, an error is thrown.
  /// - Throws: Server-side and client-side errors, including `USER_NOT_FOUND` if the user cannot be found.
  public func profile(forUser userKey: UserKey,
                      apiKey: APIKey? = nil) throws -> User {
    let (_, _, apiKey) = try resolveContext(nil, nil, apiKey, enforcePartnerLocation: false)
    return try self.service(apiKey).profile(GetProfile.Request.with { builder in
      builder.user = userKey.uid
    }).profile
  }

  /// Retrieve a user's profile using their universal key. The user's profile key is resolved from the identity ID,
  /// which is resolved or generated during the authentication process (so, you must complete an auth flow to retrieve
  /// the user's profile via this method). Only the owning user, and any application authorized by the owning user, may
  /// read the user's full profile - for social connections and similar operations, profile results may return truncated
  /// data, omitting details to which the invoking user does not have access.
  ///
  /// This method fetches the user profile asynchronously and provides results in callback form. The returned RPC call
  /// object may be used to cancel or observe the underlying RPC call.
  ///
  /// - Parameter userKey: Key for the user profile we would like to fetch.
  /// - Parameter apiKey: API key to use when fetching the profile. If none is specified, use the client-wide default.
  /// - Parameter callback: Callback to dispatch once a response, or terminal error, is available.
  /// - Returns: User profile, if one can be found. If one cannot be found, an error is thrown.
  /// - Throws: Server-side and client-side errors, including `USER_NOT_FOUND` if the user cannot be found.
  @discardableResult
  public func profile(forUser userKey: UserKey,
                      apiKey: APIKey? = nil,
                      _ callback: @escaping GetProfileCallback) throws -> GetProfileCall {
    let (_, _, apiKey) = try resolveContext(nil, nil, apiKey, enforcePartnerLocation: false)
    return try self.service(apiKey).profile(GetProfile.Request.with { builder in
      builder.user = userKey.uid
    }) { resp, callResult in
      callback(callResult, resp?.profile)
    }
  }

  // MARK: - Identity Connect

  /// Begin an identity connect flow, having already verified the user's identity using an external service.
  /// Before beginning this process, the invoking application must acquire a valid authorization nonce, and
  /// request that the user sign a data payload containing the nonce, verified user ID, client application,
  /// fingerprint, and other details (see online documentation). That signed payload is embedded in the JWT
  /// provided here, and sent along with the user's public key, the nonce, and client-app details.
  ///
  /// If no user account could be located, one is created and initialized, and a response is returned
  /// indicating the user must complete profile signup.
  ///
  /// - Parameter identity: Verified user identity token (JWT).
  /// - Parameter publicKey: Hex-encoded public key for the user's secure private key.
  /// - Parameter nonce: One-time-use nonce, issued for this auth flow.
  /// - Parameter client: Client application and device details for the software requesting authorization.
  /// - Parameter apiKey: API key to identify ourselves with to the Auth API.
  /// - Returns: Authorization token and user profile resulting from this transaction.
  /// - Throws: Client and server-side errors, since this method is synchronous.
  public func connect(identity: VerifiedIdentityToken,
                      withPublicKey publicKey: UserPublicKey,
                      andNonce nonce: NonceValue,
                      forClient client: ConnectApp,
                      apiKey: APIKey? = nil) throws -> IdentityConnect.Response {
    let (_, _, apiKey) = try resolveContext(nil, nil, apiKey, enforcePartnerLocation: false)
    return try self.service(apiKey).connect(self.buildConnectRequest(identity, publicKey, nonce, client))
  }

  /// Begin an identity connect flow, having already verified the user's identity using an external service.
  /// Before beginning this process, the invoking application must acquire a valid authorization nonce, and
  /// request that the user sign a data payload containing the nonce, verified user ID, client application,
  /// fingerprint, and other details (see online documentation). That signed payload is embedded in the JWT
  /// provided here, and sent along with the user's public key, the nonce, and client-app details.
  ///
  /// If no user account could be located, one is created and initialized, and a response is returned
  /// indicating the user must complete profile signup.
  ///
  /// - Parameter identity: Verified user identity token (JWT).
  /// - Parameter publicKey: Hex-encoded public key for the user's secure private key.
  /// - Parameter nonce: One-time-use nonce, issued for this auth flow.
  /// - Parameter client: Client application and device details for the software requesting authorization.
  /// - Parameter apiKey: API key to identify ourselves with to the Auth API.
  /// - Parameter callback: Response callback to dispatch once a response or error is ready.
  /// - Returns: RPC call object, which can be used
  /// - Throws: Client and server-side errors, since this method is synchronous.
  @discardableResult
  public func connect(identity: VerifiedIdentityToken,
                      withPublicKey publicKey: UserPublicKey,
                      andNonce nonce: NonceValue,
                      forClient client: ConnectApp,
                      apiKey: APIKey? = nil,
                      _ callback: @escaping IdentityConnectCallback) throws -> IdentityConnectCall {
    let (_, _, apiKey) = try resolveContext(nil, nil, apiKey, enforcePartnerLocation: false)
    let request = self.buildConnectRequest(identity, publicKey, nonce, client)

    return try self.service(apiKey).connect(request) { callResult, response in
      callback(response, callResult)
    }
  }

}