Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added TTL renewal of credentials in CredentialsManager [SDK-1818] #399

Merged
merged 6 commits into from
Jul 27, 2020
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 43 additions & 17 deletions Auth0/CredentialsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,10 @@ public struct CredentialsManager {
return !self.hasExpired(credentials) || 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 +141,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: Float = 0, callback: @escaping (CredentialsManagerError?, Credentials?) -> Void) {
Widcket marked this conversation as resolved.
Show resolved Hide resolved
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: Float = 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) }
private func retrieveCredentials(withScope scope: String?, minTTL: Float, 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) }
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 +186,28 @@ 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) {
// TODO: On the next major add a new case to CredentialsManagerError
Widcket marked this conversation as resolved.
Show resolved Hide resolved
let error = NSError(domain: "The lifetime of the renewed Access Token is less than minTTL", code: -99999, userInfo: nil)
lbalmaceda marked this conversation as resolved.
Show resolved Hide resolved
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: Float) -> 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 +219,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
}

}
134 changes: 124 additions & 10 deletions Auth0Tests/CredentialsManagerSpec.swift
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ class CredentialsManagerSpec: QuickSpec {

}

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

afterEach {
_ = credentialsManager.clear()
Expand Down Expand Up @@ -261,11 +261,19 @@ class CredentialsManagerSpec: QuickSpec {
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))
let ttl = Float((ExpiresIn - 100) / 1000)
expect(credentialsManager.willExpire(credentials, within: ttl)).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))
let ttl = Float((ExpiresIn + 100) / 1000)
expect(credentialsManager.willExpire(credentials, within: ttl)).to(beTrue())
}

it("should not be expired when expiry of at is + 1 hour") {
Expand All @@ -287,10 +295,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")
lbalmaceda marked this conversation as resolved.
Show resolved Hide resolved
expect(credentialsManager.hasScopeChanged(credentials, than: "openid email offline_access")).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 +477,99 @@ 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), scope: "openid profile")
_ = 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")
lbalmaceda marked this conversation as resolved.
Show resolved Hide resolved
_ = credentialsManager.store(credentials: credentials)
waitUntil(timeout: 2) { done in
credentialsManager.credentials(withScope: "openid profile offline_access", 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
let ttl = Float((ExpiresIn - 100) / 1000)
credentialsManager.credentials(withScope: nil, minTTL: ttl) { 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
let ttl = Float((ExpiresIn + 100) / 1000)
credentialsManager.credentials(withScope: nil, minTTL: ttl) { 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") {
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
Widcket marked this conversation as resolved.
Show resolved Hide resolved
expect(error).toNot(beNil())
lbalmaceda marked this conversation as resolved.
Show resolved Hide resolved
expect(newCredentials).to(beNil())
done()
}
}
}

}

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