TF-2384 Handle refresh token when token expired in NSE

Signed-off-by: dab246 <tdvu@linagora.com>
(cherry picked from commit c8a39bb42792770c001ac90d6301d53126225d95)
This commit is contained in:
dab246
2023-12-24 21:45:42 +07:00
committed by Dat H. Pham
parent 5bf1b97de3
commit b1b2bb90a5
19 changed files with 416 additions and 37 deletions
@@ -0,0 +1,7 @@
import Foundation
extension Date {
func isBefore(_ otherDate: Date) -> Bool {
return self < otherDate
}
}
@@ -0,0 +1,12 @@
import Foundation
extension String {
func convertISO8601StringToDate() -> Date? {
let dateFormatter = ISO8601DateFormatter()
dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = dateFormatter.date(from: self) {
return date
}
return nil
}
}
+37 -17
View File
@@ -3,13 +3,13 @@ import Alamofire
class JmapClient {
static let shared: JmapClient = JmapClient()
private let jmapHeader = HTTPHeader(name: "Accept", value: "application/json;jmapVersion=rfc-8621")
private func getBasicAuthorization(basicAuth: String) -> String {
return "Basic \(basicAuth)"
}
private var authentication: Authentication?
private var tokenRefreshManager: TokenRefreshManager?
private var authenticationInterceptor: AuthenticationInterceptor?
func getNewEmails(
apiUrl: String,
accountId: String,
@@ -17,27 +17,47 @@ class JmapClient {
authenticationType: AuthenticationType,
tokenOidc: TokenOidc?,
basicAuth: String?,
tokenEndpointUrl: String?,
oidcScopes: [String]?,
onSuccess: @escaping ([Email]) -> Void,
onFailure: @escaping (Error) -> Void
) {
let authenticationValue = authenticationType == AuthenticationType.basic
? getBasicAuthorization(basicAuth: basicAuth ?? "")
: tokenOidc?.getAuthorization() ?? ""
let headers = HTTPHeaders([
jmapHeader,
HTTPHeader(name: "Authorization", value: authenticationValue)
])
if (authenticationType == AuthenticationType.basic) {
authentication = AuthenticationCredential(
type: AuthenticationType.basic,
basicAuth: basicAuth ?? ""
)
} else {
authentication = AuthenticationSSO(
type: AuthenticationType.oidc,
accessToken: tokenOidc?.token ?? "",
refreshToken: tokenOidc?.refreshToken ?? "",
expireTime: tokenOidc?.expiredTime
)
tokenRefreshManager = TokenRefreshManager(
refreshToken: tokenOidc?.refreshToken ?? "",
tokenEndpoint: tokenEndpointUrl ?? "",
scopes: oidcScopes
)
}
authenticationInterceptor = AuthenticationInterceptor(
authentication: authentication,
accountId: accountId,
tokenRefreshManager: tokenRefreshManager
)
let jmapRequestObject = JmapRequestGenerator.shared.createEmailChangesRequest(
accountId: accountId,
sinceState: sinceState
)
AlamofireService.shared.post(
url: apiUrl,
payloadData: jmapRequestObject?.toData(),
headers: headers,
headers: HTTPHeaders([jmapHeader]),
interceptor: authenticationInterceptor,
onSuccess: { (data: JmapResponseObject<Email>) in
if let listEmail = data.parsing(methodName: JmapConstants.EMAIL_GET_METHOD_NAME, methodCallId: "c1"), !listEmail.isEmpty {
onSuccess(listEmail)
@@ -15,4 +15,6 @@ class JmapConstants {
"preview",
"from"
]
static let EMAIL_ID = "email_id"
}
+2 -1
View File
@@ -8,6 +8,7 @@ class AlamofireService {
url: String,
payloadData: Data?,
headers: HTTPHeaders? = nil,
interceptor: RequestInterceptor? = nil,
onSuccess: @escaping (T) -> Void,
onFailure: @escaping (Error) -> Void
) {
@@ -18,7 +19,7 @@ class AlamofireService {
request.httpBody = payloadData
AF.request(request).responseDecodable(of: T.self) { response in
AF.request(request, interceptor: interceptor).validate().responseDecodable(of: T.self) { response in
switch(response.result) {
case .success(let data):
onSuccess(data)
@@ -0,0 +1,81 @@
import Alamofire
import Foundation
class AuthenticationInterceptor: RequestInterceptor {
var authentication: Authentication?
let accountId: String
var tokenRefreshManager: TokenRefreshManager?
private lazy var keychainController = KeychainController(service: .sessions,
accessGroup: InfoPlistReader.main.keychainAccessGroupIdentifier)
init(authentication: Authentication?, accountId: String, tokenRefreshManager: TokenRefreshManager?) {
self.authentication = authentication
self.accountId = accountId
self.tokenRefreshManager = tokenRefreshManager
}
// MARK: - Adapt: Called before request to set request headers
func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, Error>) -> Void) {
var modifiedRequest = urlRequest
if (authentication != nil) {
modifiedRequest.addValue(authentication!.getAuthenticationHeader(), forHTTPHeaderField: "Authorization")
}
completion(.success(modifiedRequest))
}
// MARK: - Retry: Called when status code is not 200...299 or in failure
func retry(_ request: Request, for session: Session, dueTo error: Error, completion: @escaping (RetryResult) -> Void) {
guard let response = request.task?.response as? HTTPURLResponse,
let authenticationSSO = authentication as? AuthenticationSSO,
validateToRefreshToken(response: response, authenticationSSO: authenticationSSO),
let tokenRefreshManager else {
return completion(.doNotRetryWithError(error))
}
handleRefreshToken(tokenRefreshManager: tokenRefreshManager) { tokenResponse in
guard let accessToken = tokenResponse.accessToken,
let refreshToken = tokenResponse.refreshToken else {
return completion(.doNotRetryWithError(error))
}
self.authentication = AuthenticationSSO(
type: AuthenticationType.oidc,
accessToken: accessToken,
refreshToken: refreshToken,
expireTime: "\(tokenResponse.expiresTime ?? 0)"
)
self.keychainController.updateTokenOidc(
accountId: self.accountId,
newTokenOidc: TokenOidc(
token: accessToken,
tokenId: tokenResponse.tokenId,
expiredTime: "\(tokenResponse.expiresTime ?? 0)",
refreshToken: refreshToken
)
)
return completion(.retry)
} onFailure: { error in
return completion(.doNotRetryWithError(error))
}
}
// MARK: - Check the conditions to perform token refresh
private func validateToRefreshToken(response: HTTPURLResponse, authenticationSSO: AuthenticationSSO) -> Bool {
return response.statusCode == 401 &&
!authenticationSSO.refreshToken.isEmpty &&
authenticationSSO.isExpiredTime()
}
// MARK: - Handle refresh token to get new token
private func handleRefreshToken(tokenRefreshManager: TokenRefreshManager,
onSuccess: @escaping (TokenResponse) -> Void,
onFailure: @escaping (Error) -> Void) {
tokenRefreshManager.handleRefreshAccessToken(onSuccess: onSuccess, onFailure: onFailure)
}
}
@@ -0,0 +1,7 @@
import Foundation
protocol Authentication {
var type: AuthenticationType { get }
func getAuthenticationHeader() -> String
}
@@ -0,0 +1,16 @@
import Foundation
struct AuthenticationCredential: Authentication {
var type: AuthenticationType
let basicAuth: String
init(type: AuthenticationType, basicAuth: String) {
self.type = type
self.basicAuth = basicAuth
}
func getAuthenticationHeader() -> String {
return "Basic \(basicAuth)"
}
}
@@ -0,0 +1,33 @@
import Foundation
struct AuthenticationSSO: Authentication {
var type: AuthenticationType
let accessToken: String
let refreshToken: String
let expireTime: String?
init(type: AuthenticationType, accessToken: String, refreshToken: String, expireTime: String?) {
self.type = type
self.accessToken = accessToken
self.refreshToken = refreshToken
self.expireTime = expireTime
}
func getAuthenticationHeader() -> String {
return "Bearer \(accessToken)"
}
func isExpiredTime() -> Bool {
guard let expireTime else {
return false
}
guard let expireDate = expireTime.convertISO8601StringToDate(),
expireDate.isBefore(Date.now) else {
return true
}
return false
}
}
+1 -7
View File
@@ -2,13 +2,7 @@ import Foundation
struct TokenOidc: Codable {
let token: String
let tokenId: String
let tokenId: String?
let expiredTime: String?
let refreshToken: String
}
extension TokenOidc {
func getAuthorization() -> String {
return "Bearer \(token)"
}
}
@@ -0,0 +1,25 @@
import Foundation
struct TokenResponse: Codable {
let accessToken: String?
let refreshToken: String?
let expiresTime: Int?
let refreshExpiresIn: Int?
let tokenId: String?
let tokenType: String?
let scope: String?
let sessionState: String?
let error: String?
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case refreshToken = "refresh_token"
case expiresTime = "expires_in"
case refreshExpiresIn = "refresh_expires_in"
case tokenId = "id_token"
case tokenType = "token_type"
case scope
case sessionState = "session_state"
case error
}
}
@@ -0,0 +1,69 @@
import Foundation
import Alamofire
class TokenRefreshManager {
private let MOBIE_CLIENT_ID = "teammail-mobile"
private let MOBIE_REDIRECT_URL = "teammail.mobile://oauthredirect"
private let OIDC_SCOPES = ["openid", "profile", "email", "offline_access"]
private let GRANT_TYPE = "grant_type"
private let REFRESH_TOKEN = "refresh_token"
private let CLIENT_ID = "client_id"
private let REDIRECT_URI = "redirect_uri"
private let SCOPES = "scopes"
let refreshToken: String
let tokenEndpoint: String
let scopes: [String]?
init(refreshToken: String, tokenEndpoint: String, scopes: [String]?) {
self.refreshToken = refreshToken
self.tokenEndpoint = tokenEndpoint
self.scopes = scopes
}
private func getScopes() -> String {
if let scopes, !scopes.isEmpty {
return scopes.joined(separator: " ")
}
return OIDC_SCOPES.joined(separator: " ")
}
func handleRefreshAccessToken(
onSuccess: @escaping (TokenResponse) -> Void,
onFailure: @escaping (Error) -> Void
) {
guard let tokenEndpointUrl = URL(string: tokenEndpoint),
var request = try? URLRequest(url: tokenEndpointUrl, method: .post) else {
return onFailure(NetworkExceptions.requestUrlInvalid)
}
let params = [
CLIENT_ID: MOBIE_CLIENT_ID,
GRANT_TYPE: REFRESH_TOKEN,
REDIRECT_URI: MOBIE_REDIRECT_URL,
REFRESH_TOKEN: refreshToken,
SCOPES: getScopes(),
]
let data = params
.map { "\($0)=\($1)" }
.joined(separator: "&")
.data(using: .utf8)
request.httpBody = data
AF.request(request).responseDecodable(of: TokenResponse.self) { response in
if (response.response?.statusCode != 200) {
onFailure(NetworkExceptions(value: "Failed to get token: \(response.error?.localizedDescription ?? "Unknown Error")"))
} else {
switch(response.result) {
case .success(let data):
onSuccess(data)
case .failure(let error):
onFailure(NetworkExceptions(value: "Failed to get token \(error.localizedDescription)"))
}
}
}
}
}