Skip to content

Commit

Permalink
Added ApiKit-OAuth, important fixes for ApiKit and concurrent calls.
Browse files Browse the repository at this point in the history
  • Loading branch information
NicholasMata committed Apr 15, 2024
1 parent dd6b835 commit 169bb2d
Show file tree
Hide file tree
Showing 22 changed files with 552 additions and 157 deletions.
24 changes: 24 additions & 0 deletions .swiftpm/xcode/xcshareddata/xcschemes/ApiKit-Package.xcscheme
Expand Up @@ -81,6 +81,20 @@
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "ApiKit-OAuth"
BuildableName = "ApiKit-OAuth"
BlueprintName = "ApiKit-OAuth"
ReferencedContainer = "container:">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
Expand Down Expand Up @@ -109,6 +123,16 @@
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "ApiKit-OAuthTests"
BuildableName = "ApiKit-OAuthTests"
BlueprintName = "ApiKit-OAuthTests"
ReferencedContainer = "container:">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
Expand Down
1 change: 1 addition & 0 deletions ApiKit-Google/Sources/DefaultGoogleAuthInterceptor.swift
Expand Up @@ -6,6 +6,7 @@
//

import ApiKit
import ApiKit_OAuth
import Foundation

open class DefaultGoogleAuthInterceptor: OAuthInterceptor {
Expand Down
33 changes: 21 additions & 12 deletions ApiKit-Google/Sources/DefaultGoogleAuthProvider.swift
Expand Up @@ -6,41 +6,50 @@
//

import ApiKit
import ApiKit_OAuth
import Foundation
import GoogleSignIn

/// A default implementation of GoogleSignIn.
open class DefaultGoogleAuthProvider: OAuthProvider {
open var token: String? {
return self.token(for: GIDSignIn.sharedInstance.currentUser)
}

open var googleSignIn = GIDSignIn.sharedInstance

public init() {}

public var tokenState: TokenState {
get {
guard let currentUser = googleSignIn.currentUser else {
return TokenState.missing
}
let expiration = currentUser.authentication.accessTokenExpirationDate
guard expiration <= Date() else {
return TokenState.expired
}
return TokenState.valid(token: token(for: currentUser))
}
}

open func hasPreviousSignIn() -> Bool {
return googleSignIn.hasPreviousSignIn()
open func token(for user: GIDGoogleUser) -> String {
return user.authentication.accessToken
}

open func restorePreviousSignIn(completion: @escaping (Result<String, Error>) -> Void) {

public func refreshToken(completion: @escaping (Result<String, any Error>) -> Void) {
googleSignIn.restorePreviousSignIn { user, error in
guard error == nil else {
completion(.failure(error!))
return
}

guard let user = user, let token = self.token(for: user) else {
guard let user = user else {
completion(.failure(GoogleAuthError.unknown))
return
}

let token = self.token(for: user)
completion(.success(token))
}
}

open func token(for user: GIDGoogleUser?) -> String? {
return user?.authentication.accessToken
}
}

public enum GoogleAuthError: Error {
Expand Down
Expand Up @@ -7,11 +7,11 @@

import Foundation

private final class KeychainHelper {
public final class KeychainHelper {
public static let standard = KeychainHelper()
private init() {}

public func save(_ data: Data, service: String, account: String) {
public func save(_ data: Data, forService service: String, account: String) -> Bool {
let query = [
kSecValueData: data,
kSecAttrService: service,
Expand All @@ -33,8 +33,9 @@ private final class KeychainHelper {
let attributesToUpdate = [kSecValueData: data] as CFDictionary

// Update existing item
SecItemUpdate(query, attributesToUpdate)
let status = SecItemUpdate(query, attributesToUpdate)
}
return status == errSecSuccess
}

public func read(service: String, account: String) -> Data? {
Expand All @@ -51,22 +52,23 @@ private final class KeychainHelper {
return (result as? Data)
}

public func delete(service: String, account: String) {
public func delete(service: String, account: String) -> Bool {
let query = [
kSecAttrService: service,
kSecAttrAccount: account,
kSecClass: kSecClassGenericPassword,
] as CFDictionary

// Delete item from keychain
SecItemDelete(query)
let status = SecItemDelete(query)
return status == errSecSuccess
}
}

private extension KeychainHelper {
func save(_ data: Data?, service: String, account: String) {
guard let data = data else {
delete(service: service, account: account)
_ = delete(service: service, account: account)
return
}
save(data, service: service, account: account)
Expand Down
Expand Up @@ -5,8 +5,14 @@
// Created by Nicholas Mata on 6/14/22.
//

import ApiKit
import Foundation

enum OAuthError: Error {
case failedToRenew
case noToken
}

/// Used to add OAuth access token to request for authentication / authorization.
open class OAuthInterceptor: ApiInterceptor {
private let semaphore = DispatchSemaphore(value: 1)
Expand Down Expand Up @@ -34,60 +40,36 @@ open class OAuthInterceptor: ApiInterceptor {
self.workItem = workItem
}

public func api(_: Api,
public func api(_ api: Api,
modifyRequest request: URLRequest,
withId _: UUID,
onNewRequest: @escaping (URLRequest?) -> Void)
{
guard provider.hasPreviousSignIn() else {
onNewRequest(request)
return
}

semaphore.wait()
guard let token = provider.token else {
semaphore.signal()
failedToRenew(with: nil)
onNewRequest(request)
let tokenState = provider.tokenState
guard tokenState == TokenState.expired else {
self.semaphore.signal()
if case let .valid(token) = tokenState {
let newRequest = provider.attach(token: token, to: request)
onNewRequest(newRequest)
} else {
onNewRequest(nil)
self.failedToRenew(with: OAuthError.noToken)
}
return
}
let newRequest = provider.modify(request: request, token: token)
semaphore.signal()
onNewRequest(newRequest)
}

public func api(_ api: Api,
didReceive result: Result<HttpDataResponse, Error>,
withId _: UUID,
for request: URLRequest,
completion: HttpDataCompletion) -> Bool
{
guard case let .success(response) = result,
response.statusCode == 401
else {
return false
}

semaphore.wait()

guard provider.hasPreviousSignIn()
else {
semaphore.signal()
failedToRenew(with: nil)
return true
}

provider.restorePreviousSignIn { result in
provider.refreshToken { result in
switch result {
case let .success(newToken):
case let .success(token):
let newRequest = self.provider.attach(token: token, to: request)
self.semaphore.signal()
let newRequest = self.provider.modify(request: request, token: newToken)
_ = api.send(newRequest, completion: completion)
case let .failure(err):
onNewRequest(newRequest)
case let .failure(error):
self.semaphore.signal()
self.failedToRenew(with: err)
onNewRequest(nil)
self.failedToRenew(with: error)
}
}
return true
}
}
39 changes: 39 additions & 0 deletions ApiKit-OAuth/Sources/OAuthProvider.swift
@@ -0,0 +1,39 @@
//
// OAuthProvider.swift
//
//
// Created by Nicholas Mata on 6/14/22.
//

import Foundation

/// An OAuth provider that manages access token.
public protocol OAuthProvider {

/// Get the current state of the access token.
var tokenState: TokenState { get }

/// Refresh the token provided
/// - Parameter completion: A function to call when the token is refreshed or fails to refresh.
func refreshToken(completion: @escaping (Result<String, Error>)-> Void)

/// Attach the token to the request. The default implementation will add token to "Authorization" header as Bearer token.
/// - Parameters:
/// - token: The token to add to the request
/// - request: The request to add the token too
/// - Returns: The new request with the token attached to it
func attach(token: String, to request: URLRequest) -> URLRequest
}

public extension OAuthProvider where Self: AnyObject {
func attach(token: String, to request: URLRequest) -> URLRequest {
let bearerToken = "Bearer \(token)"
var request = request
if request.allHTTPHeaderFields != nil {
request.allHTTPHeaderFields?.updateValue(bearerToken, forKey: "Authorization")
} else {
request.allHTTPHeaderFields = ["Authorization": bearerToken]
}
return request
}
}
36 changes: 36 additions & 0 deletions ApiKit-OAuth/Sources/OAuthTokenManagedProvider.swift
@@ -0,0 +1,36 @@
//
// OAuthTokenManagedProvider.swift
//
//
// Created by Nicholas Mata on 4/15/24.
//

import Foundation

public protocol OAuthTokenManagedProvider: OAuthProvider {
/// The token manager that will be used to store and retrieve the access and refresh token
var tokenManager: TokenManager { get set }

/// Refresh the token provided
/// - Parameter completion: A function to call when the token is refreshed or fails to refresh.
func refreshToken(completion: @escaping (Result<String, Error>) -> Void)

/// Attach the token to the request. The default implementation will add token to "Authorization" header as Bearer token.
/// - Parameters:
/// - token: The token to add to the request
/// - request: The request to add the token too
/// - Returns: The new request with the token attached to it
func attach(token: String, to request: URLRequest) -> URLRequest
}

public extension OAuthTokenManagedProvider where Self: AnyObject {
var tokenState: TokenState {
guard let accessToken = tokenManager.accessToken, !accessToken.isExpired() else {
guard let refreshToken = tokenManager.refreshToken, !refreshToken.isExpired() else {
return .missing
}
return .expired
}
return .valid(token: accessToken.token)
}
}
17 changes: 17 additions & 0 deletions ApiKit-OAuth/Sources/OpenIDConnect/OIDCResponse.swift
@@ -0,0 +1,17 @@
//
// OIDCResponse.swift
//
//
// Created by Nicholas Mata on 4/15/24.
//

import Foundation

public class OIDCTokenResponse: Codable {
public var accessToken: String
public var tokenType: String
public var expiresIn: Int
public var refreshToken: String?
public var refreshTokenExpiresIn: Int?
public var idToken: String?
}

0 comments on commit 169bb2d

Please sign in to comment.