diff --git a/Package.resolved b/Package.resolved
index b5e229c..cc7ad8c 100644
--- a/Package.resolved
+++ b/Package.resolved
@@ -12,7 +12,7 @@
{
"identity" : "cocoalumberjack",
"kind" : "remoteSourceControl",
- "location" : "https://github.com/CocoaLumberjack/CocoaLumberjack.git",
+ "location" : "https://github.com/CocoaLumberjack/CocoaLumberjack",
"state" : {
"revision" : "363ed23d19a931809ea834a7d722da830353806a",
"version" : "3.8.2"
@@ -27,6 +27,15 @@
"version" : "2.0.1"
}
},
+ {
+ "identity" : "ios-login",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/Infomaniak/ios-login",
+ "state" : {
+ "revision" : "904c1ac39b4db56212302b464a0b2e023d9b5791",
+ "version" : "6.0.0"
+ }
+ },
{
"identity" : "realm-core",
"kind" : "remoteSourceControl",
@@ -59,8 +68,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git",
"state" : {
- "revision" : "32e8d724467f8fe623624570367e3d50c5638e46",
- "version" : "1.5.2"
+ "revision" : "532d8b529501fb73a2455b179e0bbb6d49b652ed",
+ "version" : "1.5.3"
}
},
{
diff --git a/Package.swift b/Package.swift
index 5fd0dc6..1ac21f0 100644
--- a/Package.swift
+++ b/Package.swift
@@ -17,6 +17,7 @@ let package = Package(
],
dependencies: [
.package(url: "https://github.com/Infomaniak/ios-dependency-injection", .upToNextMajor(from: "2.0.0")),
+ .package(url: "https://github.com/Infomaniak/ios-login", .upToNextMajor(from: "6.0.0")),
.package(url: "https://github.com/Alamofire/Alamofire", .upToNextMajor(from: "5.8.0")),
.package(url: "https://github.com/getsentry/sentry-cocoa", .upToNextMajor(from: "8.18.0")),
.package(url: "https://github.com/realm/realm-swift", .upToNextMajor(from: "10.45.0")),
@@ -29,6 +30,7 @@ let package = Package(
dependencies: [
"Alamofire",
.product(name: "InfomaniakDI", package: "ios-dependency-injection"),
+ .product(name: "InfomaniakLogin", package: "ios-login"),
.product(name: "Sentry", package: "sentry-cocoa"),
.product(name: "RealmSwift", package: "realm-swift"),
.product(name: "CocoaLumberjackSwift", package: "CocoaLumberjack"),
diff --git a/Sources/InfomaniakCore/Account/Account.swift b/Sources/InfomaniakCore/Account/Account.swift
index 6617e74..81ad1b1 100644
--- a/Sources/InfomaniakCore/Account/Account.swift
+++ b/Sources/InfomaniakCore/Account/Account.swift
@@ -17,6 +17,7 @@
*/
import Foundation
+import InfomaniakLogin
public protocol AccountUpdateDelegate {
func didUpdateCurrentAccount(_ account: Account)
diff --git a/Sources/InfomaniakCore/Account/KeychainHelper.swift b/Sources/InfomaniakCore/Account/KeychainHelper.swift
index ad067cf..7eb3974 100644
--- a/Sources/InfomaniakCore/Account/KeychainHelper.swift
+++ b/Sources/InfomaniakCore/Account/KeychainHelper.swift
@@ -1,43 +1,44 @@
/*
Infomaniak Core - iOS
Copyright (C) 2023 Infomaniak Network SA
-
+
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
-
+
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
-
+
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
import CocoaLumberjackSwift
import Foundation
+import InfomaniakLogin
import Sentry
public class KeychainHelper {
let accessGroup: String
let tag = "ch.infomaniak.token".data(using: .utf8)!
let keychainQueue = DispatchQueue(label: "com.infomaniak.keychain")
-
+
let lockedKey = "isLockedKey"
let lockedValue = "locked".data(using: .utf8)!
var accessibilityValueWritten = false
-
+
public init(accessGroup: String) {
self.accessGroup = accessGroup
}
-
+
public var isKeychainAccessible: Bool {
if !accessibilityValueWritten {
initKeychainAccessibility()
}
-
+
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: lockedKey,
@@ -47,13 +48,13 @@ public class KeychainHelper {
kSecReturnRef as String: kCFBooleanTrue as Any,
kSecMatchLimit as String: kSecMatchLimitAll
]
-
+
var result: AnyObject?
-
+
let resultCode = withUnsafeMutablePointer(to: &result) {
SecItemCopyMatching(query as CFDictionary, UnsafeMutablePointer($0))
}
-
+
if resultCode == noErr, let array = result as? [[String: Any]] {
for item in array {
if let value = item[kSecValueData as String] as? Data {
@@ -66,7 +67,7 @@ public class KeychainHelper {
return false
}
}
-
+
func initKeychainAccessibility() {
accessibilityValueWritten = true
let queryAdd: [String: Any] = [
@@ -81,7 +82,7 @@ public class KeychainHelper {
"[Keychain] Successfully init KeychainHelper ? \(resultCode == noErr || resultCode == errSecDuplicateItem), \(resultCode)"
)
}
-
+
public func deleteToken(for userId: Int) {
keychainQueue.sync {
let queryDelete: [String: Any] = [
@@ -93,7 +94,7 @@ public class KeychainHelper {
DDLogInfo("Successfully deleted token ? \(resultCode == noErr)")
}
}
-
+
public func deleteAllTokens() {
keychainQueue.sync {
let queryDelete: [String: Any] = [
@@ -104,27 +105,38 @@ public class KeychainHelper {
DDLogInfo("Successfully deleted all tokens ? \(resultCode == noErr)")
}
}
-
+
public func storeToken(_ token: ApiToken) {
var resultCode: OSStatus = noErr
let tokenData = try! JSONEncoder().encode(token)
-
+
if let savedToken = getSavedToken(for: token.userId) {
keychainQueue.sync {
+ let queryUpdate: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrAccount as String: "\(token.userId)"
+ ]
+
+ let attributes: [String: Any] = [
+ kSecValueData as String: tokenData
+ ]
+
// Save token only if it's more recent
- if savedToken.expirationDate <= token.expirationDate {
- let queryUpdate: [String: Any] = [
- kSecClass as String: kSecClassGenericPassword,
- kSecAttrAccount as String: "\(token.userId)"
- ]
-
- let attributes: [String: Any] = [
- kSecValueData as String: tokenData
- ]
+ if let savedTokenExpirationDate = savedToken.expirationDate,
+ let newTokenExpirationDate = token.expirationDate,
+ savedTokenExpirationDate <= newTokenExpirationDate {
resultCode = SecItemUpdate(queryUpdate as CFDictionary, attributes as CFDictionary)
DDLogInfo("Successfully updated token ? \(resultCode == noErr)")
SentrySDK.addBreadcrumb(token.generateBreadcrumb(level: .info, message: "Successfully updated token"))
+ } else if savedToken.expirationDate == nil || token.expirationDate == nil {
+ // Or if one of them is now an infinite refresh token
+ resultCode = SecItemUpdate(queryUpdate as CFDictionary, attributes as CFDictionary)
+ DDLogInfo("Successfully updated unlimited token ? \(resultCode == noErr)")
+ SentrySDK.addBreadcrumb(token.generateBreadcrumb(
+ level: .info,
+ message: "Successfully updated unlimited token"
+ ))
}
}
} else {
@@ -149,7 +161,7 @@ public class KeychainHelper {
.generateBreadcrumb(level: .error, message: "Failed saving token", keychainError: resultCode))
}
}
-
+
public func getSavedToken(for userId: Int) -> ApiToken? {
var savedToken: ApiToken?
keychainQueue.sync {
@@ -164,11 +176,11 @@ public class KeychainHelper {
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
-
+
let resultCode = withUnsafeMutablePointer(to: &result) {
SecItemCopyMatching(queryFindOne as CFDictionary, UnsafeMutablePointer($0))
}
-
+
let jsonDecoder = JSONDecoder()
if resultCode == noErr,
let keychainItem = result as? [String: Any],
@@ -179,7 +191,7 @@ public class KeychainHelper {
}
return savedToken
}
-
+
public func loadTokens() -> [ApiToken] {
var values = [ApiToken]()
keychainQueue.sync {
@@ -192,14 +204,14 @@ public class KeychainHelper {
kSecReturnRef as String: kCFBooleanTrue as Any,
kSecMatchLimit as String: kSecMatchLimitAll
]
-
+
var result: AnyObject?
-
+
let resultCode = withUnsafeMutablePointer(to: &result) {
SecItemCopyMatching(query as CFDictionary, UnsafeMutablePointer($0))
}
DDLogInfo("Successfully loaded tokens ? \(resultCode == noErr)")
-
+
guard resultCode == noErr else {
let crumb = Breadcrumb(level: .error, category: "Token")
crumb.type = "error"
@@ -208,7 +220,7 @@ public class KeychainHelper {
SentrySDK.addBreadcrumb(crumb)
return
}
-
+
if let array = result as? [[String: Any]] {
let jsonDecoder = JSONDecoder()
for item in array {
@@ -226,3 +238,17 @@ public class KeychainHelper {
return values
}
}
+
+public extension ApiToken {
+ func generateBreadcrumb(level: SentryLevel, message: String, keychainError: OSStatus = noErr) -> Breadcrumb {
+ let crumb = Breadcrumb(level: level, category: "Token")
+ crumb.type = level == .info ? "info" : "error"
+ crumb.message = message
+ crumb.data = ["User id": userId,
+ "Expiration date": expirationDate?.timeIntervalSince1970 ?? "infinite",
+ "Access Token": truncatedAccessToken,
+ "Refresh Token": truncatedRefreshToken,
+ "Keychain error code": keychainError]
+ return crumb
+ }
+}
diff --git a/Sources/InfomaniakCore/Networking/ApiFetcher.swift b/Sources/InfomaniakCore/Networking/ApiFetcher.swift
index 6986538..48b46a1 100644
--- a/Sources/InfomaniakCore/Networking/ApiFetcher.swift
+++ b/Sources/InfomaniakCore/Networking/ApiFetcher.swift
@@ -19,6 +19,7 @@
import Alamofire
import Foundation
import InfomaniakDI
+import InfomaniakLogin
import Sentry
public protocol RefreshTokenDelegate: AnyObject {
@@ -29,14 +30,14 @@ public protocol RefreshTokenDelegate: AnyObject {
@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *)
open class ApiFetcher {
public typealias RequestModifier = (inout URLRequest) throws -> Void
-
+
/// All status except 401 are handled by our code, 401 status is handled by Alamofire's Authenticator code
private static var handledHttpStatus: Set = {
var allStatus = Set(200 ... 500)
allStatus.remove(401)
return allStatus
}()
-
+
public var authenticatedSession: Session!
public static var decoder: JSONDecoder = {
let decoder = JSONDecoder()
@@ -139,8 +140,8 @@ open class ApiFetcher {
decoder: JSONDecoder = ApiFetcher.decoder) async throws -> (data: T, responseAt: Int?) {
let validatedRequest = request.validate(statusCode: ApiFetcher.handledHttpStatus)
let response = await validatedRequest.serializingDecodable(ApiResponse.self,
- automaticallyCancelling: true,
- decoder: decoder).response
+ automaticallyCancelling: true,
+ decoder: decoder).response
let apiResponse = try response.result.get()
return try handleApiResponse(apiResponse, responseStatusCode: response.response?.statusCode ?? -1)
}
@@ -214,6 +215,9 @@ open class OAuthAuthenticator: Authenticator {
extension ApiToken: AuthenticationCredential {
public var requiresRefresh: Bool {
+ guard let expirationDate else {
+ return false
+ }
return Date() > expirationDate
}
}
diff --git a/Sources/InfomaniakCore/Networking/ApiToken.swift b/Sources/InfomaniakCore/Networking/ApiToken.swift
deleted file mode 100644
index 71ba410..0000000
--- a/Sources/InfomaniakCore/Networking/ApiToken.swift
+++ /dev/null
@@ -1,91 +0,0 @@
-/*
- Infomaniak Core - iOS
- Copyright (C) 2023 Infomaniak Network SA
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License
- along with this program. If not, see .
- */
-
-import Foundation
-import Sentry
-
-@objc public class ApiToken: NSObject, Codable {
- @objc public var accessToken: String
- @objc public var expiresIn: Int
- @objc public var refreshToken: String
- @objc public var scope: String
- @objc public var tokenType: String
- @objc public var userId: Int
- @objc public var expirationDate: Date
-
- enum CodingKeys: String, CodingKey {
- case accessToken = "access_token"
- case expiresIn = "expires_in"
- case refreshToken = "refresh_token"
- case tokenType = "token_type"
- case userId = "user_id"
- case scope
- case expirationDate
- }
-
- public required init(from decoder: Decoder) throws {
- let values = try decoder.container(keyedBy: CodingKeys.self)
- accessToken = try values.decode(String.self, forKey: .accessToken)
- expiresIn = try values.decode(Int.self, forKey: .expiresIn)
- refreshToken = try values.decode(String.self, forKey: .refreshToken)
- scope = try values.decode(String.self, forKey: .scope)
- tokenType = try values.decode(String.self, forKey: .tokenType)
- userId = try values.decode(Int.self, forKey: .userId)
-
- let newExpirationDate = Date().addingTimeInterval(TimeInterval(Double(expiresIn)))
- expirationDate = try values.decodeIfPresent(Date.self, forKey: .expirationDate) ?? newExpirationDate
- }
-
- public init(accessToken: String, expiresIn: Int, refreshToken: String, scope: String, tokenType: String, userId: Int, expirationDate: Date) {
- self.accessToken = accessToken
- self.expiresIn = expiresIn
- self.refreshToken = refreshToken
- self.scope = scope
- self.tokenType = tokenType
- self.userId = userId
- self.expirationDate = expirationDate
- }
-}
-
-// MARK: - Token Logging
-
-extension ApiToken {
- public var truncatedAccessToken: String {
- truncateToken(accessToken)
- }
-
- public var truncatedRefreshToken: String {
- truncateToken(refreshToken)
- }
-
- func truncateToken(_ token: String) -> String {
- String(token.prefix(4) + "-*****-" + token.suffix(4))
- }
-
- public func generateBreadcrumb(level: SentryLevel, message: String, keychainError: OSStatus = noErr) -> Breadcrumb {
- let crumb = Breadcrumb(level: level, category: "Token")
- crumb.type = level == .info ? "info" : "error"
- crumb.message = message
- crumb.data = ["User id": userId,
- "Expiration date": expirationDate.timeIntervalSince1970,
- "Access Token": truncatedAccessToken,
- "Refresh Token": truncatedRefreshToken,
- "Keychain error code": keychainError]
- return crumb
- }
-}
diff --git a/Sources/InfomaniakCore/Networking/Requests/InfomaniakNetworkLogin.swift b/Sources/InfomaniakCore/Networking/Requests/InfomaniakNetworkLogin.swift
deleted file mode 100644
index 31e77d6..0000000
--- a/Sources/InfomaniakCore/Networking/Requests/InfomaniakNetworkLogin.swift
+++ /dev/null
@@ -1,194 +0,0 @@
-/*
- Infomaniak Core - iOS
- Copyright (C) 2023 Infomaniak Network SA
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License
- along with this program. If not, see .
- */
-
-import Foundation
-
-public enum Constants {
- public static let LOGIN_URL = "https://login.infomaniak.com/"
- public static let DELETEACCOUNT_URL = "https://manager.infomaniak.com/v3/ng/profile/user/dashboard?open-terminate-account-modal"
- public static let RESPONSE_TYPE = "code"
- public static let ACCESS_TYPE = "offline"
- public static let HASH_MODE = "SHA-256"
- public static let HASH_MODE_SHORT = "S256"
-
- public static func autologinUrl(to destination: String) -> URL? {
- return URL(string: "https://manager.infomaniak.com/v3/mobile_login/?url=\(destination)")
- }
-}
-
-/// Something that can keep the network stack authenticated
-public protocol InfomaniakNetworkLoginable {
- /// Get an api token async (callback on background thread)
- func getApiTokenUsing(code: String, codeVerifier: String, completion: @escaping (ApiToken?, Error?) -> Void)
-
- /// Get an api token async from an application password (callback on background thread)
- func getApiToken(username: String, applicationPassword: String, completion: @escaping (ApiToken?, Error?) -> Void)
-
- /// Refresh api token async (callback on background thread)
- func refreshToken(token: ApiToken, completion: @escaping (ApiToken?, Error?) -> Void)
-
- /// Delete an api token async
- func deleteApiToken(token: ApiToken, onError: @escaping (Error) -> Void)
-}
-
-public class InfomaniakNetworkLogin: InfomaniakNetworkLoginable {
- private static let LOGIN_API_URL = "https://login.infomaniak.com/"
- private static let GET_TOKEN_API_URL = LOGIN_API_URL + "token"
-
- private var clientId: String
- private var loginBaseUrl: String
- private var redirectUri: String
-
- // MARK: Public
-
- public init(clientId: String,
- loginUrl: String = Constants.LOGIN_URL,
- redirectUri: String = "\(Bundle.main.bundleIdentifier ?? "")://oauth2redirect") {
- self.loginBaseUrl = loginUrl
- self.clientId = clientId
- self.redirectUri = redirectUri
- }
-
- public func getApiTokenUsing(code: String, codeVerifier: String, completion: @escaping (ApiToken?, Error?) -> Void) {
- var request = URLRequest(url: URL(string: Self.GET_TOKEN_API_URL)!)
-
- let parameterDictionary: [String: Any] = [
- "grant_type": "authorization_code",
- "client_id": clientId,
- "code": code,
- "code_verifier": codeVerifier,
- "redirect_uri": redirectUri
- ]
- request.httpMethod = "POST"
- request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
- request.httpBody = parameterDictionary.percentEncoded()
-
- getApiToken(request: request, completion: completion)
- }
-
- public func getApiToken(username: String, applicationPassword: String, completion: @escaping (ApiToken?, Error?) -> Void) {
- var request = URLRequest(url: URL(string: Self.GET_TOKEN_API_URL)!)
-
- let parameterDictionary: [String: Any] = [
- "grant_type": "password",
- "access_type": "offline",
- "client_id": clientId,
- "username": username,
- "password": applicationPassword
- ]
- request.httpMethod = "POST"
- request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
- request.httpBody = parameterDictionary.percentEncoded()
-
- getApiToken(request: request, completion: completion)
- }
-
- public func refreshToken(token: ApiToken, completion: @escaping (ApiToken?, Error?) -> Void) {
- var request = URLRequest(url: URL(string: Self.GET_TOKEN_API_URL)!)
-
- let parameterDictionary: [String: Any] = [
- "grant_type": "refresh_token",
- "client_id": clientId,
- "refresh_token": token.refreshToken
- ]
- request.httpMethod = "POST"
- request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
- request.httpBody = parameterDictionary.percentEncoded()
-
- getApiToken(request: request, completion: completion)
- }
-
- public func deleteApiToken(token: ApiToken, onError: @escaping (Error) -> Void) {
- var request = URLRequest(url: URL(string: Self.GET_TOKEN_API_URL)!)
- request.addValue("Bearer \(token.accessToken)", forHTTPHeaderField: "Authorization")
- request.httpMethod = "DELETE"
-
- URLSession.shared.dataTask(with: request) { data, response, sessionError in
- guard let response = response as? HTTPURLResponse, let data else {
- if let sessionError {
- onError(sessionError)
- }
- return
- }
-
- do {
- if !response.isSuccessful() {
- let apiDeleteToken = try JSONDecoder().decode(ApiDeleteToken.self, from: data)
- onError(NSError(domain: apiDeleteToken.error!, code: response.statusCode, userInfo: ["Error": apiDeleteToken.error!]))
- }
- } catch {
- onError(error)
- }
- }.resume()
- }
-
- // MARK: Private
-
- /// Make the get token network call
- private func getApiToken(request: URLRequest, completion: @escaping (ApiToken?, Error?) -> Void) {
- let session = URLSession.shared
- session.dataTask(with: request) { data, response, sessionError in
- guard let response = response as? HTTPURLResponse,
- let data = data, data.count > 0 else {
- completion(nil, sessionError)
- return
- }
-
- do {
- if response.isSuccessful() {
- let apiToken = try JSONDecoder().decode(ApiToken.self, from: data)
- completion(apiToken, nil)
- } else {
- let apiError = try JSONDecoder().decode(LoginApiError.self, from: data)
- completion(nil, NSError(domain: apiError.error, code: response.statusCode, userInfo: ["Error": apiError]))
- }
- } catch {
- completion(nil, error)
- }
- }.resume()
- }
-}
-
-extension HTTPURLResponse {
- func isSuccessful() -> Bool {
- return statusCode >= 200 && statusCode <= 299
- }
-}
-
-extension Dictionary {
- func percentEncoded() -> Data? {
- return map { key, value in
- let escapedKey = "\(key)".addingPercentEncoding(withAllowedCharacters: .urlQueryValueAllowed) ?? ""
- let escapedValue = "\(value)".addingPercentEncoding(withAllowedCharacters: .urlQueryValueAllowed) ?? ""
- return escapedKey + "=" + escapedValue
- }
- .joined(separator: "&")
- .data(using: .utf8)
- }
-}
-
-extension CharacterSet {
- static let urlQueryValueAllowed: CharacterSet = {
- let generalDelimitersToEncode = ":#[]@" // does not include "?" or "/" due to RFC 3986 - Section 3.4
- let subDelimitersToEncode = "!$&'()*+,;="
-
- var allowed = CharacterSet.urlQueryAllowed
- allowed.remove(charactersIn: "\(generalDelimitersToEncode)\(subDelimitersToEncode)")
- return allowed
- }()
-}