Skip to content

Commit

Permalink
Added TTL renewal of credentials in CredentialsManager [SDK-1818] (#399)
Browse files Browse the repository at this point in the history
* Implement forced renewal of credentials

* Address review feedback

* Use int instead of float

* Address review feedback

* Swapped scope values

* Add doc comment
  • Loading branch information
Widcket committed Jul 27, 2020
1 parent b2c1414 commit 2874a39
Show file tree
Hide file tree
Showing 2 changed files with 196 additions and 35 deletions.
78 changes: 53 additions & 25 deletions Auth0/CredentialsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,21 +119,19 @@ public struct CredentialsManager {

/// Checks if a non-expired set of credentials are stored
///
/// - Returns: if there are valid and non-expired credentials stored
public func hasValid() -> Bool {
guard
let data = self.storage.data(forKey: self.storeKey),
/// - Parameter minTTL: minimum lifetime in seconds the access token must have left.
/// - Returns: if there are valid and non-expired credentials stored.
public func hasValid(minTTL: Int = 0) -> Bool {
guard let data = self.storage.data(forKey: self.storeKey),
let credentials = NSKeyedUnarchiver.unarchiveObject(with: data) as? Credentials,
credentials.accessToken != nil
else { return false }
return !self.hasExpired(credentials) || credentials.refreshToken != nil
credentials.accessToken != nil else { return false }
return (!self.hasExpired(credentials) && !self.willExpire(credentials, within: minTTL)) || credentials.refreshToken != nil
}

/// Retrieve credentials from keychain and yield new credentials using refreshToken if accessToken has expired
/// Retrieve credentials from keychain and yield new credentials using `refreshToken` if `accessToken` has expired
/// otherwise the retrieved credentails will be returned as they have not expired. Renewed credentials will be
/// stored in the keychain.
///
///
/// ```
/// credentialsManager.credentials {
/// guard $0 == nil else { return }
Expand All @@ -142,39 +140,40 @@ public struct CredentialsManager {
/// ```
///
/// - Parameters:
/// - scope: scopes to request for the new tokens. By default is nil which will ask for the same ones requested during original Auth
/// - scope: scopes to request for the new tokens. By default is nil which will ask for the same ones requested during original Auth.
/// - minTTL: minimum time in seconds the access token must remain valid to avoid being renewed.
/// - callback: callback with the user's credentials or the cause of the error.
/// - Important: This method only works for a refresh token obtained after auth with OAuth 2.0 API Authorization.
/// - Note: [Auth0 Refresh Tokens Docs](https://auth0.com/docs/tokens/refresh-token)
/// - Note: [Auth0 Refresh Tokens Docs](https://auth0.com/docs/tokens/concepts/refresh-tokens)
#if WEB_AUTH_PLATFORM
public func credentials(withScope scope: String? = nil, callback: @escaping (CredentialsManagerError?, Credentials?) -> Void) {
public func credentials(withScope scope: String? = nil, minTTL: Int = 0, callback: @escaping (CredentialsManagerError?, Credentials?) -> Void) {
guard self.hasValid() else { return callback(.noCredentials, nil) }
if #available(iOS 9.0, macOS 10.15, *), let bioAuth = self.bioAuth {
guard bioAuth.available else { return callback(.touchFailed(LAError(LAError.touchIDNotAvailable)), nil) }
bioAuth.validateBiometric {
guard $0 == nil else {
return callback(.touchFailed($0!), nil)
}
self.retrieveCredentials(withScope: scope, callback: callback)
self.retrieveCredentials(withScope: scope, minTTL: minTTL, callback: callback)
}
} else {
self.retrieveCredentials(withScope: scope, callback: callback)
self.retrieveCredentials(withScope: scope, minTTL: minTTL, callback: callback)
}
}
#else
public func credentials(withScope scope: String? = nil, callback: @escaping (CredentialsManagerError?, Credentials?) -> Void) {
public func credentials(withScope scope: String? = nil, minTTL: Int = 0, callback: @escaping (CredentialsManagerError?, Credentials?) -> Void) {
guard self.hasValid() else { return callback(.noCredentials, nil) }
self.retrieveCredentials(withScope: scope, callback: callback)
self.retrieveCredentials(withScope: scope, minTTL: minTTL, callback: callback)
}
#endif

private func retrieveCredentials(withScope scope: String? = nil, callback: @escaping (CredentialsManagerError?, Credentials?) -> Void) {
guard
let data = self.storage.data(forKey: self.storeKey),
let credentials = NSKeyedUnarchiver.unarchiveObject(with: data) as? Credentials
else { return callback(.noCredentials, nil) }
guard credentials.expiresIn != nil else { return callback(.noCredentials, nil) }
guard self.hasExpired(credentials) else { return callback(nil, credentials) }
private func retrieveCredentials(withScope scope: String?, minTTL: Int, callback: @escaping (CredentialsManagerError?, Credentials?) -> Void) {
guard let data = self.storage.data(forKey: self.storeKey),
let credentials = NSKeyedUnarchiver.unarchiveObject(with: data) as? Credentials else { return callback(.noCredentials, nil) }
guard let expiresIn = credentials.expiresIn else { return callback(.noCredentials, nil) }
guard self.willExpire(credentials, within: minTTL) ||
self.hasExpired(credentials) ||
self.hasScopeChanged(credentials, than: scope) else { return callback(nil, credentials) }
guard let refreshToken = credentials.refreshToken else { return callback(.noRefreshToken, nil) }

self.authentication.renew(withRefreshToken: refreshToken, scope: scope).start {
Expand All @@ -186,14 +185,31 @@ public struct CredentialsManager {
refreshToken: credentials.refreshToken ?? refreshToken,
expiresIn: credentials.expiresIn,
scope: credentials.scope)
_ = self.store(credentials: newCredentials)
callback(nil, newCredentials)
if self.willExpire(newCredentials, within: minTTL) {
let accessTokenLifetime = Int(expiresIn.timeIntervalSinceNow / 1000)
// TODO: On the next major add a new case to CredentialsManagerError
let error = NSError(domain: "The lifetime of the renewed Access Token (\(accessTokenLifetime)s) is less than minTTL requested (\(minTTL)s). Increase the 'Token Expiration' setting of your Auth0 API in the dashboard or request a lower minTTL",
code: -99999,
userInfo: nil)
callback(.failedRefresh(error), nil)
} else {
_ = self.store(credentials: newCredentials)
callback(nil, newCredentials)
}
case .failure(let error):
callback(.failedRefresh(error), nil)
}
}
}

func willExpire(_ credentials: Credentials, within ttl: Int) -> Bool {
if let expiresIn = credentials.expiresIn {
return expiresIn < Date(timeIntervalSinceNow: TimeInterval(ttl * 1000))
}

return false
}

func hasExpired(_ credentials: Credentials) -> Bool {
if let expiresIn = credentials.expiresIn {
if expiresIn < Date() { return true }
Expand All @@ -205,4 +221,16 @@ public struct CredentialsManager {

return false
}

func hasScopeChanged(_ credentials: Credentials, than scope: String?) -> Bool {
if let newScope = scope, let lastScope = credentials.scope {
let newScopeList = newScope.lowercased().split(separator: " ").sorted()
let lastScopeList = lastScope.lowercased().split(separator: " ").sorted()

return newScopeList != lastScopeList
}

return false
}

}
153 changes: 143 additions & 10 deletions Auth0Tests/CredentialsManagerSpec.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ private let NewIdToken = UUID().uuidString.replacingOccurrences(of: "-", with: "
private let RefreshToken = UUID().uuidString.replacingOccurrences(of: "-", with: "")
private let NewRefreshToken = UUID().uuidString.replacingOccurrences(of: "-", with: "")
private let ExpiresIn: TimeInterval = 3600
private let ValidTTL = Int((ExpiresIn - 1000) / 1000)
private let InvalidTTL = Int((ExpiresIn + 1000) / 1000)
private let ClientId = "CLIENT_ID"
private let Domain = "samples.auth0.com"
private let ExpiredToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiIiLCJpYXQiOjE1NzE4NTI0NjMsImV4cCI6MTU0MDIzMDA2MywiYXVkIjoiYXVkaWVuY2UiLCJzdWIiOiIxMjM0NSJ9.Lcz79P1AFAZDI4Yr1teFapFVAmBbdfhGBGbj9dQVeRM"
Expand Down Expand Up @@ -222,7 +224,7 @@ class CredentialsManagerSpec: QuickSpec {

}

describe("valididity") {
describe("validity") {

afterEach {
_ = credentialsManager.clear()
Expand Down Expand Up @@ -255,17 +257,41 @@ class CredentialsManagerSpec: QuickSpec {
expect(credentialsManager.hasValid()).to(beFalse())
}

it("should have valid credentials when the ttl is less than the token lifetime") {
let credentials = Credentials(accessToken: AccessToken, tokenType: TokenType, idToken: IdToken, refreshToken: RefreshToken, expiresIn: Date(timeIntervalSinceNow: ExpiresIn))
expect(credentialsManager.store(credentials: credentials)).to(beTrue())
expect(credentialsManager.hasValid(minTTL: ValidTTL)).to(beTrue())
}

it("should have valid credentials when the ttl is greater than the token lifetime and refresh token present") {
let credentials = Credentials(accessToken: AccessToken, tokenType: TokenType, idToken: IdToken, refreshToken: RefreshToken, expiresIn: Date(timeIntervalSinceNow: ExpiresIn))
expect(credentialsManager.store(credentials: credentials)).to(beTrue())
expect(credentialsManager.hasValid(minTTL: InvalidTTL)).to(beTrue())
}

it("should not have valid credentials when the ttl is greater than the token lifetime and no refresh token present") {
let credentials = Credentials(accessToken: AccessToken, tokenType: TokenType, idToken: IdToken, refreshToken: nil, expiresIn: Date(timeIntervalSinceNow: ExpiresIn))
expect(credentialsManager.store(credentials: credentials)).to(beTrue())
expect(credentialsManager.hasValid(minTTL: InvalidTTL)).to(beFalse())
}

it("should not have valid credentials when no access token") {
let credentials = Credentials(accessToken: nil, tokenType: TokenType, idToken: IdToken, refreshToken: RefreshToken, expiresIn: Date(timeIntervalSinceNow: ExpiresIn))
expect(credentialsManager.store(credentials: credentials)).to(beTrue())
expect(credentialsManager.hasValid()).to(beFalse())
}
}

describe("validity expiry") {

afterEach {
_ = credentialsManager.clear()

describe("expiry") {

it("should not expire soon when the min ttl is less than the at expiry") {
let credentials = Credentials(accessToken: AccessToken, tokenType: TokenType, idToken: nil, refreshToken: nil, expiresIn: Date(timeIntervalSinceNow: ExpiresIn))
expect(credentialsManager.willExpire(credentials, within: ValidTTL)).to(beFalse())
}

it("should expire soon when the min ttl is greater than the at expiry") {
let credentials = Credentials(accessToken: AccessToken, tokenType: TokenType, idToken: nil, refreshToken: nil, expiresIn: Date(timeIntervalSinceNow: ExpiresIn))
expect(credentialsManager.willExpire(credentials, within: InvalidTTL)).to(beTrue())
}

it("should not be expired when expiry of at is + 1 hour") {
Expand All @@ -287,10 +313,25 @@ class CredentialsManagerSpec: QuickSpec {
let credentials = Credentials(accessToken: AccessToken, tokenType: TokenType, idToken: ExpiredToken, refreshToken: nil, expiresIn: Date(timeIntervalSinceNow: ExpiresIn))
expect(credentialsManager.hasExpired(credentials)).to(beTrue())
}

}

describe("validity with id token") {

describe("scope") {

it("should return true when the scope has changed") {
let credentials = Credentials(scope: "openid email profile")
expect(credentialsManager.hasScopeChanged(credentials, than: "openid email")).to(beTrue())
}

it("should return false when the scope has not changed") {
let credentials = Credentials(scope: "openid email")
expect(credentialsManager.hasScopeChanged(credentials, than: "openid email")).to(beFalse())
expect(credentialsManager.hasScopeChanged(credentials, than: "email openid")).to(beFalse())
}

}

describe("id token") {

afterEach {
_ = credentialsManager.clear()
Expand Down Expand Up @@ -454,8 +495,100 @@ class CredentialsManagerSpec: QuickSpec {
}
}
}

}


context("forced renew") {

it("should not yield a new access token by default") {
credentials = Credentials(accessToken: AccessToken, refreshToken: RefreshToken, expiresIn: Date(timeIntervalSinceNow: ExpiresIn))
_ = credentialsManager.store(credentials: credentials)
waitUntil(timeout: 2) { done in
credentialsManager.credentials { error, newCredentials in
expect(error).to(beNil())
expect(newCredentials?.accessToken) == AccessToken
done()
}
}
}

it("should not yield a new access token without a new scope") {
credentials = Credentials(accessToken: AccessToken, refreshToken: RefreshToken, expiresIn: Date(timeIntervalSinceNow: ExpiresIn), scope: "openid profile")
_ = credentialsManager.store(credentials: credentials)
waitUntil(timeout: 2) { done in
credentialsManager.credentials(withScope: nil, minTTL: 0) { error, newCredentials in
expect(error).to(beNil())
expect(newCredentials?.accessToken) == AccessToken
done()
}
}
}

it("should not yield a new access token with the same scope") {
credentials = Credentials(accessToken: AccessToken, refreshToken: RefreshToken, expiresIn: Date(timeIntervalSinceNow: ExpiresIn), scope: "openid profile")
_ = credentialsManager.store(credentials: credentials)
waitUntil(timeout: 2) { done in
credentialsManager.credentials(withScope: "openid profile", minTTL: 0) { error, newCredentials in
expect(error).to(beNil())
expect(newCredentials?.accessToken) == AccessToken
done()
}
}
}

it("should yield a new access token with a new scope") {
credentials = Credentials(accessToken: AccessToken, refreshToken: RefreshToken, expiresIn: Date(timeIntervalSinceNow: ExpiresIn), scope: "openid profile offline_access")
_ = credentialsManager.store(credentials: credentials)
waitUntil(timeout: 2) { done in
credentialsManager.credentials(withScope: "openid profile", minTTL: 0) { error, newCredentials in
expect(error).to(beNil())
expect(newCredentials?.accessToken) == NewAccessToken
done()
}
}
}

it("should not yield a new access token with a min ttl less than its expiry") {
credentials = Credentials(accessToken: AccessToken, refreshToken: RefreshToken, expiresIn: Date(timeIntervalSinceNow: ExpiresIn))
_ = credentialsManager.store(credentials: credentials)
waitUntil(timeout: 2) { done in
credentialsManager.credentials(withScope: nil, minTTL: ValidTTL) { error, newCredentials in
expect(error).to(beNil())
expect(newCredentials?.accessToken) == AccessToken
done()
}
}
}

it("should yield a new access token with a min ttl greater than its expiry") {
credentials = Credentials(accessToken: AccessToken, refreshToken: RefreshToken, expiresIn: Date(timeIntervalSinceNow: ExpiresIn))
_ = credentialsManager.store(credentials: credentials)
waitUntil(timeout: 2) { done in
credentialsManager.credentials(withScope: nil, minTTL: InvalidTTL) { error, newCredentials in
expect(error).to(beNil())
expect(newCredentials?.accessToken) == NewAccessToken
done()
}
}
}

it("should fail to yield a renewed access token with a min ttl greater than its expiry") {
let expectedError = CredentialsManagerError.failedRefresh(NSError(domain: "The lifetime of the renewed Access Token (\(ExpiresIn / 1000)s) is less than minTTL requested (\(100)s). Increase the 'Token Expiration' setting of your Auth0 API in the dashboard or request a lower minTTL",
code: -99999,
userInfo: nil))
credentials = Credentials(accessToken: AccessToken, refreshToken: RefreshToken, expiresIn: Date(timeIntervalSinceNow: ExpiresIn))
_ = credentialsManager.store(credentials: credentials)
waitUntil(timeout: 2) { done in
credentialsManager.credentials(withScope: nil, minTTL: 100) { error, newCredentials in
expect(error).to(matchError(expectedError))
expect(newCredentials).to(beNil())
done()
}
}
}

}

context("custom keychain") {
let storage = A0SimpleKeychain(service: "test_service")
beforeEach {
Expand Down

0 comments on commit 2874a39

Please sign in to comment.