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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user