Skip to content

Commit

Permalink
allow to pass minTTL and scope to the SecureCredentialsManager
Browse files Browse the repository at this point in the history
  • Loading branch information
lbalmaceda committed Nov 4, 2020
1 parent 23f1da6 commit 23595fb
Show file tree
Hide file tree
Showing 2 changed files with 219 additions and 26 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@
import com.auth0.android.callback.AuthenticationCallback;
import com.auth0.android.callback.BaseCallback;
import com.auth0.android.jwt.JWT;
import com.auth0.android.request.ParameterizableRequest;
import com.auth0.android.request.internal.GsonProvider;
import com.auth0.android.result.Credentials;
import com.auth0.android.util.Clock;
import com.google.gson.Gson;

import java.util.Arrays;
import java.util.Date;

import static android.text.TextUtils.isEmpty;
Expand Down Expand Up @@ -61,6 +63,8 @@ public class SecureCredentialsManager {
//State for retrying operations
private BaseCallback<Credentials, CredentialsManagerException> decryptCallback;
private Intent authIntent;
private String scope;
private int minTtl;


@VisibleForTesting
Expand Down Expand Up @@ -140,7 +144,7 @@ public boolean checkAuthenticationResult(int requestCode, int resultCode) {
return false;
}
if (resultCode == Activity.RESULT_OK) {
continueGetCredentials(decryptCallback);
continueGetCredentials(scope, minTtl, decryptCallback);
} else {
decryptCallback.onFailure(new CredentialsManagerException("The user didn't pass the authentication challenge."));
decryptCallback = null;
Expand All @@ -160,17 +164,7 @@ public void saveCredentials(@NonNull Credentials credentials) throws Credentials
throw new CredentialsManagerException("Credentials must have a valid date of expiration and a valid access_token or id_token value.");
}

long expiresAt = credentials.getExpiresAt().getTime();

if (credentials.getIdToken() != null) {
JWT idToken = jwtDecoder.decode(credentials.getIdToken());
Date idTokenExpiresAtDate = idToken.getExpiresAt();

if (idTokenExpiresAtDate != null) {
expiresAt = Math.min(idTokenExpiresAtDate.getTime(), expiresAt);
}
}

long expiresAt = calculateExpiresAt(credentials);
String json = gson.toJson(credentials);
boolean canRefresh = !isEmpty(credentials.getRefreshToken());

Expand Down Expand Up @@ -207,18 +201,37 @@ public void saveCredentials(@NonNull Credentials credentials) throws Credentials
* @param callback the callback to receive the result in.
*/
public void getCredentials(@NonNull BaseCallback<Credentials, CredentialsManagerException> callback) {
getCredentials(null, 0, callback);
}

/**
* Tries to obtain the credentials from the Storage. The callback's {@link BaseCallback#onSuccess(Object)} method will be called with the result.
* If something unexpected happens, the {@link BaseCallback#onFailure(Auth0Exception)} method will be called with the error. Some devices are not compatible
* at all with the cryptographic implementation and will have {@link CredentialsManagerException#isDeviceIncompatible()} return true.
* <p>
* If a LockScreen is setup and {@link #requireAuthentication(Activity, int, String, String)} was called, the user will be asked to authenticate before accessing
* the credentials. Your activity must override the {@link Activity#onActivityResult(int, int, Intent)} method and call
* {@link #checkAuthenticationResult(int, int)} with the received values.
*
* @param scope the scope to request for the access token. If null is passed, the previous scope will be kept.
* @param minTtl the minimum time in seconds that both the access token and ID token should last before expiration.
* @param callback the callback to receive the result in.
*/
public void getCredentials(@Nullable String scope, int minTtl, @NonNull BaseCallback<Credentials, CredentialsManagerException> callback) {
if (!hasValidCredentials()) {
callback.onFailure(new CredentialsManagerException("No Credentials were previously set."));
return;
}

if (authenticateBeforeDecrypt) {
Log.d(TAG, "Authentication is required to read the Credentials. Showing the LockScreen.");
decryptCallback = callback;
this.decryptCallback = callback;
this.scope = scope;
this.minTtl = minTtl;
activity.startActivityForResult(authIntent, authenticationRequestCode);
return;
}
continueGetCredentials(callback);
continueGetCredentials(scope, minTtl, callback);
}

/**
Expand Down Expand Up @@ -247,7 +260,7 @@ public boolean hasValidCredentials() {
expiresAt <= getCurrentTimeInMillis() && (canRefresh == null || !canRefresh));
}

private void continueGetCredentials(final BaseCallback<Credentials, CredentialsManagerException> callback) {
private void continueGetCredentials(@Nullable String scope, final int minTtl, final BaseCallback<Credentials, CredentialsManagerException> callback) {
String encryptedEncoded = storage.retrieveString(KEY_CREDENTIALS);
byte[] encrypted = Base64.decode(encryptedEncoded, Base64.DEFAULT);

Expand All @@ -268,12 +281,17 @@ private void continueGetCredentials(final BaseCallback<Credentials, CredentialsM
}
final Credentials credentials = gson.fromJson(json, Credentials.class);
Long expiresAt = storage.retrieveLong(KEY_EXPIRES_AT);
if (isEmpty(credentials.getAccessToken()) && isEmpty(credentials.getIdToken()) || expiresAt == null) {
boolean hasEmptyCredentials = isEmpty(credentials.getAccessToken()) && isEmpty(credentials.getIdToken()) || expiresAt == null;
if (hasEmptyCredentials) {
callback.onFailure(new CredentialsManagerException("No Credentials were previously set."));
decryptCallback = null;
return;
}
if (expiresAt > getCurrentTimeInMillis()) {

boolean willExpire = willExpire(expiresAt, minTtl);
boolean scopeChanged = hasScopeChanged(credentials.getScope(), scope);

if (!willExpire && !scopeChanged) {
callback.onSuccess(credentials);
decryptCallback = null;
return;
Expand All @@ -285,11 +303,24 @@ private void continueGetCredentials(final BaseCallback<Credentials, CredentialsM
}

Log.d(TAG, "Credentials have expired. Renewing them now...");
apiClient.renewAuth(credentials.getRefreshToken()).start(new AuthenticationCallback<Credentials>() {
ParameterizableRequest<Credentials, AuthenticationException> request = apiClient.renewAuth(credentials.getRefreshToken());
if (scope != null) {
request.addParameter("scope", scope);
}
request.start(new AuthenticationCallback<Credentials>() {
@Override
public void onSuccess(@Nullable Credentials fresh) {
long nextExpiresAt = calculateExpiresAt(fresh);
boolean willExpire = willExpire(nextExpiresAt, minTtl);
if (willExpire) {
long tokenLifetime = (nextExpiresAt - getCurrentTimeInMillis() - minTtl * 1000) / -1000;
CredentialsManagerException wrongTtlException = new CredentialsManagerException(String.format("The lifetime of the renewed Access Token or Id Token (%d) is less than the minTTL requested (%d). Increase the 'Token Expiration' setting of your Auth0 API or the 'ID Token Expiration' of your Auth0 Application in the dashboard, or request a lower minTTL.", tokenLifetime, minTtl));
callback.onFailure(wrongTtlException);
decryptCallback = null;
return;
}

//non-empty refresh token for refresh token rotation scenarios
//noinspection ConstantConditions
String updatedRefreshToken = isEmpty(fresh.getRefreshToken()) ? credentials.getRefreshToken() : fresh.getRefreshToken();
Credentials refreshed = new Credentials(fresh.getIdToken(), fresh.getAccessToken(), fresh.getType(), updatedRefreshToken, fresh.getExpiresAt(), fresh.getScope());
saveCredentials(refreshed);
Expand All @@ -310,4 +341,34 @@ long getCurrentTimeInMillis() {
return clock.getCurrentTimeMillis();
}

private boolean hasScopeChanged(@NonNull String storedScope, @Nullable String requiredScope) {
if (requiredScope == null) {
return false;
}
String[] stored = storedScope.split(" ");
Arrays.sort(stored);
String[] required = requiredScope.split(" ");
Arrays.sort(required);
return stored != required;
}

private boolean willExpire(long expiresAt, long minTtl) {
long nextClock = getCurrentTimeInMillis() + minTtl * 1000;
return expiresAt <= nextClock;
}

private long calculateExpiresAt(Credentials credentials) {
long expiresAt = credentials.getExpiresAt().getTime();

if (credentials.getIdToken() != null) {
JWT idToken = jwtDecoder.decode(credentials.getIdToken());
Date idTokenExpiresAtDate = idToken.getExpiresAt();

if (idTokenExpiresAtDate != null) {
expiresAt = Math.min(idTokenExpiresAtDate.getTime(), expiresAt);
}
}
return expiresAt;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,6 @@ public void shouldThrowOnSaveIfCredentialsDoesNotHaveExpiresAt() {
exception.expectMessage("Credentials must have a valid date of expiration and a valid access_token or id_token value.");

Date date = null;
//noinspection ConstantConditions
Credentials credentials = new CredentialsMock("idToken", "accessToken", "type", "refreshToken", date, "scope");
prepareJwtDecoderMock(new Date());
manager.saveCredentials(credentials);
Expand Down Expand Up @@ -443,21 +442,154 @@ public void shouldGetNonExpiredCredentialsFromStorageWhenOnlyAccessTokenIsAvaila
assertThat(retrievedCredentials.getScope(), is("scope"));
}

@Test
public void shouldRenewCredentialsWithMinTtl() {
Date expiresAt = new Date(CredentialsMock.CURRENT_TIME_MS); // expired credentials
insertTestCredentials(false, true, true, expiresAt);

Date newDate = new Date(CredentialsMock.CURRENT_TIME_MS + 61 * 1000); // new token expires in minTTL + 1 seconds
JWT jwtMock = mock(JWT.class);
when(jwtMock.getExpiresAt()).thenReturn(newDate);
when(jwtDecoder.decode("newId")).thenReturn(jwtMock);
when(client.renewAuth("refreshToken")).thenReturn(request);

manager.getCredentials(null, 60, callback);
verify(request, never()).addParameter(eq("scope"), anyString());
verify(request).start(requestCallbackCaptor.capture());

// Trigger success
Credentials expectedCredentials = new Credentials("newId", "newAccess", "newType", "refreshToken", newDate, "newScope");
String expectedJson = gson.toJson(expectedCredentials);
when(crypto.encrypt(expectedJson.getBytes())).thenReturn(expectedJson.getBytes());
requestCallbackCaptor.getValue().onSuccess(expectedCredentials);
verify(callback).onSuccess(credentialsCaptor.capture());
verify(storage).store(eq("com.auth0.credentials"), stringCaptor.capture());
verify(storage).store("com.auth0.credentials_expires_at", newDate.getTime());
verify(storage).store("com.auth0.credentials_can_refresh", true);
verify(storage, never()).remove(anyString());

// Verify the returned credentials are the latest
Credentials retrievedCredentials = credentialsCaptor.getValue();
assertThat(retrievedCredentials, is(notNullValue()));
assertThat(retrievedCredentials.getIdToken(), is("newId"));
assertThat(retrievedCredentials.getAccessToken(), is("newAccess"));
assertThat(retrievedCredentials.getType(), is("newType"));
assertThat(retrievedCredentials.getRefreshToken(), is("refreshToken"));
assertThat(retrievedCredentials.getExpiresAt(), is(newDate));
assertThat(retrievedCredentials.getScope(), is("newScope"));

// Verify the credentials are property stored
String encodedJson = stringCaptor.getValue();
assertThat(encodedJson, is(notNullValue()));
final byte[] decoded = Base64.decode(encodedJson, Base64.DEFAULT);
Credentials renewedStoredCredentials = gson.fromJson(new String(decoded), Credentials.class);
assertThat(renewedStoredCredentials.getIdToken(), is("newId"));
assertThat(renewedStoredCredentials.getAccessToken(), is("newAccess"));
assertThat(renewedStoredCredentials.getRefreshToken(), is("refreshToken"));
assertThat(renewedStoredCredentials.getType(), is("newType"));
assertThat(renewedStoredCredentials.getExpiresAt(), is(notNullValue()));
assertThat(renewedStoredCredentials.getExpiresAt().getTime(), is(newDate.getTime()));
assertThat(renewedStoredCredentials.getScope(), is("newScope"));
}

@Test
public void shouldGetAndFailToRenewExpiredCredentialsWhenReceivedTokenHasLowerTtl() {
Date expiresAt = new Date(CredentialsMock.CURRENT_TIME_MS); // expired credentials
insertTestCredentials(false, true, true, expiresAt);

Date newDate = new Date(CredentialsMock.CURRENT_TIME_MS + 59 * 1000); // new token expires in minTTL - 1 seconds
JWT jwtMock = mock(JWT.class);
when(jwtMock.getExpiresAt()).thenReturn(newDate);
when(jwtDecoder.decode("newId")).thenReturn(jwtMock);
when(client.renewAuth("refreshToken")).thenReturn(request);

manager.getCredentials(null, 60, callback);
verify(request, never()).addParameter(eq("scope"), anyString());
verify(request).start(requestCallbackCaptor.capture());

// Trigger success
Credentials expectedCredentials = new Credentials("newId", "newAccess", "newType", "refreshToken", newDate, "newScope");
String expectedJson = gson.toJson(expectedCredentials);
when(crypto.encrypt(expectedJson.getBytes())).thenReturn(expectedJson.getBytes());
requestCallbackCaptor.getValue().onSuccess(expectedCredentials);

verify(callback).onFailure(exceptionCaptor.capture());
CredentialsManagerException exception = exceptionCaptor.getValue();
assertThat(exception, is(notNullValue()));
assertThat(exception.getMessage(), is("The lifetime of the renewed Access Token or Id Token (1) is less than the minTTL requested (60). Increase the 'Token Expiration' setting of your Auth0 API or the 'ID Token Expiration' of your Auth0 Application in the dashboard, or request a lower minTTL."));

verify(storage, never()).store(eq("com.auth0.credentials"), anyString());
verify(storage, never()).store(eq("com.auth0.credentials_expires_at"), anyLong());
verify(storage, never()).store(eq("com.auth0.credentials_can_refresh"), anyBoolean());
verify(storage, never()).remove(anyString());
}

@Test
public void shouldRenewCredentialsWhenScopeHasChanged() {
Date expiresAt = new Date(CredentialsMock.CURRENT_TIME_MS + 1234 * 1000); // non expired credentials
insertTestCredentials(false, true, true, expiresAt); // "scope" is set

Date newDate = new Date(CredentialsMock.CURRENT_TIME_MS + 2222 * 1000);
JWT jwtMock = mock(JWT.class);
when(jwtMock.getExpiresAt()).thenReturn(newDate);
when(jwtDecoder.decode("newId")).thenReturn(jwtMock);
when(client.renewAuth("refreshToken")).thenReturn(request);

manager.getCredentials("different scope", 0, callback);
verify(request).addParameter(eq("scope"), eq("different scope"));
verify(request).start(requestCallbackCaptor.capture());

// Trigger success
Credentials expectedCredentials = new Credentials("newId", "newAccess", "newType", "refreshToken", newDate, "different scope");
String expectedJson = gson.toJson(expectedCredentials);
when(crypto.encrypt(expectedJson.getBytes())).thenReturn(expectedJson.getBytes());
requestCallbackCaptor.getValue().onSuccess(expectedCredentials);
verify(callback).onSuccess(credentialsCaptor.capture());
verify(storage).store(eq("com.auth0.credentials"), stringCaptor.capture());
verify(storage).store("com.auth0.credentials_expires_at", newDate.getTime());
verify(storage).store("com.auth0.credentials_can_refresh", true);
verify(storage, never()).remove(anyString());

// Verify the returned credentials are the latest
Credentials retrievedCredentials = credentialsCaptor.getValue();
assertThat(retrievedCredentials, is(notNullValue()));
assertThat(retrievedCredentials.getIdToken(), is("newId"));
assertThat(retrievedCredentials.getAccessToken(), is("newAccess"));
assertThat(retrievedCredentials.getType(), is("newType"));
assertThat(retrievedCredentials.getRefreshToken(), is("refreshToken"));
assertThat(retrievedCredentials.getExpiresAt(), is(newDate));
assertThat(retrievedCredentials.getScope(), is("different scope"));

// Verify the credentials are property stored
String encodedJson = stringCaptor.getValue();
assertThat(encodedJson, is(notNullValue()));
final byte[] decoded = Base64.decode(encodedJson, Base64.DEFAULT);
Credentials renewedStoredCredentials = gson.fromJson(new String(decoded), Credentials.class);
assertThat(renewedStoredCredentials.getIdToken(), is("newId"));
assertThat(renewedStoredCredentials.getAccessToken(), is("newAccess"));
assertThat(renewedStoredCredentials.getRefreshToken(), is("refreshToken"));
assertThat(renewedStoredCredentials.getType(), is("newType"));
assertThat(renewedStoredCredentials.getExpiresAt(), is(notNullValue()));
assertThat(renewedStoredCredentials.getExpiresAt().getTime(), is(newDate.getTime()));
assertThat(renewedStoredCredentials.getScope(), is("different scope"));
}

@Test
public void shouldGetAndSuccessfullyRenewExpiredCredentials() {
Date expiresAt = new Date(CredentialsMock.CURRENT_TIME_MS);
Date expiresAt = new Date(CredentialsMock.CURRENT_TIME_MS); // current time means expired credentials
insertTestCredentials(false, true, true, expiresAt);

Date newDate = new Date(123412341234L);
Date newDate = new Date(CredentialsMock.CURRENT_TIME_MS + 1234 * 1000);
JWT jwtMock = mock(JWT.class);
when(jwtMock.getExpiresAt()).thenReturn(expiresAt);
when(jwtMock.getExpiresAt()).thenReturn(newDate);
when(jwtDecoder.decode("newId")).thenReturn(jwtMock);
when(client.renewAuth("refreshToken")).thenReturn(request);

manager.getCredentials(callback);
verify(request, never()).addParameter(eq("scope"), anyString());
verify(request).start(requestCallbackCaptor.capture());

//Trigger success
// Trigger success
Credentials renewedCredentials = new Credentials("newId", "newAccess", "newType", null, newDate, "newScope");
Credentials expectedCredentials = new Credentials("newId", "newAccess", "newType", "refreshToken", newDate, "newScope");
String expectedJson = gson.toJson(expectedCredentials);
Expand Down Expand Up @@ -498,9 +630,9 @@ public void shouldGetAndSuccessfullyRenewExpiredCredentialsWithRefreshTokenRotat
Date expiresAt = new Date(CredentialsMock.CURRENT_TIME_MS);
insertTestCredentials(false, true, true, expiresAt);

Date newDate = new Date(123412341234L);
Date newDate = new Date(CredentialsMock.CURRENT_TIME_MS + 1234 * 1000);
JWT jwtMock = mock(JWT.class);
when(jwtMock.getExpiresAt()).thenReturn(expiresAt);
when(jwtMock.getExpiresAt()).thenReturn(newDate);
when(jwtDecoder.decode("newId")).thenReturn(jwtMock);
when(client.renewAuth("refreshToken")).thenReturn(request);

Expand Down

0 comments on commit 23595fb

Please sign in to comment.