diff --git a/CHANGELOG.md b/CHANGELOG.md index 867527e49..3a9046f68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Change Log +## [1.9.0](https://github.com/auth0/Auth0.Android/tree/1.9.0) (2017-07-10) +[Full Changelog](https://github.com/auth0/Auth0.Android/compare/1.8.0...1.9.0) + +**Added** +- Add hasValidCredentials and clearCredentials to CredentialsManager [\#102](https://github.com/auth0/Auth0.Android/pull/102) ([lbalmaceda](https://github.com/lbalmaceda)) +- Add granted scope to the Credentials object [\#97](https://github.com/auth0/Auth0.Android/pull/97) ([lbalmaceda](https://github.com/lbalmaceda)) +- Add CredentialsManager and generic Storage [\#96](https://github.com/auth0/Auth0.Android/pull/96) ([lbalmaceda](https://github.com/lbalmaceda)) + +**Changed** +- Use Chrome Custom Tabs when possible [\#95](https://github.com/auth0/Auth0.Android/pull/95) ([lbalmaceda](https://github.com/lbalmaceda)) + ## [1.8.0](https://github.com/auth0/Auth0.Android/tree/1.8.0) (2017-04-27) [Full Changelog](https://github.com/auth0/Auth0.Android/compare/1.7.0...1.8.0) diff --git a/README.md b/README.md index 28d3adf79..1569f3ac4 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,13 @@ Android API version 15 or newer ## Installation -###Gradle +### Gradle Auth0.android is available through [Gradle](https://gradle.org/). To install it, simply add the following line to your `build.gradle` file: ```gradle dependencies { - compile 'com.auth0.android:auth0:1.8.0' + compile 'com.auth0.android:auth0:1.9.0' } ``` @@ -56,6 +56,179 @@ And then create a new Auth0 instance by passing an Android Context: Auth0 account = new Auth0(context); ``` +### OIDC Conformant Mode + +It is strongly encouraged that this SDK be used in OIDC Conformant mode. When this mode is enabled, it will force the SDK to use Auth0's current authentication pipeline and will prevent it from reaching legacy endpoints. By default is `false` + +```java +Auth0 account = new Auth0("{YOUR_CLIENT_ID}", "{YOUR_DOMAIN}"); +//Configure the account in OIDC conformant mode +account.setOIDCConformant(true); +//Use the account in the API clients +``` + +Passwordless authentication *cannot be used* with this flag set to `true`. For more information, please see the [OIDC adoption guide](https://auth0.com/docs/api-auth/tutorials/adoption). + + +### Authentication with Hosted Login Page + +First go to [Auth0 Dashboard](https://manage.auth0.com/#/applications) and go to your application's settings. Make sure you have in *Allowed Callback URLs* a URL with the following format: + +``` +https://{YOUR_AUTH0_DOMAIN}/android/{YOUR_APP_PACKAGE_NAME}/callback +``` + +Remember to replace `{YOUR_APP_PACKAGE_NAME}` with your actual application's package name, available in your `app/build.gradle` file as the `applicationId` value. + + +Next, define a placeholder for the Auth0 Domain which is going to be used internally by the library to register an **intent-filter**. Go to your application's `build.gradle` file and add the `manifestPlaceholders` line as shown below: + +```groovy +apply plugin: 'com.android.application' + +android { + compileSdkVersion 25 + defaultConfig { + applicationId "com.auth0.samples" + minSdkVersion 15 + targetSdkVersion 25 + //... + + //---> Add the next line + manifestPlaceholders = [auth0Domain: "@string/auth0_domain"] + //<--- + } + //... +} +``` + +It's a good practice to define reusable resources like `@string/auth0_domain` but you can also hard code the value in the file. + +Alternatively, you can declare the `RedirectActivity` in the `AndroidManifest.xml` file with your own **intent-filter** so it overrides the library's default. If you do this then the `manifestPlaceholders` don't need to be set as long as the activity contains the `tools:node="replace"` like in the snippet below. If you choose to use a [custom scheme](#a-note-about-app-deep-linking) you must define your own intent-filter as explained below. + +In your manifest inside your application's tag add the `RedirectActivity` declaration: + +```xml + + + + + + + + + + + + + + + + + + + + +``` + +If you request a different scheme you must replace the `android:scheme` property value. Finally, don't forget to add the internet permission. + +```xml + +``` + + +> In versions 1.8.0 and before you had to define the **intent-filter** inside your activity to capture the result in the `onNewIntent` method and call `WebAuthProvider.resume()` with the received intent. This call is no longer required for versions greater than 1.8.0 as it's now done for you by the library. + + +Finally, authenticate by showing the **Auth0 Hosted Login Page**: + +```java +WebAuthProvider.init(account) + .start(MainActivity.this, authCallback); +``` + +If you've followed the configuration steps, the authentication result will be redirected from the browser to your application and you'll receive it in the Callback. + + +##### A note about App Deep Linking: + +Currently, the default scheme used in the Callback Uri is `https`. This works best for Android API 23 or newer if you're using [Android App Links](https://developer.android.com/training/app-links/index.html), but in previous Android versions this may show the intent chooser dialog prompting the user to chose either your application or the browser. You can change this behaviour by using a custom unique scheme, so that the OS opens directly the link with your app. + +1. Update the intent filter in the Android Manifest and change the custom scheme. +2. Update the allowed callback urls in your [Auth0 Dashboard](https://manage.auth0.com/#/applications) client's settings. +3. Call `withScheme()` passing the scheme you want to use. + + +```java +WebAuthProvider.init(account) + .withScheme("myapp") + .start(MainActivity.this, authCallback); +``` + + +#### Authenticate with any Auth0 connection + +```java +WebAuthProvider.init(account) + .withConnection("twitter") + .start(MainActivity.this, authCallback); +``` + +#### Use Code grant with PKCE + +> Before you can use `Code Grant` in Android, make sure to go to your [client's section](https://manage.auth0.com/#/applications) in dashboard and check in the Settings that `Client Type` is `Native`. + + +```java +WebAuthProvider.init(account) + .useCodeGrant(true) + .start(MainActivity.this, authCallback); +``` + +#### Specify audience + +The snippet below requests the "userinfo" audience in order to guarantee OIDC compliant responses from the server. This can also be achieved by flipping the "OIDC Conformant" switch on in the OAuth Advanced Settings of your client. For more information check [this documentation](https://auth0.com/docs/api-auth/intro#how-to-use-the-new-flows). + +```java +WebAuthProvider.init(account) + .withAudience("https://{YOUR_AUTH0_DOMAIN}/userinfo") + .start(MainActivity.this, authCallback); +``` + +> Replace `{YOUR_AUTH0_DOMAIN}` with your actual Auth0 domain (i.e. `mytenant.auth0.com`). + +#### Specify scope + +```java +WebAuthProvider.init(account) + .withScope("openid profile email") + .start(MainActivity.this, authCallback); +``` + +> The default scope used is `openid` + +#### Specify Connection scope + +```java +WebAuthProvider.init(account) + .withConnectionScope("email", "profile", "calendar:read") + .start(MainActivity.this, authCallback); +``` + + +## Next steps + +### Learning resources + +Check out the [Android QuickStart Guide](https://auth0.com/docs/quickstart/native/android) to find out more about the Auth0.Android toolkit and explore our tutorials and sample projects. ### Authentication API @@ -69,6 +242,8 @@ AuthenticationAPIClient authentication = new AuthenticationAPIClient(account); #### Login with database connection +If the `Auth0` instance wasn't configured as "OIDC conformant", this call requires the client to have the *Resource Owner* Client Grant Type enabled. Check [this article](https://auth0.com/docs/clients/client-grant-types) to learn how to enable it. + ```java authentication .login("info@auth0.com", "a secret password", "my-database-connection") @@ -89,6 +264,10 @@ authentication #### Passwordless Login +This feature requires your client to have the *Resource Owner* Legacy Grant Type enabled. Check [this article](https://auth0.com/docs/clients/client-grant-types) to learn how to enable it. Note that Passwordless authentication *cannot be used* with the [OIDC Conformant Mode](#oidc-conformant-mode) enabled. + +Passwordless it's a 2 steps flow: + Step 1: Request the code ```java @@ -114,7 +293,6 @@ Step 2: Input the code ```java authentication .loginWithEmail("info@auth0.com", "123456", "my-passwordless-connection") - .start(new BaseCallback() { @Override public void onSuccess(Credentials payload) { //Logged in! @@ -213,7 +391,7 @@ users }); ``` -### Get User Profile +#### Get User Profile ```java users @@ -231,7 +409,7 @@ users }); ``` -### Update User Metadata +#### Update User Metadata ```java Map metadata = new HashMap<>(); @@ -256,129 +434,70 @@ users > In all the cases, the `User ID` parameter is the unique identifier of the auth0 account instance. i.e. in `google-oauth2|123456789081523216417` it would be the part after the '|' pipe: `123456789081523216417`. -### Web-based Auth - -First go to [Auth0 Dashboard](https://manage.auth0.com/#/applications) and go to your application's settings. Make sure you have in *Allowed Callback URLs* a URL with the following format: - -``` -https://{YOUR_AUTH0_DOMAIN}/android/{YOUR_APP_PACKAGE_NAME}/callback -``` - -Open your app's `AndroidManifest.xml` file and add the following permission. - -```xml - -``` - -Also register the intent filters inside your activity's tag, so you can receive the call in your activity. Note that you will have to specify the callback url inside the `data` tag. - -```xml - - - - - - - - - - - - - - - - - - - - -``` - -Make sure the Activity's **launchMode** is declared as "singleTask" or the result won't come back after the authentication. +### Credentials Manager +This library ships with a `CredentialsManager` class to easily store and retrieve fresh Credentials from a given `Storage`. -When you launch the WebAuthProvider you'll expect a result back. To capture the response override the `onNewIntent` method and call `WebAuthProvider.resume()` with the received parameters: +#### Usage +1. **Instantiate the manager** +You'll need an `AuthenticationAPIClient` instance used to renew the credentials when they expire and a `Storage`. The Storage implementation is up to you. We provide a `SharedPreferencesStorage` that uses `SharedPreferences` to create a file in the application's directory with Context.MODE_PRIVATE mode. This implementation is thread safe and can either be obtained through a Singleton like method or be created every time it's needed. ```java -public class MyActivity extends Activity { - - @Override - protected void onNewIntent(Intent intent) { - if (WebAuthProvider.resume(intent)) { - return; - } - super.onNewIntent(intent); - } -} - +AuthenticationAPIClient authentication = new AuthenticationAPIClient(account); +Storage storage = new SharedPreferencesStorage(this); +CredentialsManager manager = new CredentialsManager(authentication, storage); ``` -##### A note about App Deep Linking: - -Currently, the default scheme used in the Callback Uri is `https`. This works best for Android API 23 or newer if you're using [Android App Links](https://developer.android.com/training/app-links/index.html), but in previous Android versions this may show the intent chooser dialog prompting the user to chose either your application or the browser. You can change this behaviour by using a custom unique scheme, so that the OS opens directly the link with your app. - -1. Update the intent filter in the Android Manifest and change the custom scheme. -2. Update the allowed callback urls in your [Auth0 Dashboard](https://manage.auth0.com/#/applications) client's settings. -3. Call `withScheme()` passing the scheme you want to use. - +2. **Save credentials** +The credentials to save **must have** `expires_in` and at least an `access_token` or `id_token` value. If one of the values is missing when trying to set the credentials, the method will throw a `CredentialsManagerException`. If you want the manager to successfully renew the credentials when expired you must also request the `offline_access` scope when logging in in order to receive a `refresh_token` value along with the rest of the tokens. i.e. Logging in with a database connection and saving the credentials: ```java -WebAuthProvider.init(account) - .withScheme("myapp") - .start(MainActivity.this, authCallback); -``` - -#### Authenticate with any Auth0 connection +authentication + .login("info@auth0.com", "a secret password", "my-database-connection") + .setScope("openid offline_access") + .start(new BaseCallback() { + @Override + public void onSuccess(Credentials credentials) { + //Save the credentials + manager.saveCredentials(credentials); + } -```java -WebAuthProvider.init(account) - .withConnection("twitter") - .start(MainActivity.this, authCallback); + @Override + public void onFailure(AuthenticationException error) { + //Error! + } + }); ``` -#### Use Code grant with PKCE -> Before you can use `Code Grant` in Android, make sure to go to your [client's section](https://manage.auth0.com/#/applications) in dashboard and check in the Settings that `Client Type` is `Native`. - +3. **Check credentials existence** +There are cases were you just want to check if a user session is still valid (i.e. to know if you should present the login screen or the main screen). For convenience we include a `hasValidCredentials` method that can let you know in advance if a non-expired token is available without making an additional network call. The same rules of the `getCredentials` method apply: ```java -WebAuthProvider.init(account) - .useCodeGrant(true) - .start(MainActivity.this, authCallback); +boolean authenticated = manager.hasValidCredentials(); ``` -#### Specify scope +4. **Retrieve credentials** +Existing credentials will be returned if they are still valid, otherwise the `refresh_token` will be used to attempt to renew them. If the `expires_in` or both the `access_token` and `id_token` values are missing, the method will throw a `CredentialsManagerException`. The same will happen if the credentials have expired and there's no `refresh_token` available. ```java -WebAuthProvider.init(account) - .withScope("user openid") - .start(MainActivity.this, authCallback); +manager.getCredentials(new BaseCallback(){ + public void onSuccess(Credentials credentials){ + //Use the Credentials + } + + public void onFailure(CredentialsManagerException error){ + //Error! + } +}); ``` -> The default scope used is `openid` - -#### Specify Connection scope - -```java -WebAuthProvider.init(account) - .withConnectionScope("email", "profile", "calendar:read") - .start(MainActivity.this, authCallback); -``` -#### Authenticate with Auth0 hosted login page -Simply don't specify any custom connection and the Lock web widget will show. +5. **Clear credentials** +When you want to log the user out: ```java -WebAuthProvider.init(account) - .start(MainActivity.this, authCallback); +manager.clearCredentials(); ``` - ## FAQ * Why is the Android Lint _error_ `'InvalidPackage'` considered a _warning_? @@ -396,7 +515,7 @@ android { ref: https://github.com/square/okio/issues/58#issuecomment-72672263 -##Proguard +## Proguard The rules should be applied automatically if your application is using `minifyEnabled = true`. If you want to include them manually check the [proguard directory](proguard). By default you should at least use the following files: * `proguard-okio.pro` diff --git a/auth0/build.gradle b/auth0/build.gradle index 152195589..6ee655754 100644 --- a/auth0/build.gradle +++ b/auth0/build.gradle @@ -59,14 +59,21 @@ android { lintOptions { warning 'InvalidPackage' } + buildTypes { + debug { + //Helps tests. buildTypes values are not included in the merged manifest + manifestPlaceholders = [auth0Domain: "auth0.test.domain"] + } + } } dependencies { compile fileTree(dir: 'libs', include: ['*.jar']) - compile 'com.android.support:appcompat-v7:25.0.1' + compile 'com.android.support:appcompat-v7:25.3.1' + compile 'com.android.support:customtabs:25.3.1' compile 'com.squareup.okhttp:okhttp:2.7.5' compile 'com.squareup.okhttp:logging-interceptor:2.7.5' - compile 'com.google.code.gson:gson:2.6.2' + compile 'com.google.code.gson:gson:2.7' compile 'com.auth0.android:jwtdecode:1.1.0' testCompile 'junit:junit:4.12' diff --git a/auth0/src/main/AndroidManifest.xml b/auth0/src/main/AndroidManifest.xml index ccc5384fb..6f2354e4b 100644 --- a/auth0/src/main/AndroidManifest.xml +++ b/auth0/src/main/AndroidManifest.xml @@ -22,4 +22,35 @@ ~ THE SOFTWARE. --> - + + + + + + + + + + + + + + + + + + + + + + diff --git a/auth0/src/main/java/com/auth0/android/Auth0.java b/auth0/src/main/java/com/auth0/android/Auth0.java index 934018428..d32932d2a 100755 --- a/auth0/src/main/java/com/auth0/android/Auth0.java +++ b/auth0/src/main/java/com/auth0/android/Auth0.java @@ -40,6 +40,15 @@ *
{@code
  * Auth0 auth0 = new Auth0("YOUR_CLIENT_ID", "YOUR_DOMAIN");
  * }
+ * It is strongly encouraged that this SDK be used in OIDC Conformant mode. + * When this mode is enabled, it will force the SDK to use Auth0's current authentication pipeline + * and will prevent it from reaching legacy endpoints. By default is `false` + *
{@code
+ * auth0.setOIDCConformant(true);
+ * }
+ * For more information, please see the OIDC adoption guide. + * + * @see Auth0#setOIDCConformant(boolean) */ public class Auth0 { @@ -148,9 +157,11 @@ public void doNotSendTelemetry() { } /** - * Defines if the client uses OIDC conformant authentication endpoints. By default is {@code false} + * It is strongly encouraged that this SDK be used in OIDC Conformant mode. + * When this mode is enabled, it will force the SDK to use Auth0's current authentication pipeline + * and will prevent it from reaching legacy endpoints. By default is {@code false} + * For more information, please see the OIDC adoption guide. *

- * You will need to enable this setting in the Auth0 Dashboard first: Go to Account (top right), Account Settings, click Advanced and check the toggle at the bottom. * This setting affects how authentication is performed in the following methods: *

    *
  • {@link AuthenticationAPIClient#login(String, String, String)}
  • @@ -159,7 +170,7 @@ public void doNotSendTelemetry() { *
  • {@link AuthenticationAPIClient#renewAuth(String)}
  • *
* - * @param enabled if Lock will use the Legacy Auth API or the new OIDC Conformant Auth API. + * @param enabled if Lock will use the Legacy Authentication API or the new OIDC Conformant Authentication API. */ public void setOIDCConformant(boolean enabled) { this.oidcConformant = enabled; diff --git a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.java b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.java index 024ee3c83..50cb8079e 100755 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.java +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.java @@ -163,7 +163,7 @@ public void setUserAgent(String userAgent) { /** * Log in a user with email/username and password for a connection/realm. * In OIDC conformant mode ({@link Auth0#isOIDCConformant()}) it will use the password-realm grant type for the {@code /oauth/token} endpoint - * otherwise it will use {@code /oauth/ro} + * otherwise it will use {@code /oauth/ro}, which requires your client to have the Resource Owner Legacy Grant Type enabled. See Client Grant Types to learn how to enable it. * Example: *
      * {@code
@@ -279,6 +279,7 @@ public AuthenticationRequest loginWithOAuthAccessToken(@NonNull String token, @N
     /**
      * Log in a user using a phone number and a verification code received via SMS (Part of passwordless login flow)
      * The default scope used is 'openid'.
+     * Requires your client to have the Resource Owner Legacy Grant Type enabled. See Client Grant Types to learn how to enable it.
      * Example usage:
      * 
      * {@code
@@ -339,6 +340,7 @@ public AuthenticationRequest loginWithPhoneNumber(@NonNull String phoneNumber, @
     /**
      * Log in a user using an email and a verification code received via Email (Part of passwordless login flow).
      * The default scope used is 'openid'.
+     * Requires your client to have the Resource Owner Legacy Grant Type enabled. See Client Grant Types to learn how to enable it.
      * Example usage:
      * 
      * {@code
@@ -622,7 +624,6 @@ public DatabaseConnectionRequest resetPassword(@N
     /**
      * Request the revoke of a given refresh_token. Once revoked, the refresh_token cannot be used to obtain new tokens.
      * The client must be of type 'Native' or have the 'Token Endpoint Authentication Method' set to 'none' for this endpoint to work.
-     * 
      * Example usage:
      * 
      * {@code
@@ -790,6 +791,7 @@ public DelegationRequest> delegationWithIdToken(@NonNull Str
 
     /**
      * Start a passwordless flow with an Email
+     * Requires your client to have the Resource Owner Legacy Grant Type enabled. See Client Grant Types to learn how to enable it.
      * Example usage:
      * 
      * {@code
@@ -824,6 +826,7 @@ public ParameterizableRequest passwordlessWithEma
     /**
      * Start a passwordless flow with an Email
      * By default it will try to authenticate using "email" connection.
+     * Requires your client to have the Resource Owner Legacy Grant Type enabled. See Client Grant Types to learn how to enable it.
      * Example usage:
      * 
      * {@code
@@ -849,6 +852,7 @@ public ParameterizableRequest passwordlessWithEma
 
     /**
      * Start a passwordless flow with a SMS
+     * Requires your client to have the Resource Owner Legacy Grant Type enabled. See Client Grant Types to learn how to enable it.
      * Example usage:
      * 
      * {@code
@@ -882,6 +886,7 @@ public ParameterizableRequest passwordlessWithSMS
     /**
      * Start a passwordless flow with a SMS
      * By default it will try to authenticate using the "sms" connection.
+     * Requires your client to have the Resource Owner Legacy Grant Type enabled. See Client Grant Types to learn how to enable it.
      * Example usage:
      * 
      * {@code
diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.java b/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.java
new file mode 100644
index 000000000..abad58626
--- /dev/null
+++ b/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.java
@@ -0,0 +1,136 @@
+package com.auth0.android.authentication.storage;
+
+import android.support.annotation.NonNull;
+import android.support.annotation.VisibleForTesting;
+
+import com.auth0.android.authentication.AuthenticationAPIClient;
+import com.auth0.android.authentication.AuthenticationException;
+import com.auth0.android.callback.AuthenticationCallback;
+import com.auth0.android.callback.BaseCallback;
+import com.auth0.android.result.Credentials;
+
+import java.util.Date;
+
+import static android.text.TextUtils.isEmpty;
+
+/**
+ * Class that handles credentials and allows to save and retrieve them.
+ */
+@SuppressWarnings({"WeakerAccess", "unused"})
+public class CredentialsManager {
+    private static final String KEY_ACCESS_TOKEN = "com.auth0.access_token";
+    private static final String KEY_REFRESH_TOKEN = "com.auth0.refresh_token";
+    private static final String KEY_ID_TOKEN = "com.auth0.id_token";
+    private static final String KEY_TOKEN_TYPE = "com.auth0.token_type";
+    private static final String KEY_EXPIRES_AT = "com.auth0.expires_at";
+    private static final String KEY_SCOPE = "com.auth0.scope";
+
+    private final AuthenticationAPIClient authClient;
+    private final Storage storage;
+
+    /**
+     * Creates a new instance of the manager that will store the credentials in the given Storage.
+     *
+     * @param authenticationClient the Auth0 Authentication client to refresh credentials with.
+     * @param storage              the storage to use for the credentials.
+     */
+    public CredentialsManager(@NonNull AuthenticationAPIClient authenticationClient, @NonNull Storage storage) {
+        this.authClient = authenticationClient;
+        this.storage = storage;
+    }
+
+    /**
+     * Stores the given credentials in the storage. Must have an access_token or id_token and a expires_in value.
+     *
+     * @param credentials the credentials to save in the storage.
+     */
+    public void saveCredentials(@NonNull Credentials credentials) {
+        if ((isEmpty(credentials.getAccessToken()) && isEmpty(credentials.getIdToken())) || credentials.getExpiresAt() == null) {
+            throw new CredentialsManagerException("Credentials must have a valid date of expiration and a valid access_token or id_token value.");
+        }
+        storage.store(KEY_ACCESS_TOKEN, credentials.getAccessToken());
+        storage.store(KEY_REFRESH_TOKEN, credentials.getRefreshToken());
+        storage.store(KEY_ID_TOKEN, credentials.getIdToken());
+        storage.store(KEY_TOKEN_TYPE, credentials.getType());
+        storage.store(KEY_EXPIRES_AT, credentials.getExpiresAt().getTime());
+        storage.store(KEY_SCOPE, credentials.getScope());
+    }
+
+    /**
+     * Retrieves the credentials from the storage and refresh them if they have already expired.
+     * It will fail with {@link CredentialsManagerException} if the saved access_token or id_token is null,
+     * or if the tokens have already expired and the refresh_token is null.
+     *
+     * @param callback the callback that will receive a valid {@link Credentials} or the {@link CredentialsManagerException}.
+     */
+    public void getCredentials(@NonNull final BaseCallback callback) {
+        String accessToken = storage.retrieveString(KEY_ACCESS_TOKEN);
+        String refreshToken = storage.retrieveString(KEY_REFRESH_TOKEN);
+        String idToken = storage.retrieveString(KEY_ID_TOKEN);
+        String tokenType = storage.retrieveString(KEY_TOKEN_TYPE);
+        Long expiresAt = storage.retrieveLong(KEY_EXPIRES_AT);
+        String scope = storage.retrieveString(KEY_SCOPE);
+
+        if (isEmpty(accessToken) && isEmpty(idToken) || expiresAt == null) {
+            callback.onFailure(new CredentialsManagerException("No Credentials were previously set."));
+            return;
+        }
+        if (expiresAt > getCurrentTimeInMillis()) {
+            callback.onSuccess(recreateCredentials(idToken, accessToken, tokenType, refreshToken, new Date(expiresAt), scope));
+            return;
+        }
+        if (refreshToken == null) {
+            callback.onFailure(new CredentialsManagerException("Credentials have expired and no Refresh Token was available to renew them."));
+            return;
+        }
+
+        authClient.renewAuth(refreshToken).start(new AuthenticationCallback() {
+            @Override
+            public void onSuccess(Credentials freshCredentials) {
+                callback.onSuccess(freshCredentials);
+            }
+
+            @Override
+            public void onFailure(AuthenticationException error) {
+                callback.onFailure(new CredentialsManagerException("An error occurred while trying to use the Refresh Token to renew the Credentials.", error));
+            }
+        });
+    }
+
+    /**
+     * Checks if a non-expired pair of credentials can be obtained from this manager.
+     */
+    public boolean hasValidCredentials() {
+        String accessToken = storage.retrieveString(KEY_ACCESS_TOKEN);
+        String refreshToken = storage.retrieveString(KEY_REFRESH_TOKEN);
+        String idToken = storage.retrieveString(KEY_ID_TOKEN);
+        Long expiresAt = storage.retrieveLong(KEY_EXPIRES_AT);
+
+        return !(isEmpty(accessToken) && isEmpty(idToken) ||
+                expiresAt == null ||
+                expiresAt <= getCurrentTimeInMillis() && refreshToken == null);
+    }
+
+    /**
+     * Removes the credentials from the storage if present.
+     */
+    public void clearCredentials() {
+        storage.remove(KEY_ACCESS_TOKEN);
+        storage.remove(KEY_REFRESH_TOKEN);
+        storage.remove(KEY_ID_TOKEN);
+        storage.remove(KEY_TOKEN_TYPE);
+        storage.remove(KEY_EXPIRES_AT);
+        storage.remove(KEY_SCOPE);
+    }
+
+    @VisibleForTesting
+    Credentials recreateCredentials(String idToken, String accessToken, String tokenType, String refreshToken, Date expiresAt, String scope) {
+        return new Credentials(idToken, accessToken, tokenType, refreshToken, expiresAt, scope);
+    }
+
+    @VisibleForTesting
+    long getCurrentTimeInMillis() {
+        return System.currentTimeMillis();
+    }
+
+}
diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManagerException.java b/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManagerException.java
new file mode 100644
index 000000000..a79573305
--- /dev/null
+++ b/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManagerException.java
@@ -0,0 +1,18 @@
+package com.auth0.android.authentication.storage;
+
+
+import com.auth0.android.Auth0Exception;
+
+/**
+ * Represents an error raised by the {@link CredentialsManager}.
+ */
+@SuppressWarnings("WeakerAccess")
+public class CredentialsManagerException extends Auth0Exception {
+    public CredentialsManagerException(String message, Throwable cause) {
+        super(message, cause);
+    }
+
+    public CredentialsManagerException(String message) {
+        super(message);
+    }
+}
diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/SharedPreferencesStorage.java b/auth0/src/main/java/com/auth0/android/authentication/storage/SharedPreferencesStorage.java
new file mode 100644
index 000000000..50c79705f
--- /dev/null
+++ b/auth0/src/main/java/com/auth0/android/authentication/storage/SharedPreferencesStorage.java
@@ -0,0 +1,100 @@
+package com.auth0.android.authentication.storage;
+
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.text.TextUtils;
+
+/**
+ * An implementation of {@link Storage} that uses {@link android.content.SharedPreferences} in Context.MODE_PRIVATE to store the values.
+ */
+@SuppressWarnings({"WeakerAccess", "unused"})
+public class SharedPreferencesStorage implements Storage {
+
+    private static final String SHARED_PREFERENCES_NAME = "com.auth0.authentication.storage";
+
+    private final SharedPreferences sp;
+
+    /**
+     * Creates a new {@link Storage} that uses {@link SharedPreferences} in Context.MODE_PRIVATE to store values.
+     *
+     * @param context a valid context
+     */
+    public SharedPreferencesStorage(@NonNull Context context) {
+        this(context, SHARED_PREFERENCES_NAME);
+    }
+
+    /**
+     * Creates a new {@link Storage} that uses {@link SharedPreferences} in Context.MODE_PRIVATE to store values.
+     *
+     * @param context               a valid context
+     * @param sharedPreferencesName the preferences file name
+     */
+    public SharedPreferencesStorage(@NonNull Context context, @NonNull String sharedPreferencesName) {
+        if (TextUtils.isEmpty(sharedPreferencesName)) {
+            throw new IllegalArgumentException("The SharedPreferences name is invalid.");
+        }
+        sp = context.getSharedPreferences(sharedPreferencesName, Context.MODE_PRIVATE);
+    }
+
+    @Override
+    public void store(@NonNull String name, @Nullable Long value) {
+        if (value == null) {
+            sp.edit().remove(name).apply();
+        } else {
+            sp.edit().putLong(name, value).apply();
+        }
+    }
+
+    @Override
+    public void store(@NonNull String name, @Nullable Integer value) {
+        if (value == null) {
+            sp.edit().remove(name).apply();
+        } else {
+            sp.edit().putInt(name, value).apply();
+        }
+    }
+
+    @Override
+    public void store(@NonNull String name, @Nullable String value) {
+        if (value == null) {
+            sp.edit().remove(name).apply();
+        } else {
+            sp.edit().putString(name, value).apply();
+        }
+    }
+
+    @Nullable
+    @Override
+    public Long retrieveLong(@NonNull String name) {
+        if (!sp.contains(name)) {
+            return null;
+        }
+        return sp.getLong(name, 0);
+    }
+
+    @Nullable
+    @Override
+    public String retrieveString(@NonNull String name) {
+        if (!sp.contains(name)) {
+            return null;
+        }
+        return sp.getString(name, null);
+    }
+
+    @Nullable
+    @Override
+    public Integer retrieveInteger(@NonNull String name) {
+        if (!sp.contains(name)) {
+            return null;
+        }
+        return sp.getInt(name, 0);
+    }
+
+    @Override
+    public void remove(@NonNull String name) {
+        sp.edit().remove(name).apply();
+    }
+}
diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/Storage.java b/auth0/src/main/java/com/auth0/android/authentication/storage/Storage.java
new file mode 100644
index 000000000..12570796a
--- /dev/null
+++ b/auth0/src/main/java/com/auth0/android/authentication/storage/Storage.java
@@ -0,0 +1,70 @@
+package com.auth0.android.authentication.storage;
+
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+
+/**
+ * Represents a Storage of key-value data.
+ * Supported classes are String, Long and Integer.
+ */
+@SuppressWarnings("WeakerAccess")
+public interface Storage {
+
+    /**
+     * Store a given value in the Storage.
+     *
+     * @param name  the name of the value to store.
+     * @param value the value to store. Can be null.
+     */
+    void store(@NonNull String name, @Nullable Long value);
+
+    /**
+     * Store a given value in the Storage.
+     *
+     * @param name  the name of the value to store.
+     * @param value the value to store. Can be null.
+     */
+    void store(@NonNull String name, @Nullable Integer value);
+
+    /**
+     * Store a given value in the Storage.
+     *
+     * @param name  the name of the value to store.
+     * @param value the value to store. Can be null.
+     */
+    void store(@NonNull String name, @Nullable String value);
+
+    /**
+     * Retrieve a value from the Storage.
+     *
+     * @param name the name of the value to retrieve.
+     * @return the value that was previously saved. Can be null.
+     */
+    @Nullable
+    Long retrieveLong(@NonNull String name);
+
+    /**
+     * Retrieve a value from the Storage.
+     *
+     * @param name the name of the value to retrieve.
+     * @return the value that was previously saved. Can be null.
+     */
+    @Nullable
+    String retrieveString(@NonNull String name);
+
+    /**
+     * Retrieve a value from the Storage.
+     *
+     * @param name the name of the value to retrieve.
+     * @return the value that was previously saved. Can be null.
+     */
+    @Nullable
+    Integer retrieveInteger(@NonNull String name);
+
+    /**
+     * Removes a value from the storage.
+     *
+     * @param name the name of the value to remove.
+     */
+    void remove(@NonNull String name);
+}
diff --git a/auth0/src/main/java/com/auth0/android/management/UsersAPIClient.java b/auth0/src/main/java/com/auth0/android/management/UsersAPIClient.java
index cf39fc7c8..3ecbfcc1f 100755
--- a/auth0/src/main/java/com/auth0/android/management/UsersAPIClient.java
+++ b/auth0/src/main/java/com/auth0/android/management/UsersAPIClient.java
@@ -134,7 +134,7 @@ public void setUserAgent(String userAgent) {
 
 
     /**
-     * Link a user identity calling '/api/v2/users/:primaryUserId/identities' endpoint
+     * Link a user identity calling '/api/v2/users/:primaryUserId/identities' endpoint
      * Example usage:
      * 
      * {@code
diff --git a/auth0/src/main/java/com/auth0/android/provider/AuthenticationActivity.java b/auth0/src/main/java/com/auth0/android/provider/AuthenticationActivity.java
new file mode 100644
index 000000000..8450fec02
--- /dev/null
+++ b/auth0/src/main/java/com/auth0/android/provider/AuthenticationActivity.java
@@ -0,0 +1,120 @@
+package com.auth0.android.provider;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
+import android.support.annotation.VisibleForTesting;
+
+public class AuthenticationActivity extends Activity {
+
+    static final String EXTRA_USE_BROWSER = "com.auth0.android.EXTRA_USE_BROWSER";
+    static final String EXTRA_USE_FULL_SCREEN = "com.auth0.android.EXTRA_USE_FULL_SCREEN";
+    static final String EXTRA_CONNECTION_NAME = "com.auth0.android.EXTRA_CONNECTION_NAME";
+    private static final String EXTRA_INTENT_LAUNCHED = "com.auth0.android.EXTRA_INTENT_LAUNCHED";
+
+    private boolean intentLaunched;
+    private CustomTabsController customTabsController;
+
+    static void authenticateUsingBrowser(Context context, Uri authorizeUri) {
+        Intent intent = new Intent(context, AuthenticationActivity.class);
+        intent.setData(authorizeUri);
+        intent.putExtra(AuthenticationActivity.EXTRA_USE_BROWSER, true);
+        intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+        context.startActivity(intent);
+    }
+
+    static void authenticateUsingWebView(Activity activity, Uri authorizeUri, int requestCode, String connection, boolean useFullScreen) {
+        Intent intent = new Intent(activity, AuthenticationActivity.class);
+        intent.setData(authorizeUri);
+        intent.putExtra(AuthenticationActivity.EXTRA_USE_BROWSER, false);
+        intent.putExtra(AuthenticationActivity.EXTRA_USE_FULL_SCREEN, useFullScreen);
+        intent.putExtra(AuthenticationActivity.EXTRA_CONNECTION_NAME, connection);
+        intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+        activity.startActivityForResult(intent, requestCode);
+    }
+
+    @Override
+    protected void onNewIntent(Intent intent) {
+        super.onNewIntent(intent);
+        setIntent(intent);
+    }
+
+    @Override
+    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+        if (resultCode == RESULT_OK) {
+            deliverSuccessfulAuthenticationResult(data);
+        }
+        finish();
+    }
+
+    @Override
+    protected void onSaveInstanceState(Bundle outState) {
+        super.onSaveInstanceState(outState);
+        outState.putBoolean(EXTRA_INTENT_LAUNCHED, intentLaunched);
+    }
+
+    @Override
+    protected void onCreate(@Nullable Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        if (savedInstanceState != null) {
+            intentLaunched = savedInstanceState.getBoolean(EXTRA_INTENT_LAUNCHED, false);
+        }
+    }
+
+    @Override
+    protected void onResume() {
+        super.onResume();
+        if (!intentLaunched) {
+            intentLaunched = true;
+            launchAuthenticationIntent();
+            return;
+        }
+
+        if (getIntent().getData() != null) {
+            deliverSuccessfulAuthenticationResult(getIntent());
+        }
+        finish();
+    }
+
+    @Override
+    protected void onDestroy() {
+        super.onDestroy();
+        if (customTabsController != null) {
+            customTabsController.unbindService();
+            customTabsController = null;
+        }
+    }
+
+    private void launchAuthenticationIntent() {
+        Bundle extras = getIntent().getExtras();
+        final Uri authorizeUri = getIntent().getData();
+        if (!extras.getBoolean(EXTRA_USE_BROWSER, true)) {
+            Intent intent = new Intent(this, WebAuthActivity.class);
+            intent.setData(authorizeUri);
+            intent.putExtra(WebAuthActivity.CONNECTION_NAME_EXTRA, extras.getString(EXTRA_CONNECTION_NAME));
+            intent.putExtra(WebAuthActivity.FULLSCREEN_EXTRA, extras.getBoolean(EXTRA_USE_FULL_SCREEN));
+            //The request code value can be ignored
+            startActivityForResult(intent, 33);
+            return;
+        }
+
+        customTabsController = createCustomTabsController(this);
+        customTabsController.bindService();
+        customTabsController.launchUri(authorizeUri);
+    }
+
+    @VisibleForTesting
+    CustomTabsController createCustomTabsController(@NonNull Context context) {
+        return new CustomTabsController(context);
+    }
+
+    @VisibleForTesting
+    void deliverSuccessfulAuthenticationResult(Intent result) {
+        WebAuthProvider.resume(result);
+    }
+
+}
diff --git a/auth0/src/main/java/com/auth0/android/provider/CustomTabsController.java b/auth0/src/main/java/com/auth0/android/provider/CustomTabsController.java
new file mode 100644
index 000000000..b912c2966
--- /dev/null
+++ b/auth0/src/main/java/com/auth0/android/provider/CustomTabsController.java
@@ -0,0 +1,187 @@
+package com.auth0.android.provider;
+
+import android.content.ActivityNotFoundException;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.net.Uri;
+import android.os.Build;
+import android.support.annotation.NonNull;
+import android.support.annotation.VisibleForTesting;
+import android.support.customtabs.CustomTabsClient;
+import android.support.customtabs.CustomTabsIntent;
+import android.support.customtabs.CustomTabsServiceConnection;
+import android.support.customtabs.CustomTabsSession;
+import android.util.Log;
+
+import java.lang.ref.WeakReference;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+@SuppressWarnings("WeakerAccess")
+class CustomTabsController extends CustomTabsServiceConnection {
+
+    private static final String TAG = CustomTabsController.class.getSimpleName();
+    private static final long MAX_WAIT_TIME_SECONDS = 1;
+    private static final String ACTION_CUSTOM_TABS_CONNECTION = "android.support.customtabs.action.CustomTabsService";
+    //Known Browsers
+    private static final String CHROME_STABLE = "com.android.chrome";
+    private static final String CHROME_SYSTEM = "com.google.android.apps.chrome";
+    private static final String CHROME_BETA = "com.android.chrome.beta";
+    private static final String CHROME_DEV = "com.android.chrome.dev";
+
+    private final WeakReference context;
+    private final AtomicReference session;
+    private final CountDownLatch sessionLatch;
+    private final String preferredPackage;
+
+
+    @VisibleForTesting
+    CustomTabsController(@NonNull Context context, @NonNull String browserPackage) {
+        this.context = new WeakReference<>(context);
+        this.session = new AtomicReference<>();
+        this.sessionLatch = new CountDownLatch(1);
+        this.preferredPackage = browserPackage;
+    }
+
+    public CustomTabsController(@NonNull Context context) {
+        this(context, getBestBrowserPackage(context));
+    }
+
+    @VisibleForTesting
+    void clearContext() {
+        this.context.clear();
+    }
+
+    @Override
+    public void onCustomTabsServiceConnected(ComponentName componentName, CustomTabsClient customTabsClient) {
+        if (customTabsClient == null) {
+            return;
+        }
+        Log.d(TAG, "CustomTabs Service connected");
+        customTabsClient.warmup(0L);
+        session.set(customTabsClient.newSession(null));
+        sessionLatch.countDown();
+    }
+
+    @Override
+    public void onServiceDisconnected(ComponentName componentName) {
+        Log.d(TAG, "CustomTabs Service disconnected");
+        session.set(null);
+    }
+
+    /**
+     * Attempts to bind the Custom Tabs Service to the Context.
+     */
+    public void bindService() {
+        Log.v(TAG, "Trying to bind the service");
+        Context context = this.context.get();
+        boolean success = false;
+        if (context != null) {
+            success = CustomTabsClient.bindCustomTabsService(context, preferredPackage, this);
+        }
+        Log.v(TAG, "Bind request result: " + success);
+    }
+
+    /**
+     * Attempts to unbind the Custom Tabs Service from the Context.
+     */
+    public void unbindService() {
+        Log.v(TAG, "Trying to unbind the service");
+        Context context = this.context.get();
+        if (context != null) {
+            context.unbindService(this);
+        }
+    }
+
+    /**
+     * Opens a Uri in a Custom Tab or Browser.
+     * The Custom Tab service will be given up to {@link CustomTabsController#MAX_WAIT_TIME_SECONDS} to be connected.
+     * If it fails to connect the Uri will be opened on a Browser.
+     *
+     * @param uri the uri to open in a Custom Tab or Browser.
+     */
+    public void launchUri(@NonNull final Uri uri) {
+        final Context context = this.context.get();
+        if (context == null) {
+            Log.v(TAG, "Custom Tab Context was no longer valid.");
+            return;
+        }
+
+        new Thread(new Runnable() {
+            @Override
+            public void run() {
+                boolean available = false;
+                try {
+                    available = sessionLatch.await(MAX_WAIT_TIME_SECONDS, TimeUnit.SECONDS);
+                } catch (InterruptedException ignored) {
+                }
+                Log.d(TAG, "Launching URI. Custom Tabs available: " + available);
+
+                final Intent intent = new CustomTabsIntent.Builder(session.get())
+                        .build()
+                        .intent;
+                intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY);
+                intent.setData(uri);
+                try {
+                    context.startActivity(intent);
+                } catch (ActivityNotFoundException ignored) {
+                    Intent fallbackIntent = new Intent(Intent.ACTION_VIEW, uri);
+                    fallbackIntent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY);
+                    context.startActivity(fallbackIntent);
+                }
+            }
+        }).start();
+    }
+
+    /**
+     * Query the OS for a Custom Tab compatible Browser application.
+     * It will pick the default browser first if is Custom Tab compatible, then any Chrome browser or the first Custom Tab compatible browser.
+     *
+     * @param context a valid Context
+     * @return the recommended Browser application package name, compatible with Custom Tabs if possible.
+     */
+    @VisibleForTesting
+    static String getBestBrowserPackage(@NonNull Context context) {
+        PackageManager pm = context.getPackageManager();
+        Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse("http://www.example.com"));
+        ResolveInfo webHandler = pm.resolveActivity(browserIntent,
+                Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? PackageManager.MATCH_ALL : PackageManager.MATCH_DEFAULT_ONLY);
+        String defaultBrowser = null;
+        if (webHandler != null) {
+            defaultBrowser = webHandler.activityInfo.packageName;
+        }
+
+        List resolvedActivityList = pm.queryIntentActivities(browserIntent, 0);
+        List customTabsBrowsers = new ArrayList<>();
+        for (ResolveInfo info : resolvedActivityList) {
+            Intent serviceIntent = new Intent();
+            serviceIntent.setAction(ACTION_CUSTOM_TABS_CONNECTION);
+            serviceIntent.setPackage(info.activityInfo.packageName);
+            if (pm.resolveService(serviceIntent, 0) != null) {
+                customTabsBrowsers.add(info.activityInfo.packageName);
+            }
+        }
+        if (customTabsBrowsers.contains(defaultBrowser)) {
+            return defaultBrowser;
+        } else if (customTabsBrowsers.contains(CHROME_STABLE)) {
+            return CHROME_STABLE;
+        } else if (customTabsBrowsers.contains(CHROME_SYSTEM)) {
+            return CHROME_SYSTEM;
+        } else if (customTabsBrowsers.contains(CHROME_BETA)) {
+            return CHROME_BETA;
+        } else if (customTabsBrowsers.contains(CHROME_DEV)) {
+            return CHROME_DEV;
+        } else if (!customTabsBrowsers.isEmpty()) {
+            return customTabsBrowsers.get(0);
+        } else {
+            return defaultBrowser;
+        }
+    }
+
+}
\ No newline at end of file
diff --git a/auth0/src/main/java/com/auth0/android/provider/OAuthManager.java b/auth0/src/main/java/com/auth0/android/provider/OAuthManager.java
index 38fbb83af..385b2b553 100644
--- a/auth0/src/main/java/com/auth0/android/provider/OAuthManager.java
+++ b/auth0/src/main/java/com/auth0/android/provider/OAuthManager.java
@@ -2,11 +2,11 @@
 
 import android.app.Activity;
 import android.app.Dialog;
-import android.content.Intent;
 import android.net.Uri;
 import android.support.annotation.NonNull;
 import android.support.annotation.Nullable;
 import android.support.annotation.VisibleForTesting;
+import android.text.TextUtils;
 import android.util.Base64;
 import android.util.Log;
 
@@ -19,9 +19,11 @@
 import com.auth0.android.result.Credentials;
 
 import java.security.SecureRandom;
+import java.util.Date;
 import java.util.HashMap;
 import java.util.Map;
 
+@SuppressWarnings("WeakerAccess")
 class OAuthManager {
 
     private static final String TAG = OAuthManager.class.getSimpleName();
@@ -49,6 +51,7 @@ class OAuthManager {
     private static final String KEY_REFRESH_TOKEN = "refresh_token";
     private static final String KEY_EXPIRES_IN = "expires_in";
     private static final String KEY_CODE = "code";
+    private static final String KEY_SCOPE = "scope";
 
     private final Auth0 account;
     private final AuthCallback callback;
@@ -58,18 +61,19 @@ class OAuthManager {
     private boolean useBrowser = true;
     private int requestCode;
     private PKCE pkce;
+    private Long currentTimeInMillis;
 
-    public OAuthManager(Auth0 account, AuthCallback callback, Map parameters) {
+    OAuthManager(@NonNull Auth0 account, @NonNull AuthCallback callback, @NonNull Map parameters) {
         this.account = account;
         this.callback = callback;
         this.parameters = new HashMap<>(parameters);
     }
 
-    public void useFullScreen(boolean useFullScreen) {
+    void useFullScreen(boolean useFullScreen) {
         this.useFullScreen = useFullScreen;
     }
 
-    public void useBrowser(boolean useBrowser) {
+    void useBrowser(boolean useBrowser) {
         this.useBrowser = useBrowser;
     }
 
@@ -78,30 +82,21 @@ void setPKCE(PKCE pkce) {
         this.pkce = pkce;
     }
 
-    public void startAuthorization(Activity activity, String redirectUri, int requestCode) {
+    void startAuthorization(Activity activity, String redirectUri, int requestCode) {
         addPKCEParameters(parameters, redirectUri);
         addClientParameters(parameters, redirectUri);
         addValidationParameters(parameters);
         Uri uri = buildAuthorizeUri();
-
         this.requestCode = requestCode;
-        final Intent intent;
-        if (this.useBrowser) {
-            Log.d(TAG, "About to start the authorization using the Browser");
-            intent = new Intent(Intent.ACTION_VIEW, uri);
-            intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY);
-            activity.startActivity(intent);
+
+        if (useBrowser) {
+            AuthenticationActivity.authenticateUsingBrowser(activity, uri);
         } else {
-            Log.d(TAG, "About to start the authorization using the WebView");
-            intent = new Intent(activity, WebAuthActivity.class);
-            intent.setData(uri);
-            intent.putExtra(WebAuthActivity.CONNECTION_NAME_EXTRA, parameters.get(KEY_CONNECTION));
-            intent.putExtra(WebAuthActivity.FULLSCREEN_EXTRA, useFullScreen);
-            activity.startActivityForResult(intent, requestCode);
+            AuthenticationActivity.authenticateUsingWebView(activity, uri, requestCode, parameters.get(KEY_CONNECTION), useFullScreen);
         }
     }
 
-    public boolean resumeAuthorization(AuthorizeResult data) {
+    boolean resumeAuthorization(AuthorizeResult data) {
         if (!data.isValid(requestCode)) {
             Log.w(TAG, "The Authorize Result is invalid.");
             return false;
@@ -117,16 +112,13 @@ public boolean resumeAuthorization(AuthorizeResult data) {
         try {
             assertNoError(values.get(KEY_ERROR), values.get(KEY_ERROR_DESCRIPTION));
             assertValidState(parameters.get(KEY_STATE), values.get(KEY_STATE));
-            if (parameters.get(KEY_RESPONSE_TYPE).contains(RESPONSE_TYPE_ID_TOKEN)) {
+            if (parameters.containsKey(KEY_RESPONSE_TYPE) && parameters.get(KEY_RESPONSE_TYPE).contains(RESPONSE_TYPE_ID_TOKEN)) {
                 assertValidNonce(parameters.get(KEY_NONCE), values.get(KEY_ID_TOKEN));
             }
 
             Log.d(TAG, "Authenticated using web flow");
-            Long expiresIn = null;
-            if (values.containsKey(KEY_EXPIRES_IN)) {
-                expiresIn = Long.valueOf(values.get(KEY_EXPIRES_IN));
-            }
-            final Credentials urlCredentials = new Credentials(values.get(KEY_ID_TOKEN), values.get(KEY_ACCESS_TOKEN), values.get(KEY_TOKEN_TYPE), values.get(KEY_REFRESH_TOKEN), expiresIn);
+            final Date expiresAt = !values.containsKey(KEY_EXPIRES_IN) ? null : new Date(getCurrentTimeInMillis() + Long.valueOf(values.get(KEY_EXPIRES_IN)) * 1000);
+            final Credentials urlCredentials = new Credentials(values.get(KEY_ID_TOKEN), values.get(KEY_ACCESS_TOKEN), values.get(KEY_TOKEN_TYPE), values.get(KEY_REFRESH_TOKEN), expiresAt, values.get(KEY_SCOPE));
             if (!shouldUsePKCE()) {
                 callback.onSuccess(urlCredentials);
             } else {
@@ -154,6 +146,15 @@ public void onSuccess(@NonNull Credentials codeCredentials) {
         return true;
     }
 
+    private long getCurrentTimeInMillis() {
+        return currentTimeInMillis != null ? currentTimeInMillis : System.currentTimeMillis();
+    }
+
+    @VisibleForTesting
+    void setCurrentTimeInMillis(long currentTimeInMillis) {
+        this.currentTimeInMillis = currentTimeInMillis;
+    }
+
     //Helper Methods
 
     private void assertNoError(String errorValue, String errorDescription) throws AuthenticationException {
@@ -224,7 +225,7 @@ private void addValidationParameters(Map parameters) {
         String state = getRandomString(parameters.get(KEY_STATE));
         parameters.put(KEY_STATE, state);
 
-        if (parameters.get(KEY_RESPONSE_TYPE).contains(RESPONSE_TYPE_ID_TOKEN)) {
+        if (parameters.containsKey(KEY_RESPONSE_TYPE) && parameters.get(KEY_RESPONSE_TYPE).contains(RESPONSE_TYPE_ID_TOKEN)) {
             String nonce = getRandomString(parameters.get(KEY_NONCE));
             parameters.put(KEY_NONCE, nonce);
         }
@@ -245,7 +246,7 @@ private void createPKCE(String redirectUri) {
     }
 
     private boolean shouldUsePKCE() {
-        return parameters.get(KEY_RESPONSE_TYPE).contains(RESPONSE_TYPE_CODE) && PKCE.isAvailable();
+        return parameters.containsKey(KEY_RESPONSE_TYPE) && parameters.get(KEY_RESPONSE_TYPE).contains(RESPONSE_TYPE_CODE) && PKCE.isAvailable();
     }
 
     @VisibleForTesting
@@ -260,13 +261,14 @@ boolean useFullScreen() {
 
     @VisibleForTesting
     static Credentials mergeCredentials(Credentials urlCredentials, Credentials codeCredentials) {
-        final String idToken = codeCredentials.getIdToken() != null ? codeCredentials.getIdToken() : urlCredentials.getIdToken();
-        final String accessToken = codeCredentials.getAccessToken() != null ? codeCredentials.getAccessToken() : urlCredentials.getAccessToken();
-        final String type = codeCredentials.getType() != null ? codeCredentials.getType() : urlCredentials.getType();
-        final String refreshToken = codeCredentials.getRefreshToken() != null ? codeCredentials.getRefreshToken() : urlCredentials.getRefreshToken();
-        final Long expiresIn = codeCredentials.getExpiresIn() != null ? codeCredentials.getExpiresIn() : urlCredentials.getExpiresIn();
-
-        return new Credentials(idToken, accessToken, type, refreshToken, expiresIn);
+        final String idToken = TextUtils.isEmpty(codeCredentials.getIdToken()) ? urlCredentials.getIdToken() : codeCredentials.getIdToken();
+        final String accessToken = TextUtils.isEmpty(codeCredentials.getAccessToken()) ? urlCredentials.getAccessToken() : codeCredentials.getAccessToken();
+        final String type = TextUtils.isEmpty(codeCredentials.getType()) ? urlCredentials.getType() : codeCredentials.getType();
+        final String refreshToken = TextUtils.isEmpty(codeCredentials.getRefreshToken()) ? urlCredentials.getRefreshToken() : codeCredentials.getRefreshToken();
+        final Date expiresAt = codeCredentials.getExpiresAt() != null ? codeCredentials.getExpiresAt() : urlCredentials.getExpiresAt();
+        final String scope = TextUtils.isEmpty(codeCredentials.getScope()) ? urlCredentials.getScope() : codeCredentials.getScope();
+
+        return new Credentials(idToken, accessToken, type, refreshToken, expiresAt, scope);
     }
 
     @VisibleForTesting
diff --git a/auth0/src/main/java/com/auth0/android/provider/RedirectActivity.java b/auth0/src/main/java/com/auth0/android/provider/RedirectActivity.java
new file mode 100644
index 000000000..75eea1f09
--- /dev/null
+++ b/auth0/src/main/java/com/auth0/android/provider/RedirectActivity.java
@@ -0,0 +1,21 @@
+package com.auth0.android.provider;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.os.Bundle;
+
+public class RedirectActivity extends Activity {
+
+    @Override
+    public void onCreate(Bundle savedInstanceBundle) {
+        super.onCreate(savedInstanceBundle);
+        Intent intent = new Intent(this, AuthenticationActivity.class);
+        intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP);
+        if (getIntent() != null) {
+            intent.setData(getIntent().getData());
+        }
+        startActivity(intent);
+        finish();
+    }
+
+}
diff --git a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.java b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.java
index d24f97561..813a4ceae 100644
--- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.java
+++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.java
@@ -255,9 +255,7 @@ Builder withPKCE(PKCE pkce) {
         }
 
         /**
-         * Begins the authentication flow.
-         * Make sure to override your activity's onNewIntent() and onActivityResult() methods,
-         * and call this provider's resume() method with the received parameters.
+         * Request user Authentication. The result will be received in the callback.
          *
          * @param activity    context to run the authentication
          * @param callback    to receive the parsed results
@@ -266,8 +264,8 @@ Builder withPKCE(PKCE pkce) {
          */
         @Deprecated
         public void start(@NonNull Activity activity, @NonNull AuthCallback callback, int requestCode) {
+            managerInstance = null;
             if (account.getAuthorizeUrl() == null) {
-                managerInstance = null;
                 final AuthenticationException ex = new AuthenticationException("a0.invalid_authorize_url", "Auth0 authorize URL not properly set. This can be related to an invalid domain.");
                 callback.onFailure(ex);
                 return;
@@ -285,8 +283,7 @@ public void start(@NonNull Activity activity, @NonNull AuthCallback callback, in
         }
 
         /**
-         * Begins the authentication flow.
-         * Make sure to override your activity's onNewIntent() method and call this provider's resume() method with the received parameters.
+         * Request user Authentication. The result will be received in the callback.
          *
          * @param activity context to run the authentication
          * @param callback to receive the parsed results
@@ -309,7 +306,6 @@ public static Builder init(@NonNull Auth0 account) {
         return new Builder(account);
     }
 
-
     /**
      * Initialize the WebAuthProvider instance with an Android Context. Additional settings can be configured
      * in the Builder, like setting the connection name or authentication parameters.
@@ -318,12 +314,14 @@ public static Builder init(@NonNull Auth0 account) {
      * @return a new Builder instance to customize.
      */
     public static Builder init(@NonNull Context context) {
-        return new Builder(new Auth0(context));
+        return init(new Auth0(context));
     }
 
     /**
      * Finishes the authentication flow by passing the data received in the activity's onActivityResult() callback.
      * The final authentication result will be delivered to the callback specified when calling start().
+     * 

+ * This is no longer required to be called, the authentication is handled internally as long as you've correctly setup the intent-filter. * * @param requestCode the request code received on the onActivityResult() call * @param resultCode the result code received on the onActivityResult() call @@ -348,6 +346,8 @@ public static boolean resume(int requestCode, int resultCode, @Nullable Intent i /** * Finishes the authentication flow by passing the data received in the activity's onNewIntent() callback. * The final authentication result will be delivered to the callback specified when calling start(). + *

+ * This is no longer required to be called, the authentication is handled internally as long as you've correctly setup the intent-filter. * * @param intent the data received on the onNewIntent() call * @return true if a result was expected and has a valid format, or false if not. diff --git a/auth0/src/main/java/com/auth0/android/request/internal/CredentialsDeserializer.java b/auth0/src/main/java/com/auth0/android/request/internal/CredentialsDeserializer.java new file mode 100755 index 000000000..076560ff1 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/request/internal/CredentialsDeserializer.java @@ -0,0 +1,38 @@ +package com.auth0.android.request.internal; + +import android.support.annotation.VisibleForTesting; + +import com.auth0.android.result.Credentials; +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; + +import java.lang.reflect.Type; +import java.util.Date; + +class CredentialsDeserializer implements JsonDeserializer { + + @Override + public Credentials deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException { + if (!json.isJsonObject() || json.isJsonNull() || json.getAsJsonObject().entrySet().isEmpty()) { + throw new JsonParseException("credentials json is not a valid json object"); + } + + JsonObject object = json.getAsJsonObject(); + final String idToken = context.deserialize(object.remove("id_token"), String.class); + final String accessToken = context.deserialize(object.remove("access_token"), String.class); + final String type = context.deserialize(object.remove("token_type"), String.class); + final String refreshToken = context.deserialize(object.remove("refresh_token"), String.class); + final Long expiresIn = context.deserialize(object.remove("expires_in"), Long.class); + final String scope = context.deserialize(object.remove("scope"), String.class); + final Date expiresAt = expiresIn == null ? null : new Date(getCurrentTimeInMillis() + expiresIn * 1000); + return new Credentials(idToken, accessToken, type, refreshToken, expiresAt, scope); + } + + @VisibleForTesting + long getCurrentTimeInMillis() { + return System.currentTimeMillis(); + } +} diff --git a/auth0/src/main/java/com/auth0/android/request/internal/GsonProvider.java b/auth0/src/main/java/com/auth0/android/request/internal/GsonProvider.java index efb4aa8c5..6d0dec76b 100755 --- a/auth0/src/main/java/com/auth0/android/request/internal/GsonProvider.java +++ b/auth0/src/main/java/com/auth0/android/request/internal/GsonProvider.java @@ -1,5 +1,6 @@ package com.auth0.android.request.internal; +import com.auth0.android.result.Credentials; import com.auth0.android.result.UserProfile; import com.auth0.android.util.JsonRequiredTypeAdapterFactory; import com.google.gson.Gson; @@ -11,6 +12,7 @@ public static Gson buildGson() { return new GsonBuilder() .registerTypeAdapterFactory(new JsonRequiredTypeAdapterFactory()) .registerTypeAdapter(UserProfile.class, new UserProfileDeserializer()) + .registerTypeAdapter(Credentials.class, new CredentialsDeserializer()) .setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'") .create(); } diff --git a/auth0/src/main/java/com/auth0/android/result/Credentials.java b/auth0/src/main/java/com/auth0/android/result/Credentials.java index 28f2fa0b7..19152aa9e 100755 --- a/auth0/src/main/java/com/auth0/android/result/Credentials.java +++ b/auth0/src/main/java/com/auth0/android/result/Credentials.java @@ -26,9 +26,12 @@ import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; import com.google.gson.annotations.SerializedName; +import java.util.Date; + /** * Holds the user's credentials returned by Auth0. *

    @@ -36,6 +39,9 @@ *
  • accessToken: Access Token for Auth0 API
  • *
  • refreshToken: Refresh Token that can be used to request new tokens without signing in again
  • *
  • type: The type of the received Token.
  • + *
  • expiresIn: The token lifetime in seconds.
  • + *
  • expiresAt: The token expiration date.
  • + *
  • scope: The token's granted scope.
  • *
*/ public class Credentials { @@ -55,12 +61,39 @@ public class Credentials { @SerializedName("expires_in") private Long expiresIn; - public Credentials(String idToken, String accessToken, String type, String refreshToken, Long expiresIn) { + @SerializedName("scope") + private String scope; + + private Date expiresAt; + + //TODO: Deprecate this constructor + public Credentials(@Nullable String idToken, @Nullable String accessToken, @Nullable String type, @Nullable String refreshToken, @Nullable Long expiresIn) { + this(idToken, accessToken, type, refreshToken, expiresIn, null, null); + } + + public Credentials(@Nullable String idToken, @Nullable String accessToken, @Nullable String type, @Nullable String refreshToken, @Nullable Date expiresAt, @Nullable String scope) { + this(idToken, accessToken, type, refreshToken, null, expiresAt, scope); + } + + private Credentials(@Nullable String idToken, @Nullable String accessToken, @Nullable String type, @Nullable String refreshToken, @Nullable Long expiresIn, @Nullable Date expiresAt, @Nullable String scope) { this.idToken = idToken; this.accessToken = accessToken; this.type = type; this.refreshToken = refreshToken; this.expiresIn = expiresIn; + this.scope = scope; + this.expiresAt = expiresAt; + if (expiresAt == null && expiresIn != null) { + this.expiresAt = new Date(getCurrentTimeInMillis() + expiresIn * 1000); + } + if (expiresIn == null && expiresAt != null) { + this.expiresIn = (expiresAt.getTime() - getCurrentTimeInMillis()) / 1000; + } + } + + @VisibleForTesting + long getCurrentTimeInMillis() { + return System.currentTimeMillis(); } /** @@ -103,7 +136,35 @@ public String getRefreshToken() { return refreshToken; } + /** + * Getter for the token lifetime in seconds. + * Once expired, the token can no longer be used to access an API and a new token needs to be obtained. + * + * @return the token lifetime in seconds. + */ + @Nullable public Long getExpiresIn() { return expiresIn; } + + /** + * Getter for the token's granted scope. Only available if the requested scope differs from the granted one. + * + * @return the granted scope. + */ + @Nullable + public String getScope() { + return scope; + } + + /** + * Getter for the expiration date of this token. + * Once expired, the token can no longer be used to access an API and a new token needs to be obtained. + * + * @return the expiration date of this token + */ + @Nullable + public Date getExpiresAt() { + return expiresAt; + } } diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.java b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.java new file mode 100644 index 000000000..a365d38ef --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.java @@ -0,0 +1,389 @@ +package com.auth0.android.authentication.storage; + +import com.auth0.android.authentication.AuthenticationAPIClient; +import com.auth0.android.authentication.AuthenticationException; +import com.auth0.android.callback.BaseCallback; +import com.auth0.android.request.ParameterizableRequest; +import com.auth0.android.result.Credentials; +import com.auth0.android.result.CredentialsMock; + +import org.hamcrest.core.Is; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.util.Date; + +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.core.Is.is; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThat; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +@RunWith(RobolectricTestRunner.class) +@Config(constants = com.auth0.android.auth0.BuildConfig.class, sdk = 21, manifest = Config.NONE) +public class CredentialsManagerTest { + + @Mock + private AuthenticationAPIClient client; + @Mock + private Storage storage; + @Mock + private BaseCallback callback; + @Mock + private ParameterizableRequest request; + @Captor + private ArgumentCaptor credentialsCaptor; + @Captor + private ArgumentCaptor exceptionCaptor; + @Rule + public ExpectedException exception = ExpectedException.none(); + + private CredentialsManager manager; + @Captor + private ArgumentCaptor> requestCallbackCaptor; + + @Before + public void setUp() throws Exception { + MockitoAnnotations.initMocks(this); + CredentialsManager credentialsManager = new CredentialsManager(client, storage); + manager = spy(credentialsManager); + //Needed to test expiration verification + doReturn(CredentialsMock.CURRENT_TIME_MS).when(manager).getCurrentTimeInMillis(); + doAnswer(new Answer() { + @Override + public Object answer(InvocationOnMock invocation) throws Throwable { + String idToken = invocation.getArgumentAt(0, String.class); + String accessToken = invocation.getArgumentAt(1, String.class); + String type = invocation.getArgumentAt(2, String.class); + String refreshToken = invocation.getArgumentAt(3, String.class); + Date expiresAt = invocation.getArgumentAt(4, Date.class); + String scope = invocation.getArgumentAt(5, String.class); + return new CredentialsMock(idToken, accessToken, type, refreshToken, expiresAt, scope); + } + }).when(manager).recreateCredentials(anyString(), anyString(), anyString(), anyString(), any(Date.class), anyString()); + } + + @Test + public void shouldSaveCredentialsInStorage() throws Exception { + long expirationTime = CredentialsMock.CURRENT_TIME_MS + 123456 * 1000; + Credentials credentials = new CredentialsMock("idToken", "accessToken", "type", "refreshToken", new Date(expirationTime), "scope"); + manager.saveCredentials(credentials); + + verify(storage).store("com.auth0.id_token", "idToken"); + verify(storage).store("com.auth0.access_token", "accessToken"); + verify(storage).store("com.auth0.refresh_token", "refreshToken"); + verify(storage).store("com.auth0.token_type", "type"); + verify(storage).store("com.auth0.expires_at", expirationTime); + verify(storage).store("com.auth0.scope", "scope"); + verifyNoMoreInteractions(storage); + } + + @Test + public void shouldThrowOnSetIfCredentialsDoesNotHaveIdTokenOrAccessToken() throws Exception { + exception.expect(CredentialsManagerException.class); + exception.expectMessage("Credentials must have a valid date of expiration and a valid access_token or id_token value."); + + Credentials credentials = new CredentialsMock(null, null, "type", "refreshToken", 123456L); + manager.saveCredentials(credentials); + } + + @SuppressWarnings("ConstantConditions") + @Test + public void shouldThrowOnSetIfCredentialsDoesNotHaveExpiresAt() throws Exception { + exception.expect(CredentialsManagerException.class); + exception.expectMessage("Credentials must have a valid date of expiration and a valid access_token or id_token value."); + + Date date = null; + Credentials credentials = new CredentialsMock("idToken", "accessToken", "type", "refreshToken", date, "scope"); + manager.saveCredentials(credentials); + } + + @Test + public void shouldNotThrowOnSetIfCredentialsHaveAccessTokenAndExpiresIn() throws Exception { + Credentials credentials = new CredentialsMock(null, "accessToken", "type", "refreshToken", 123456L); + manager.saveCredentials(credentials); + } + + @Test + public void shouldNotThrowOnSetIfCredentialsHaveIdTokenAndExpiresIn() throws Exception { + Credentials credentials = new CredentialsMock("idToken", null, "type", "refreshToken", 123456L); + manager.saveCredentials(credentials); + } + + @Test + public void shouldFailOnGetCredentialsWhenNoAccessTokenOrIdTokenWasSaved() throws Exception { + verifyNoMoreInteractions(client); + + when(storage.retrieveString("com.auth0.id_token")).thenReturn(null); + when(storage.retrieveString("com.auth0.access_token")).thenReturn(null); + when(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken"); + when(storage.retrieveString("com.auth0.token_type")).thenReturn("type"); + long expirationTime = CredentialsMock.CURRENT_TIME_MS + 123456L * 1000; + when(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime); + when(storage.retrieveString("com.auth0.scope")).thenReturn("scope"); + + manager.getCredentials(callback); + + verify(callback).onFailure(exceptionCaptor.capture()); + CredentialsManagerException exception = exceptionCaptor.getValue(); + assertThat(exception, is(notNullValue())); + assertThat(exception.getMessage(), is("No Credentials were previously set.")); + } + + @Test + public void shouldFailOnGetCredentialsWhenNoExpirationTimeWasSaved() throws Exception { + verifyNoMoreInteractions(client); + + when(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken"); + when(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken"); + when(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken"); + when(storage.retrieveString("com.auth0.token_type")).thenReturn("type"); + when(storage.retrieveLong("com.auth0.expires_at")).thenReturn(null); + when(storage.retrieveString("com.auth0.scope")).thenReturn("scope"); + + manager.getCredentials(callback); + + verify(callback).onFailure(exceptionCaptor.capture()); + CredentialsManagerException exception = exceptionCaptor.getValue(); + assertThat(exception, is(notNullValue())); + assertThat(exception.getMessage(), is("No Credentials were previously set.")); + } + + @SuppressWarnings("UnnecessaryLocalVariable") + @Test + public void shouldFailOnGetCredentialsWhenExpiredAndNoRefreshTokenWasSaved() throws Exception { + verifyNoMoreInteractions(client); + + when(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken"); + when(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken"); + when(storage.retrieveString("com.auth0.refresh_token")).thenReturn(null); + when(storage.retrieveString("com.auth0.token_type")).thenReturn("type"); + long expirationTime = CredentialsMock.CURRENT_TIME_MS; //Same as current time --> expired + when(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime); + when(storage.retrieveString("com.auth0.scope")).thenReturn("scope"); + + manager.getCredentials(callback); + + verify(callback).onFailure(exceptionCaptor.capture()); + CredentialsManagerException exception = exceptionCaptor.getValue(); + assertThat(exception, is(notNullValue())); + assertThat(exception.getMessage(), is("Credentials have expired and no Refresh Token was available to renew them.")); + } + + @Test + public void shouldGetNonExpiredCredentialsFromStorage() throws Exception { + verifyNoMoreInteractions(client); + + when(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken"); + when(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken"); + when(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken"); + when(storage.retrieveString("com.auth0.token_type")).thenReturn("type"); + long expirationTime = CredentialsMock.CURRENT_TIME_MS + 123456L * 1000; + when(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime); + when(storage.retrieveString("com.auth0.scope")).thenReturn("scope"); + + manager.getCredentials(callback); + verify(callback).onSuccess(credentialsCaptor.capture()); + Credentials retrievedCredentials = credentialsCaptor.getValue(); + + assertThat(retrievedCredentials, is(notNullValue())); + assertThat(retrievedCredentials.getAccessToken(), is("accessToken")); + assertThat(retrievedCredentials.getIdToken(), is("idToken")); + assertThat(retrievedCredentials.getRefreshToken(), is("refreshToken")); + assertThat(retrievedCredentials.getType(), is("type")); + assertThat(retrievedCredentials.getExpiresIn(), is(123456L)); + assertThat(retrievedCredentials.getExpiresAt(), is(notNullValue())); + assertThat(retrievedCredentials.getExpiresAt().getTime(), is(expirationTime)); + assertThat(retrievedCredentials.getScope(), is("scope")); + } + + @Test + public void shouldGetNonExpiredCredentialsFromStorageWhenOnlyIdTokenIsAvailable() throws Exception { + verifyNoMoreInteractions(client); + + when(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken"); + when(storage.retrieveString("com.auth0.access_token")).thenReturn(null); + when(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken"); + when(storage.retrieveString("com.auth0.token_type")).thenReturn("type"); + long expirationTime = CredentialsMock.CURRENT_TIME_MS + 123456L * 1000; + when(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime); + when(storage.retrieveString("com.auth0.scope")).thenReturn("scope"); + + manager.getCredentials(callback); + verify(callback).onSuccess(credentialsCaptor.capture()); + Credentials retrievedCredentials = credentialsCaptor.getValue(); + + assertThat(retrievedCredentials, is(notNullValue())); + assertThat(retrievedCredentials.getAccessToken(), is(nullValue())); + assertThat(retrievedCredentials.getIdToken(), is("idToken")); + assertThat(retrievedCredentials.getRefreshToken(), is("refreshToken")); + assertThat(retrievedCredentials.getType(), is("type")); + assertThat(retrievedCredentials.getExpiresIn(), is(123456L)); + assertThat(retrievedCredentials.getExpiresAt(), is(notNullValue())); + assertThat(retrievedCredentials.getExpiresAt().getTime(), is(expirationTime)); + assertThat(retrievedCredentials.getScope(), is("scope")); + } + + @Test + public void shouldGetNonExpiredCredentialsFromStorageWhenOnlyAccessTokenIsAvailable() throws Exception { + verifyNoMoreInteractions(client); + + when(storage.retrieveString("com.auth0.id_token")).thenReturn(null); + when(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken"); + when(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken"); + when(storage.retrieveString("com.auth0.token_type")).thenReturn("type"); + Long expirationTime = CredentialsMock.CURRENT_TIME_MS + 123456L * 1000; + when(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime); + when(storage.retrieveString("com.auth0.scope")).thenReturn("scope"); + + manager.getCredentials(callback); + verify(callback).onSuccess(credentialsCaptor.capture()); + Credentials retrievedCredentials = credentialsCaptor.getValue(); + + assertThat(retrievedCredentials, is(notNullValue())); + assertThat(retrievedCredentials.getAccessToken(), is("accessToken")); + assertThat(retrievedCredentials.getIdToken(), is(nullValue())); + assertThat(retrievedCredentials.getRefreshToken(), is("refreshToken")); + assertThat(retrievedCredentials.getType(), is("type")); + assertThat(retrievedCredentials.getExpiresIn(), is(123456L)); + assertThat(retrievedCredentials.getExpiresAt(), is(notNullValue())); + assertThat(retrievedCredentials.getExpiresAt().getTime(), is(expirationTime)); + assertThat(retrievedCredentials.getScope(), is("scope")); + } + + @SuppressWarnings("UnnecessaryLocalVariable") + @Test + public void shouldGetAndSuccessfullyRenewExpiredCredentials() throws Exception { + when(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken"); + when(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken"); + when(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken"); + when(storage.retrieveString("com.auth0.token_type")).thenReturn("type"); + long expirationTime = CredentialsMock.CURRENT_TIME_MS; //Same as current time --> expired + when(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime); + when(storage.retrieveString("com.auth0.scope")).thenReturn("scope"); + when(client.renewAuth("refreshToken")).thenReturn(request); + + manager.getCredentials(callback); + verify(request).start(requestCallbackCaptor.capture()); + + //Trigger success + Credentials renewedCredentials = mock(Credentials.class); + requestCallbackCaptor.getValue().onSuccess(renewedCredentials); + verify(callback).onSuccess(credentialsCaptor.capture()); + + Credentials retrievedCredentials = credentialsCaptor.getValue(); + assertThat(retrievedCredentials, is(notNullValue())); + assertThat(retrievedCredentials, is(renewedCredentials)); + } + + @SuppressWarnings("UnnecessaryLocalVariable") + @Test + public void shouldGetAndFailToRenewExpiredCredentials() throws Exception { + when(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken"); + when(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken"); + when(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken"); + when(storage.retrieveString("com.auth0.token_type")).thenReturn("type"); + long expirationTime = CredentialsMock.CURRENT_TIME_MS; //Same as current time --> expired + when(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime); + when(storage.retrieveString("com.auth0.scope")).thenReturn("scope"); + when(client.renewAuth("refreshToken")).thenReturn(request); + + manager.getCredentials(callback); + verify(request).start(requestCallbackCaptor.capture()); + + //Trigger failure + AuthenticationException authenticationException = mock(AuthenticationException.class); + requestCallbackCaptor.getValue().onFailure(authenticationException); + verify(callback).onFailure(exceptionCaptor.capture()); + + CredentialsManagerException exception = exceptionCaptor.getValue(); + assertThat(exception, is(notNullValue())); + assertThat(exception.getCause(), Is.is(authenticationException)); + assertThat(exception.getMessage(), is("An error occurred while trying to use the Refresh Token to renew the Credentials.")); + } + + @Test + public void shouldClearCredentials() throws Exception { + manager.clearCredentials(); + + verify(storage).remove("com.auth0.id_token"); + verify(storage).remove("com.auth0.access_token"); + verify(storage).remove("com.auth0.refresh_token"); + verify(storage).remove("com.auth0.token_type"); + verify(storage).remove("com.auth0.expires_at"); + verify(storage).remove("com.auth0.scope"); + verifyNoMoreInteractions(storage); + } + + @Test + public void shouldHaveCredentialsWhenTokenHasNotExpired() throws Exception { + long expirationTime = CredentialsMock.CURRENT_TIME_MS + 123456L * 1000; + when(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime); + + when(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken"); + when(storage.retrieveString("com.auth0.access_token")).thenReturn(null); + assertThat(manager.hasValidCredentials(), is(true)); + + when(storage.retrieveString("com.auth0.id_token")).thenReturn(null); + when(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken"); + assertThat(manager.hasValidCredentials(), is(true)); + } + + @Test + public void shouldNotHaveCredentialsWhenTokenHasExpiredAndNoRefreshTokenIsAvailable() throws Exception { + long expirationTime = CredentialsMock.CURRENT_TIME_MS; //Same as current time --> expired + when(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime); + when(storage.retrieveString("com.auth0.refresh_token")).thenReturn(null); + + when(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken"); + when(storage.retrieveString("com.auth0.access_token")).thenReturn(null); + assertFalse(manager.hasValidCredentials()); + + when(storage.retrieveString("com.auth0.id_token")).thenReturn(null); + when(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken"); + assertFalse(manager.hasValidCredentials()); + } + + @Test + public void shouldHaveCredentialsWhenTokenHasExpiredButRefreshTokenIsAvailable() throws Exception { + long expirationTime = CredentialsMock.CURRENT_TIME_MS; //Same as current time --> expired + when(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime); + when(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken"); + + when(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken"); + when(storage.retrieveString("com.auth0.access_token")).thenReturn(null); + assertThat(manager.hasValidCredentials(), is(true)); + + when(storage.retrieveString("com.auth0.id_token")).thenReturn(null); + when(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken"); + assertThat(manager.hasValidCredentials(), is(true)); + } + + @Test + public void shouldNotHaveCredentialsWhenAccessTokenAndIdTokenAreMissing() throws Exception { + when(storage.retrieveString("com.auth0.id_token")).thenReturn(null); + when(storage.retrieveString("com.auth0.access_token")).thenReturn(null); + + assertFalse(manager.hasValidCredentials()); + } +} \ No newline at end of file diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/SharedPreferencesStorageTest.java b/auth0/src/test/java/com/auth0/android/authentication/storage/SharedPreferencesStorageTest.java new file mode 100644 index 000000000..04768be12 --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/SharedPreferencesStorageTest.java @@ -0,0 +1,201 @@ +package com.auth0.android.authentication.storage; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.SharedPreferences; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import static org.hamcrest.core.Is.is; +import static org.hamcrest.core.IsNull.nullValue; +import static org.junit.Assert.assertThat; +import static org.mockito.Matchers.anyBoolean; +import static org.mockito.Matchers.anyFloat; +import static org.mockito.Matchers.anyInt; +import static org.mockito.Matchers.anyLong; +import static org.mockito.Matchers.anySet; +import static org.mockito.Matchers.anyString; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@RunWith(RobolectricTestRunner.class) +@Config(constants = com.auth0.android.auth0.BuildConfig.class, sdk = 21, manifest = Config.NONE) +@SuppressLint("CommitPrefEdits") +public class SharedPreferencesStorageTest { + + @Mock + private Context context; + @Mock + private SharedPreferences sharedPreferences; + @Mock + private SharedPreferences.Editor sharedPreferencesEditor; + @Rule + public ExpectedException exception = ExpectedException.none(); + + @Before + public void setUp() throws Exception { + MockitoAnnotations.initMocks(this); + when(context.getSharedPreferences(anyString(), eq(Context.MODE_PRIVATE))).thenReturn(sharedPreferences); + when(sharedPreferences.edit()).thenReturn(sharedPreferencesEditor); + when(sharedPreferencesEditor.remove(anyString())).thenReturn(sharedPreferencesEditor); + when(sharedPreferencesEditor.putString(anyString(), anyString())).thenReturn(sharedPreferencesEditor); + when(sharedPreferencesEditor.putBoolean(anyString(), anyBoolean())).thenReturn(sharedPreferencesEditor); + when(sharedPreferencesEditor.putLong(anyString(), anyLong())).thenReturn(sharedPreferencesEditor); + when(sharedPreferencesEditor.putFloat(anyString(), anyFloat())).thenReturn(sharedPreferencesEditor); + when(sharedPreferencesEditor.putInt(anyString(), anyInt())).thenReturn(sharedPreferencesEditor); + when(sharedPreferencesEditor.putStringSet(anyString(), anySet())).thenReturn(sharedPreferencesEditor); + } + + @Test + public void shouldCreateWithDefaultPreferencesFileName() throws Exception { + new SharedPreferencesStorage(context); + verify(context).getSharedPreferences("com.auth0.authentication.storage", Context.MODE_PRIVATE); + } + + @Test + public void shouldCreateWithCustomPreferencesFileName() throws Exception { + new SharedPreferencesStorage(context, "my-preferences-file"); + verify(context).getSharedPreferences("my-preferences-file", Context.MODE_PRIVATE); + } + + @SuppressWarnings("ConstantConditions") + @Test + public void shouldThrowOnCreateIfCustomPreferencesFileNameIsNull() throws Exception { + exception.expect(IllegalArgumentException.class); + exception.expectMessage("The SharedPreferences name is invalid"); + new SharedPreferencesStorage(context, null); + } + + + //Store + + @SuppressWarnings("ConstantConditions") + @Test + public void shouldRemovePreferencesKeyOnNullStringValue() throws Exception { + SharedPreferencesStorage storage = new SharedPreferencesStorage(context); + String value = null; + storage.store("name", value); + verify(sharedPreferencesEditor).remove("name"); + verify(sharedPreferencesEditor).apply(); + } + + @SuppressWarnings("ConstantConditions") + @Test + public void shouldRemovePreferencesKeyOnNullLongValue() throws Exception { + SharedPreferencesStorage storage = new SharedPreferencesStorage(context); + Long value = null; + storage.store("name", value); + verify(sharedPreferencesEditor).remove("name"); + verify(sharedPreferencesEditor).apply(); + } + + @SuppressWarnings("ConstantConditions") + @Test + public void shouldRemovePreferencesKeyOnNullIntegerValue() throws Exception { + SharedPreferencesStorage storage = new SharedPreferencesStorage(context); + Integer value = null; + storage.store("name", value); + verify(sharedPreferencesEditor).remove("name"); + verify(sharedPreferencesEditor).apply(); + } + + @Test + public void shouldStoreStringValueOnPreferences() throws Exception { + SharedPreferencesStorage storage = new SharedPreferencesStorage(context); + storage.store("name", "value"); + verify(sharedPreferencesEditor).putString("name", "value"); + verify(sharedPreferencesEditor).apply(); + } + + @Test + public void shouldStoreLongValueOnPreferences() throws Exception { + SharedPreferencesStorage storage = new SharedPreferencesStorage(context); + storage.store("name", 123L); + verify(sharedPreferencesEditor).putLong("name", 123L); + verify(sharedPreferencesEditor).apply(); + } + + @Test + public void shouldStoreIntegerValueOnPreferences() throws Exception { + SharedPreferencesStorage storage = new SharedPreferencesStorage(context); + storage.store("name", 123); + verify(sharedPreferencesEditor).putInt("name", 123); + verify(sharedPreferencesEditor).apply(); + } + + + //Retrieve + + @Test + public void shouldRetrieveNullStringValueIfMissingKeyFromPreferences() throws Exception { + when(sharedPreferences.contains("name")).thenReturn(false); + SharedPreferencesStorage storage = new SharedPreferencesStorage(context); + String value = storage.retrieveString("name"); + assertThat(value, is(nullValue())); + } + + @Test + public void shouldRetrieveNullLongValueIfMissingKeyFromPreferences() throws Exception { + when(sharedPreferences.contains("name")).thenReturn(false); + SharedPreferencesStorage storage = new SharedPreferencesStorage(context); + Long value = storage.retrieveLong("name"); + assertThat(value, is(nullValue())); + } + + @Test + public void shouldRetrieveNullIntegerValueIfMissingKeyFromPreferences() throws Exception { + when(sharedPreferences.contains("name")).thenReturn(false); + SharedPreferencesStorage storage = new SharedPreferencesStorage(context); + Integer value = storage.retrieveInteger("name"); + assertThat(value, is(nullValue())); + } + + @Test + public void shouldRetrieveStringValueFromPreferences() throws Exception { + when(sharedPreferences.contains("name")).thenReturn(true); + when(sharedPreferences.getString("name", null)).thenReturn("value"); + SharedPreferencesStorage storage = new SharedPreferencesStorage(context); + String value = storage.retrieveString("name"); + assertThat(value, is("value")); + } + + @Test + public void shouldRetrieveLongValueFromPreferences() throws Exception { + when(sharedPreferences.contains("name")).thenReturn(true); + when(sharedPreferences.getLong("name", 0)).thenReturn(1234567890L); + SharedPreferencesStorage storage = new SharedPreferencesStorage(context); + Long value = storage.retrieveLong("name"); + assertThat(value, is(1234567890L)); + } + + @Test + public void shouldRetrieveIntegerValueFromPreferences() throws Exception { + when(sharedPreferences.contains("name")).thenReturn(true); + when(sharedPreferences.getInt("name", 0)).thenReturn(123); + SharedPreferencesStorage storage = new SharedPreferencesStorage(context); + Integer value = storage.retrieveInteger("name"); + assertThat(value, is(123)); + } + + + //Remove + + @SuppressWarnings("ConstantConditions") + @Test + public void shouldRemovePreferencesKey() throws Exception { + SharedPreferencesStorage storage = new SharedPreferencesStorage(context); + storage.remove("name"); + verify(sharedPreferencesEditor).remove("name"); + verify(sharedPreferencesEditor).apply(); + } + +} \ No newline at end of file diff --git a/auth0/src/test/java/com/auth0/android/provider/AuthenticationActivityMock.java b/auth0/src/test/java/com/auth0/android/provider/AuthenticationActivityMock.java new file mode 100644 index 000000000..f249cb913 --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/provider/AuthenticationActivityMock.java @@ -0,0 +1,34 @@ +package com.auth0.android.provider; + +import android.content.Context; +import android.content.Intent; +import android.support.annotation.NonNull; + +/** + * Created by lbalmaceda on 6/12/17. + */ + +public class AuthenticationActivityMock extends AuthenticationActivity { + + private CustomTabsController customTabsController; + private Intent deliveredIntent; + + @Override + protected CustomTabsController createCustomTabsController(@NonNull Context context) { + return customTabsController; + } + + @Override + protected void deliverSuccessfulAuthenticationResult(Intent result) { + this.deliveredIntent = result; + super.deliverSuccessfulAuthenticationResult(result); + } + + public void setCustomTabsController(CustomTabsController customTabsController) { + this.customTabsController = customTabsController; + } + + public Intent getDeliveredIntent() { + return deliveredIntent; + } +} diff --git a/auth0/src/test/java/com/auth0/android/provider/AuthenticationActivityTest.java b/auth0/src/test/java/com/auth0/android/provider/AuthenticationActivityTest.java new file mode 100644 index 000000000..d76a99971 --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/provider/AuthenticationActivityTest.java @@ -0,0 +1,334 @@ +package com.auth0.android.provider; + +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; +import org.robolectric.shadows.ShadowActivity; +import org.robolectric.util.ActivityController; + +import static android.support.test.espresso.intent.matcher.IntentMatchers.hasComponent; +import static android.support.test.espresso.intent.matcher.IntentMatchers.hasData; +import static android.support.test.espresso.intent.matcher.IntentMatchers.hasFlag; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.greaterThan; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.core.Is.is; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.robolectric.Shadows.shadowOf; + +@RunWith(RobolectricTestRunner.class) +@Config(constants = com.auth0.android.auth0.BuildConfig.class, sdk = 18, manifest = Config.NONE) +public class AuthenticationActivityTest { + + @Mock + private Uri uri; + @Mock + private Uri resultUri; + @Mock + private CustomTabsController customTabsController; + @Captor + private ArgumentCaptor intentCaptor; + @Captor + private ArgumentCaptor uriCaptor; + + private Activity callerActivity; + private AuthenticationActivityMock activity; + private ShadowActivity activityShadow; + private ActivityController activityController; + + @Before + public void setUp() throws Exception { + MockitoAnnotations.initMocks(this); + callerActivity = spy(Robolectric.buildActivity(Activity.class).get()); + } + + private void createActivity(Intent configurationIntent) { + activityController = Robolectric.buildActivity(AuthenticationActivityMock.class, configurationIntent); + activity = activityController.get(); + activity.setCustomTabsController(customTabsController); + activityShadow = shadowOf(activity); + } + + @SuppressWarnings("deprecation") + @Test + public void shouldAuthenticateUsingBrowser() throws Exception { + AuthenticationActivity.authenticateUsingBrowser(callerActivity, uri); + verify(callerActivity).startActivity(intentCaptor.capture()); + + createActivity(intentCaptor.getValue()); + activityController.create().start().resume(); + + verify(customTabsController).bindService(); + verify(customTabsController).launchUri(uriCaptor.capture()); + assertThat(uriCaptor.getValue(), is(notNullValue())); + assertThat(uriCaptor.getValue(), is(uri)); + assertThat(activity.getDeliveredIntent(), is(nullValue())); + activityController.pause().stop(); + //Browser is shown + + Intent authenticationResultIntent = new Intent(); + authenticationResultIntent.setData(resultUri); + activityController.newIntent(authenticationResultIntent); + activityController.start().resume(); + + assertThat(activity.getDeliveredIntent(), is(notNullValue())); + assertThat(activity.getDeliveredIntent().getData(), is(resultUri)); + + assertThat(activity.isFinishing(), is(true)); + + activityController.destroy(); + verify(customTabsController).unbindService(); + } + + @SuppressWarnings("deprecation") + @Test + public void shouldAuthenticateAfterRecreatedUsingBrowser() throws Exception { + AuthenticationActivity.authenticateUsingBrowser(callerActivity, uri); + verify(callerActivity).startActivity(intentCaptor.capture()); + + createActivity(intentCaptor.getValue()); + activityController.create().start().resume(); + + verify(customTabsController).bindService(); + verify(customTabsController).launchUri(uriCaptor.capture()); + assertThat(uriCaptor.getValue(), is(notNullValue())); + assertThat(uriCaptor.getValue(), is(uri)); + assertThat(activity.getDeliveredIntent(), is(nullValue())); + //Browser is shown + //Memory needed. Let's kill the activity + Intent authenticationResultIntent = new Intent(); + authenticationResultIntent.setData(resultUri); + recreateAndCallNewIntent(authenticationResultIntent); + + assertThat(activity.getDeliveredIntent(), is(notNullValue())); + assertThat(activity.getDeliveredIntent().getData(), is(resultUri)); + + assertThat(activity.isFinishing(), is(true)); + + activityController.destroy(); + verify(customTabsController).unbindService(); + } + + @SuppressWarnings("deprecation") + @Test + public void shouldCancelAuthenticationUsingBrowser() throws Exception { + AuthenticationActivity.authenticateUsingBrowser(callerActivity, uri); + verify(callerActivity).startActivity(intentCaptor.capture()); + + createActivity(intentCaptor.getValue()); + activityController.create().start().resume(); + + verify(customTabsController).bindService(); + verify(customTabsController).launchUri(uriCaptor.capture()); + assertThat(uriCaptor.getValue(), is(notNullValue())); + assertThat(uriCaptor.getValue(), is(uri)); + assertThat(activity.getDeliveredIntent(), is(nullValue())); + activityController.pause().stop(); + //Browser is shown + + Intent authenticationResultIntent = new Intent(); + authenticationResultIntent.setData(null); + activityController.newIntent(authenticationResultIntent); + activityController.start().resume(); + + assertThat(activity.getDeliveredIntent(), is(nullValue())); + assertThat(activity.isFinishing(), is(true)); + + activityController.destroy(); + verify(customTabsController).unbindService(); + } + + @SuppressWarnings("deprecation") + @Test + public void shouldAuthenticateUsingWebView() throws Exception { + verifyNoMoreInteractions(customTabsController); + + AuthenticationActivity.authenticateUsingWebView(callerActivity, uri, 123, "facebook", true); + verify(callerActivity).startActivityForResult(intentCaptor.capture(), eq(123)); + + createActivity(intentCaptor.getValue()); + activityController.create().start().resume(); + final ShadowActivity.IntentForResult webViewIntent = activityShadow.getNextStartedActivityForResult(); + + Bundle extras = webViewIntent.intent.getExtras(); + assertThat(extras.containsKey(WebAuthActivity.CONNECTION_NAME_EXTRA), is(true)); + assertThat(extras.getString(WebAuthActivity.CONNECTION_NAME_EXTRA), is("facebook")); + assertThat(extras.containsKey(WebAuthActivity.FULLSCREEN_EXTRA), is(true)); + assertThat(extras.getBoolean(WebAuthActivity.FULLSCREEN_EXTRA), is(true)); + + assertThat(webViewIntent.intent, hasComponent(WebAuthActivity.class.getName())); + assertThat(webViewIntent.intent, hasData(uri)); + assertThat(webViewIntent.requestCode, is(greaterThan(0))); + assertThat(activity.getDeliveredIntent(), is(nullValue())); + activityController.pause(); + //WebViewActivity is shown + + Intent authenticationResultIntent = new Intent(); + authenticationResultIntent.setData(resultUri); + activityShadow.receiveResult(webViewIntent.intent, Activity.RESULT_OK, authenticationResultIntent); + + assertThat(activity.getDeliveredIntent(), is(notNullValue())); + assertThat(activity.getDeliveredIntent().getData(), is(resultUri)); + + assertThat(activity.isFinishing(), is(true)); + + activityController.destroy(); + } + + @SuppressWarnings("deprecation") + @Test + public void shouldAuthenticateAfterRecreatedUsingWebView() throws Exception { + verifyNoMoreInteractions(customTabsController); + + AuthenticationActivity.authenticateUsingWebView(callerActivity, uri, 123, "facebook", true); + verify(callerActivity).startActivityForResult(intentCaptor.capture(), eq(123)); + + createActivity(intentCaptor.getValue()); + activityController.create().start().resume(); + final ShadowActivity.IntentForResult webViewIntent = activityShadow.getNextStartedActivityForResult(); + + Bundle extras = webViewIntent.intent.getExtras(); + assertThat(extras.containsKey(WebAuthActivity.CONNECTION_NAME_EXTRA), is(true)); + assertThat(extras.getString(WebAuthActivity.CONNECTION_NAME_EXTRA), is("facebook")); + assertThat(extras.containsKey(WebAuthActivity.FULLSCREEN_EXTRA), is(true)); + assertThat(extras.getBoolean(WebAuthActivity.FULLSCREEN_EXTRA), is(true)); + + assertThat(webViewIntent.intent, hasComponent(WebAuthActivity.class.getName())); + assertThat(webViewIntent.intent, hasData(uri)); + assertThat(webViewIntent.requestCode, is(greaterThan(0))); + assertThat(activity.getDeliveredIntent(), is(nullValue())); + //WebViewActivity is shown + //Memory needed. Let's kill the activity + Intent authenticationResultIntent = new Intent(); + authenticationResultIntent.setData(resultUri); + recreateAndCallActivityResult(123, authenticationResultIntent); + + assertThat(activity.getDeliveredIntent(), is(notNullValue())); + assertThat(activity.getDeliveredIntent().getData(), is(resultUri)); + + assertThat(activity.isFinishing(), is(true)); + + activityController.destroy(); + } + + @SuppressWarnings("deprecation") + @Test + public void shouldCancelAuthenticationUsingWebView() throws Exception { + verifyNoMoreInteractions(customTabsController); + + AuthenticationActivity.authenticateUsingWebView(callerActivity, uri, 123, "facebook", true); + verify(callerActivity).startActivityForResult(intentCaptor.capture(), eq(123)); + + createActivity(intentCaptor.getValue()); + activityController.create().start().resume(); + final ShadowActivity.IntentForResult webViewIntent = activityShadow.getNextStartedActivityForResult(); + + Bundle extras = webViewIntent.intent.getExtras(); + assertThat(extras.containsKey(WebAuthActivity.CONNECTION_NAME_EXTRA), is(true)); + assertThat(extras.getString(WebAuthActivity.CONNECTION_NAME_EXTRA), is("facebook")); + assertThat(extras.containsKey(WebAuthActivity.FULLSCREEN_EXTRA), is(true)); + assertThat(extras.getBoolean(WebAuthActivity.FULLSCREEN_EXTRA), is(true)); + + assertThat(webViewIntent.intent, hasComponent(WebAuthActivity.class.getName())); + assertThat(webViewIntent.intent, hasData(uri)); + assertThat(webViewIntent.requestCode, is(greaterThan(0))); + assertThat(activity.getDeliveredIntent(), is(nullValue())); + activityController.pause().stop(); + //WebViewActivity is shown + + Intent authenticationResultIntent = new Intent(); + authenticationResultIntent.setData(resultUri); + activityShadow.receiveResult(webViewIntent.intent, Activity.RESULT_CANCELED, authenticationResultIntent); + + assertThat(activity.getDeliveredIntent(), is(nullValue())); + assertThat(activity.isFinishing(), is(true)); + + activityController.destroy(); + } + + @SuppressWarnings("deprecation") + @Test + public void shouldLaunchForBrowserAuthentication() throws Exception { + AuthenticationActivity.authenticateUsingBrowser(callerActivity, uri); + verify(callerActivity).startActivity(intentCaptor.capture()); + + Intent intent = intentCaptor.getValue(); + Assert.assertThat(intent, is(notNullValue())); + Assert.assertThat(intent, hasComponent(AuthenticationActivity.class.getName())); + Assert.assertThat(intent, hasFlag(Intent.FLAG_ACTIVITY_CLEAR_TOP)); + Assert.assertThat(intent, hasData(uri)); + + Bundle extras = intent.getExtras(); + Assert.assertThat(extras.containsKey(AuthenticationActivity.EXTRA_CONNECTION_NAME), is(false)); + Assert.assertThat(extras.containsKey(AuthenticationActivity.EXTRA_USE_FULL_SCREEN), is(false)); + Assert.assertThat(extras.containsKey(AuthenticationActivity.EXTRA_USE_BROWSER), is(true)); + Assert.assertThat(extras.getBoolean(AuthenticationActivity.EXTRA_USE_BROWSER), is(true)); + } + + @SuppressWarnings("deprecation") + @Test + public void shouldLaunchForWebViewAuthentication() throws Exception { + AuthenticationActivity.authenticateUsingWebView(callerActivity, uri, 123, "facebook", true); + verify(callerActivity).startActivityForResult(intentCaptor.capture(), eq(123)); + + Intent intent = intentCaptor.getValue(); + Assert.assertThat(intent, is(notNullValue())); + Assert.assertThat(intent, hasComponent(AuthenticationActivity.class.getName())); + Assert.assertThat(intent, hasFlag(Intent.FLAG_ACTIVITY_CLEAR_TOP)); + Assert.assertThat(intent, hasData(uri)); + + Bundle extras = intentCaptor.getValue().getExtras(); + Assert.assertThat(extras.containsKey(AuthenticationActivity.EXTRA_CONNECTION_NAME), is(true)); + Assert.assertThat(extras.getString(AuthenticationActivity.EXTRA_CONNECTION_NAME), is("facebook")); + Assert.assertThat(extras.containsKey(AuthenticationActivity.EXTRA_USE_FULL_SCREEN), is(true)); + Assert.assertThat(extras.getBoolean(AuthenticationActivity.EXTRA_USE_FULL_SCREEN), is(true)); + Assert.assertThat(extras.containsKey(AuthenticationActivity.EXTRA_USE_BROWSER), is(true)); + Assert.assertThat(extras.getBoolean(AuthenticationActivity.EXTRA_USE_BROWSER), is(false)); + } + + @Test + public void shouldCreateCustomTabsController() throws Exception { + final AuthenticationActivity authenticationActivity = new AuthenticationActivity(); + final CustomTabsController controller = authenticationActivity.createCustomTabsController(RuntimeEnvironment.application); + + assertThat(controller, is(notNullValue())); + } + + private void recreateAndCallNewIntent(Intent data) { + Bundle outState = new Bundle(); + activityController.saveInstanceState(outState); + activityController.pause().stop().destroy(); + createActivity(null); + activityController.create(outState).start().restoreInstanceState(outState); + activityController.newIntent(data); + activityController.resume(); + } + + private void recreateAndCallActivityResult(int reqCode, Intent data) { + Bundle outState = new Bundle(); + activityController.saveInstanceState(outState); + activityController.pause().stop().destroy(); + createActivity(null); + activityController.create(outState).start().restoreInstanceState(outState); + activity.onActivityResult(reqCode, Activity.RESULT_OK, data); + activityController.resume(); + } +} \ No newline at end of file diff --git a/auth0/src/test/java/com/auth0/android/provider/CustomTabsControllerTest.java b/auth0/src/test/java/com/auth0/android/provider/CustomTabsControllerTest.java new file mode 100644 index 000000000..ba83f7d13 --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/provider/CustomTabsControllerTest.java @@ -0,0 +1,255 @@ +package com.auth0.android.provider; + +import android.content.ActivityNotFoundException; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.ActivityInfo; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.net.Uri; +import android.support.customtabs.CustomTabsCallback; +import android.support.customtabs.CustomTabsClient; +import android.support.customtabs.CustomTabsIntent; +import android.support.customtabs.CustomTabsServiceConnection; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Matchers; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.mockito.internal.verification.VerificationModeFactory; +import org.mockito.verification.Timeout; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.util.ArrayList; +import java.util.List; + +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.core.Is.is; +import static org.junit.Assert.assertThat; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyInt; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@RunWith(RobolectricTestRunner.class) +@Config(constants = com.auth0.android.auth0.BuildConfig.class, sdk = 21, manifest = Config.NONE) +public class CustomTabsControllerTest { + + private static final String DEFAULT_BROWSER_PACKAGE = "com.auth0.browser"; + private static final String CHROME_STABLE_PACKAGE = "com.android.chrome"; + private static final String CHROME_SYSTEM_PACKAGE = "com.google.android.apps.chrome"; + private static final String CHROME_BETA_PACKAGE = "com.android.chrome.beta"; + private static final String CHROME_DEV_PACKAGE = "com.android.chrome.dev"; + private static final String CUSTOM_TABS_BROWSER_1 = "com.browser.customtabs1"; + private static final String CUSTOM_TABS_BROWSER_2 = "com.browser.customtabs2"; + private static final long MAX_TEST_WAIT_TIME_MS = 2000; + + @Mock + private Context context; + @Mock + private Uri uri; + @Mock + private CustomTabsClient customTabsClient; + @Captor + private ArgumentCaptor launchIntentCaptor; + @Captor + private ArgumentCaptor serviceIntentCaptor; + @Captor + private ArgumentCaptor serviceConnectionCaptor; + @Rule + public ExpectedException exception = ExpectedException.none(); + + private CustomTabsController controller; + + + @Before + public void setUp() throws Exception { + MockitoAnnotations.initMocks(this); + controller = new CustomTabsController(context, DEFAULT_BROWSER_PACKAGE); + } + + @Test + public void shouldChooseNullBrowserIfNoBrowserAvailable() throws Exception { + preparePackageManagerForCustomTabs(null); + String bestPackage = CustomTabsController.getBestBrowserPackage(context); + assertThat(bestPackage, is(nullValue())); + } + + @Test + public void shouldChooseDefaultBrowserIfIsCustomTabsCapable() throws Exception { + preparePackageManagerForCustomTabs(DEFAULT_BROWSER_PACKAGE, DEFAULT_BROWSER_PACKAGE); + String bestPackage = CustomTabsController.getBestBrowserPackage(context); + assertThat(bestPackage, is(DEFAULT_BROWSER_PACKAGE)); + } + + @Test + public void shouldChooseDefaultBrowserIfNoOtherBrowserIsCustomTabsCapable() throws Exception { + preparePackageManagerForCustomTabs(DEFAULT_BROWSER_PACKAGE); + String bestPackage = CustomTabsController.getBestBrowserPackage(context); + assertThat(bestPackage, is(DEFAULT_BROWSER_PACKAGE)); + } + + @Test + public void shouldChooseChromeStableOverOtherCustomTabsCapableBrowsers() throws Exception { + preparePackageManagerForCustomTabs(DEFAULT_BROWSER_PACKAGE, CHROME_STABLE_PACKAGE, CHROME_SYSTEM_PACKAGE, CHROME_BETA_PACKAGE, CHROME_DEV_PACKAGE, CUSTOM_TABS_BROWSER_1, CUSTOM_TABS_BROWSER_2); + String bestPackage = CustomTabsController.getBestBrowserPackage(context); + assertThat(bestPackage, is(CHROME_STABLE_PACKAGE)); + } + + @Test + public void shouldChooseChromeSystemOverOtherCustomTabsCapableBrowsers() throws Exception { + preparePackageManagerForCustomTabs(DEFAULT_BROWSER_PACKAGE, CHROME_SYSTEM_PACKAGE, CHROME_BETA_PACKAGE, CHROME_DEV_PACKAGE, CUSTOM_TABS_BROWSER_1, CUSTOM_TABS_BROWSER_2); + String bestPackage = CustomTabsController.getBestBrowserPackage(context); + assertThat(bestPackage, is(CHROME_SYSTEM_PACKAGE)); + } + + @Test + public void shouldChooseChromeBetaOverOtherCustomTabsCapableBrowsers() throws Exception { + preparePackageManagerForCustomTabs(DEFAULT_BROWSER_PACKAGE, CHROME_BETA_PACKAGE, CHROME_DEV_PACKAGE, CUSTOM_TABS_BROWSER_1, CUSTOM_TABS_BROWSER_2); + String bestPackage = CustomTabsController.getBestBrowserPackage(context); + assertThat(bestPackage, is(CHROME_BETA_PACKAGE)); + } + + @Test + public void shouldChooseChromeDevOverOtherCustomTabsCapableBrowsers() throws Exception { + preparePackageManagerForCustomTabs(DEFAULT_BROWSER_PACKAGE, CHROME_DEV_PACKAGE, CUSTOM_TABS_BROWSER_1, CUSTOM_TABS_BROWSER_2); + String bestPackage = CustomTabsController.getBestBrowserPackage(context); + assertThat(bestPackage, is(CHROME_DEV_PACKAGE)); + } + + @Test + public void shouldChooseCustomTabsCapableBrowserIfAvailable() throws Exception { + preparePackageManagerForCustomTabs(DEFAULT_BROWSER_PACKAGE, CUSTOM_TABS_BROWSER_1, CUSTOM_TABS_BROWSER_2); + String bestPackage = CustomTabsController.getBestBrowserPackage(context); + assertThat(bestPackage, is(CUSTOM_TABS_BROWSER_1)); + } + + @Test + public void shouldUnbind() throws Exception { + bindService(true); + connectBoundService(); + + controller.unbindService(); + verify(context).unbindService(serviceConnectionCaptor.capture()); + final CustomTabsServiceConnection connection = serviceConnectionCaptor.getValue(); + CustomTabsServiceConnection controllerConnection = controller; + assertThat(connection, is(equalTo(controllerConnection))); + } + + @Test + public void shouldBindAndLaunchUri() throws Exception { + bindService(true); + controller.launchUri(uri); + connectBoundService(); + + verify(context, timeout(MAX_TEST_WAIT_TIME_MS)).startActivity(launchIntentCaptor.capture()); + Intent intent = launchIntentCaptor.getValue(); + assertThat(intent.getAction(), is(Intent.ACTION_VIEW)); + assertThat(intent.hasExtra(CustomTabsIntent.EXTRA_SESSION), is(true)); + assertThat(intent.getData(), is(uri)); + assertThat(intent.getFlags() & Intent.FLAG_ACTIVITY_NO_HISTORY, is(Intent.FLAG_ACTIVITY_NO_HISTORY)); + } + + @Test + public void shouldFailToBindButLaunchUri() throws Exception { + bindService(false); + controller.launchUri(uri); + + verify(context, timeout(MAX_TEST_WAIT_TIME_MS)).startActivity(launchIntentCaptor.capture()); + Intent intent = launchIntentCaptor.getValue(); + assertThat(intent.getAction(), is(Intent.ACTION_VIEW)); + assertThat(intent.getData(), is(uri)); + assertThat(intent.getFlags() & Intent.FLAG_ACTIVITY_NO_HISTORY, is(Intent.FLAG_ACTIVITY_NO_HISTORY)); + } + + @Test + public void shouldNotLaunchUriIfContextNoLongerValid() throws Exception { + bindService(true); + controller.clearContext(); + controller.launchUri(uri); + verify(context, never()).startActivity(any(Intent.class)); + } + + @Test + public void shouldLaunchUriWithFallbackIfCustomTabIntentFails() throws Exception { + doThrow(ActivityNotFoundException.class) + .doNothing() + .when(context).startActivity(any(Intent.class)); + controller.launchUri(uri); + + verify(context, new Timeout(MAX_TEST_WAIT_TIME_MS, VerificationModeFactory.times(2))).startActivity(launchIntentCaptor.capture()); + List intents = launchIntentCaptor.getAllValues(); + + Intent customTabIntent = intents.get(0); + assertThat(customTabIntent.getAction(), is(Intent.ACTION_VIEW)); + assertThat(customTabIntent.getData(), is(uri)); + assertThat(customTabIntent.getFlags() & Intent.FLAG_ACTIVITY_NO_HISTORY, is(Intent.FLAG_ACTIVITY_NO_HISTORY)); + assertThat(customTabIntent.hasExtra(CustomTabsIntent.EXTRA_SESSION), is(true)); + + Intent fallbackIntent = intents.get(1); + assertThat(fallbackIntent.getAction(), is(Intent.ACTION_VIEW)); + assertThat(fallbackIntent.getData(), is(uri)); + assertThat(fallbackIntent.getFlags() & Intent.FLAG_ACTIVITY_NO_HISTORY, is(Intent.FLAG_ACTIVITY_NO_HISTORY)); + assertThat(fallbackIntent.hasExtra(CustomTabsIntent.EXTRA_SESSION), is(false)); + } + + //Helper Methods + + @SuppressWarnings("WrongConstant") + private void bindService(boolean willSucceed) { + Mockito.doReturn(willSucceed).when(context).bindService( + serviceIntentCaptor.capture(), + serviceConnectionCaptor.capture(), + Mockito.anyInt()); + controller.bindService(); + Intent intent = serviceIntentCaptor.getValue(); + assertThat(intent.getPackage(), is(DEFAULT_BROWSER_PACKAGE)); + } + + private void connectBoundService() { + CustomTabsServiceConnection conn = serviceConnectionCaptor.getValue(); + conn.onCustomTabsServiceConnected(new ComponentName(DEFAULT_BROWSER_PACKAGE, DEFAULT_BROWSER_PACKAGE + ".CustomTabsService"), customTabsClient); + verify(customTabsClient).newSession(Matchers.eq(null)); + verify(customTabsClient).warmup(eq(0L)); + } + + @SuppressWarnings("WrongConstant") + private void preparePackageManagerForCustomTabs(String defaultBrowserPackage, String... customTabEnabledPackages) { + PackageManager pm = mock(PackageManager.class); + when(context.getPackageManager()).thenReturn(pm); + ResolveInfo defaultPackage = resolveInfoForPackageName(defaultBrowserPackage); + when(pm.resolveActivity(any(Intent.class), anyInt())).thenReturn(defaultPackage); + when(pm.resolveService(any(Intent.class), eq(0))).thenReturn(defaultPackage); + + List customTabsCapable = new ArrayList<>(); + for (String customTabEnabledPackage : customTabEnabledPackages) { + customTabsCapable.add(resolveInfoForPackageName(customTabEnabledPackage)); + } + when(pm.queryIntentActivities(any(Intent.class), eq(0))).thenReturn(customTabsCapable); + } + + private ResolveInfo resolveInfoForPackageName(String packageName) { + if (packageName == null) { + return null; + } + ResolveInfo resInfo = mock(ResolveInfo.class); + resInfo.activityInfo = new ActivityInfo(); + resInfo.activityInfo.packageName = packageName; + return resInfo; + } +} \ No newline at end of file diff --git a/auth0/src/test/java/com/auth0/android/provider/OAuthManagerTest.java b/auth0/src/test/java/com/auth0/android/provider/OAuthManagerTest.java index d2c8af2bc..133bf8b96 100644 --- a/auth0/src/test/java/com/auth0/android/provider/OAuthManagerTest.java +++ b/auth0/src/test/java/com/auth0/android/provider/OAuthManagerTest.java @@ -14,6 +14,7 @@ import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; +import java.util.Date; import java.util.HashMap; import static org.hamcrest.CoreMatchers.is; @@ -67,8 +68,9 @@ public void shouldUseFullScreen() throws Exception { @Test public void shouldMergeCredentials() throws Exception { - Credentials urlCredentials = new Credentials("urlId", "urlAccess", "urlType", "urlRefresh", 9999L); - Credentials codeCredentials = new Credentials("codeId", "codeAccess", "codeType", "codeRefresh", 9999L); + Date expiresAt = new Date(); + Credentials urlCredentials = new Credentials("urlId", "urlAccess", "urlType", "urlRefresh", expiresAt, "urlScope"); + Credentials codeCredentials = new Credentials("codeId", "codeAccess", "codeType", "codeRefresh", expiresAt, "codeScope"); Credentials merged = OAuthManager.mergeCredentials(urlCredentials, codeCredentials); assertThat(merged.getIdToken(), is(codeCredentials.getIdToken())); @@ -76,12 +78,16 @@ public void shouldMergeCredentials() throws Exception { assertThat(merged.getType(), is(codeCredentials.getType())); assertThat(merged.getRefreshToken(), is(codeCredentials.getRefreshToken())); assertThat(merged.getExpiresIn(), is(codeCredentials.getExpiresIn())); + assertThat(merged.getExpiresAt(), is(expiresAt)); + assertThat(merged.getExpiresAt(), is(codeCredentials.getExpiresAt())); + assertThat(merged.getScope(), is(codeCredentials.getScope())); } @Test + @SuppressWarnings("ConstantConditions") public void shouldPreferNonNullValuesWhenMergingCredentials() throws Exception { - Credentials urlCredentials = new Credentials("urlId", "urlAccess", "urlType", "urlRefresh", 9999L); - Credentials codeCredentials = new Credentials(null, null, null, null, null); + Credentials urlCredentials = new Credentials("urlId", "urlAccess", "urlType", "urlRefresh", new Date(), "urlScope"); + Credentials codeCredentials = new Credentials(null, null, null, null, null, null); Credentials merged = OAuthManager.mergeCredentials(urlCredentials, codeCredentials); assertThat(merged.getIdToken(), is(urlCredentials.getIdToken())); @@ -89,6 +95,8 @@ public void shouldPreferNonNullValuesWhenMergingCredentials() throws Exception { assertThat(merged.getType(), is(urlCredentials.getType())); assertThat(merged.getRefreshToken(), is(urlCredentials.getRefreshToken())); assertThat(merged.getExpiresIn(), is(urlCredentials.getExpiresIn())); + assertThat(merged.getScope(), is(urlCredentials.getScope())); + assertThat(merged.getExpiresAt(), is(urlCredentials.getExpiresAt())); } @Test diff --git a/auth0/src/test/java/com/auth0/android/provider/RedirectActivityTest.java b/auth0/src/test/java/com/auth0/android/provider/RedirectActivityTest.java new file mode 100644 index 000000000..655d567ec --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/provider/RedirectActivityTest.java @@ -0,0 +1,86 @@ +package com.auth0.android.provider; + +import android.content.Intent; +import android.net.Uri; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import org.robolectric.shadows.ShadowActivity; +import org.robolectric.util.ActivityController; + +import static android.support.test.espresso.intent.matcher.IntentMatchers.hasComponent; +import static android.support.test.espresso.intent.matcher.IntentMatchers.hasData; +import static android.support.test.espresso.intent.matcher.IntentMatchers.hasFlags; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; +import static org.hamcrest.core.Is.is; +import static org.robolectric.Shadows.shadowOf; + +@RunWith(RobolectricTestRunner.class) +@Config(constants = com.auth0.android.auth0.BuildConfig.class, sdk = 18, manifest = Config.NONE) +public class RedirectActivityTest { + + + @Mock + private Uri uri; + + private RedirectActivity activity; + private ShadowActivity activityShadow; + private ActivityController activityController; + + @Before + public void setUp() throws Exception { + MockitoAnnotations.initMocks(this); + } + + private void createActivity(Intent launchIntent) { + activityController = Robolectric.buildActivity(RedirectActivity.class, launchIntent); + activity = activityController.get(); + activityShadow = shadowOf(activity); + } + + @SuppressWarnings("deprecation") + @Test + public void shouldLaunchAuthenticationActivityWithDataOnSuccess() throws Exception { + Intent resultIntent = new Intent(); + resultIntent.setData(uri); + + createActivity(resultIntent); + activityController.create().start().resume(); + + Intent authenticationIntent = activityShadow.getNextStartedActivity(); + assertThat(authenticationIntent, is(notNullValue())); + assertThat(authenticationIntent, hasComponent(AuthenticationActivity.class.getName())); + assertThat(authenticationIntent, hasData(uri)); + assertThat(authenticationIntent, hasFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP)); + + assertThat(activity.isFinishing(), is(true)); + activityController.destroy(); + } + + @SuppressWarnings("deprecation") + @Test + public void shouldLaunchAuthenticationActivityWithoutDataOnCancel() throws Exception { + Intent resultIntent = new Intent(); + resultIntent.setData(null); + + createActivity(resultIntent); + activityController.create().start().resume(); + + Intent authenticationIntent = activityShadow.getNextStartedActivity(); + assertThat(authenticationIntent, is(notNullValue())); + assertThat(authenticationIntent, hasComponent(AuthenticationActivity.class.getName())); + assertThat(authenticationIntent.getData(), is(nullValue())); + assertThat(authenticationIntent, hasFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP)); + + assertThat(activity.isFinishing(), is(true)); + activityController.destroy(); + } +} \ No newline at end of file diff --git a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.java b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.java index cde1d8f73..4a9e7103c 100644 --- a/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.java +++ b/auth0/src/test/java/com/auth0/android/provider/WebAuthProviderTest.java @@ -2,11 +2,12 @@ import android.app.Activity; import android.app.Dialog; -import android.content.ComponentName; import android.content.Context; import android.content.Intent; +import android.content.ServiceConnection; import android.content.res.Resources; import android.net.Uri; +import android.os.Bundle; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.util.Base64; @@ -31,13 +32,12 @@ import java.io.UnsupportedEncodingException; import java.util.Collections; +import java.util.Date; import java.util.HashMap; import java.util.Map; import java.util.Set; -import static android.support.test.espresso.intent.matcher.IntentMatchers.hasAction; import static android.support.test.espresso.intent.matcher.IntentMatchers.hasComponent; -import static android.support.test.espresso.intent.matcher.IntentMatchers.hasExtra; import static android.support.test.espresso.intent.matcher.IntentMatchers.hasFlag; import static android.support.test.espresso.intent.matcher.UriMatchers.hasHost; import static android.support.test.espresso.intent.matcher.UriMatchers.hasParamWithName; @@ -55,9 +55,10 @@ import static org.junit.Assert.assertThat; import static org.junit.Assert.assertTrue; import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyInt; import static org.mockito.Matchers.anyString; import static org.mockito.Matchers.eq; -import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; @@ -70,6 +71,7 @@ public class WebAuthProviderTest { private static final int REQUEST_CODE = 11; private static final String KEY_STATE = "state"; private static final String KEY_NONCE = "nonce"; + private static final long CURRENT_TIME_MS = 1234567890000L; @Mock private AuthCallback callback; @@ -89,6 +91,10 @@ public void setUp() throws Exception { MockitoAnnotations.initMocks(this); activity = spy(Robolectric.buildActivity(Activity.class).get()); account = new Auth0("clientId", "domain"); + + //Next line is needed to avoid CustomTabService from being bound to Test environment + //noinspection WrongConstant + doReturn(false).when(activity).bindService(any(Intent.class), any(ServiceConnection.class), anyInt()); } @SuppressWarnings("deprecation") @@ -944,56 +950,55 @@ public void shouldBuildAuthorizeURIWithResponseTypeCode() throws Exception { @SuppressWarnings("deprecation") @Test public void shouldStartWithBrowser() throws Exception { - Activity activity = mock(Activity.class); - Context appContext = mock(Context.class); - when(activity.getApplicationContext()).thenReturn(appContext); - when(activity.getPackageName()).thenReturn("package"); - when(appContext.getPackageName()).thenReturn("package"); WebAuthProvider.init(account) .useBrowser(true) .useCodeGrant(false) .start(activity, callback); - ArgumentCaptor intentCaptor = ArgumentCaptor.forClass(Intent.class); verify(activity).startActivity(intentCaptor.capture()); - assertThat(intentCaptor.getValue(), is(notNullValue())); - assertThat(intentCaptor.getValue(), hasAction(Intent.ACTION_VIEW)); - assertThat(intentCaptor.getValue(), hasFlag(Intent.FLAG_ACTIVITY_NO_HISTORY)); + Intent intent = intentCaptor.getValue(); + assertThat(intent, is(notNullValue())); + assertThat(intent, hasComponent(AuthenticationActivity.class.getName())); + assertThat(intent, hasFlag(Intent.FLAG_ACTIVITY_CLEAR_TOP)); + assertThat(intent.getData(), is(notNullValue())); + + Bundle extras = intentCaptor.getValue().getExtras(); + assertThat(extras.containsKey(AuthenticationActivity.EXTRA_CONNECTION_NAME), is(false)); + assertThat(extras.containsKey(AuthenticationActivity.EXTRA_USE_FULL_SCREEN), is(false)); + assertThat(extras.containsKey(AuthenticationActivity.EXTRA_USE_BROWSER), is(true)); + assertThat(extras.getBoolean(AuthenticationActivity.EXTRA_USE_BROWSER), is(true)); } @SuppressWarnings("deprecation") @Test public void shouldStartWithWebViewAndDefaultConnection() throws Exception { - Activity activity = mock(Activity.class); - Context appContext = mock(Context.class); - when(activity.getApplicationContext()).thenReturn(appContext); - when(activity.getPackageName()).thenReturn("package"); - when(appContext.getPackageName()).thenReturn("package"); WebAuthProvider.init(account) .useBrowser(false) .useCodeGrant(false) .useFullscreen(false) .start(activity, callback, REQUEST_CODE); - ArgumentCaptor intentCaptor = ArgumentCaptor.forClass(Intent.class); verify(activity).startActivityForResult(intentCaptor.capture(), any(Integer.class)); - ComponentName expComponent = new ComponentName("package", WebAuthActivity.class.getName()); - assertThat(intentCaptor.getValue(), is(notNullValue())); - assertThat(intentCaptor.getValue(), hasComponent(expComponent)); - assertThat(intentCaptor.getValue(), hasExtra(WebAuthActivity.CONNECTION_NAME_EXTRA, null)); - assertThat(intentCaptor.getValue(), hasExtra(WebAuthActivity.FULLSCREEN_EXTRA, false)); + Intent intent = intentCaptor.getValue(); + assertThat(intent, is(notNullValue())); + assertThat(intent, hasComponent(AuthenticationActivity.class.getName())); + assertThat(intent, hasFlag(Intent.FLAG_ACTIVITY_CLEAR_TOP)); + assertThat(intent.getData(), is(notNullValue())); + + Bundle extras = intentCaptor.getValue().getExtras(); + assertThat(extras.containsKey(AuthenticationActivity.EXTRA_CONNECTION_NAME), is(true)); + assertThat(extras.getString(AuthenticationActivity.EXTRA_CONNECTION_NAME), is(nullValue())); + assertThat(extras.containsKey(AuthenticationActivity.EXTRA_USE_FULL_SCREEN), is(true)); + assertThat(extras.getBoolean(AuthenticationActivity.EXTRA_USE_FULL_SCREEN), is(false)); + assertThat(extras.containsKey(AuthenticationActivity.EXTRA_USE_BROWSER), is(true)); + assertThat(extras.getBoolean(AuthenticationActivity.EXTRA_USE_BROWSER), is(false)); } @SuppressWarnings("deprecation") @Test public void shouldStartWithWebViewAndCustomConnection() throws Exception { - Activity activity = mock(Activity.class); - Context appContext = mock(Context.class); - when(activity.getApplicationContext()).thenReturn(appContext); - when(activity.getPackageName()).thenReturn("package"); - when(appContext.getPackageName()).thenReturn("package"); WebAuthProvider.init(account) .useBrowser(false) .withConnection("my-connection") @@ -1001,14 +1006,21 @@ public void shouldStartWithWebViewAndCustomConnection() throws Exception { .useFullscreen(true) .start(activity, callback); - ArgumentCaptor intentCaptor = ArgumentCaptor.forClass(Intent.class); verify(activity).startActivityForResult(intentCaptor.capture(), any(Integer.class)); - ComponentName expComponent = new ComponentName("package", WebAuthActivity.class.getName()); - assertThat(intentCaptor.getValue(), is(notNullValue())); - assertThat(intentCaptor.getValue(), hasComponent(expComponent)); - assertThat(intentCaptor.getValue(), hasExtra(WebAuthActivity.CONNECTION_NAME_EXTRA, "my-connection")); - assertThat(intentCaptor.getValue(), hasExtra(WebAuthActivity.FULLSCREEN_EXTRA, true)); + Intent intent = intentCaptor.getValue(); + assertThat(intent, is(notNullValue())); + assertThat(intent, hasComponent(AuthenticationActivity.class.getName())); + assertThat(intent, hasFlag(Intent.FLAG_ACTIVITY_CLEAR_TOP)); + assertThat(intent.getData(), is(notNullValue())); + + Bundle extras = intent.getExtras(); + assertThat(extras.containsKey(AuthenticationActivity.EXTRA_CONNECTION_NAME), is(true)); + assertThat(extras.getString(AuthenticationActivity.EXTRA_CONNECTION_NAME), is("my-connection")); + assertThat(extras.containsKey(AuthenticationActivity.EXTRA_USE_FULL_SCREEN), is(true)); + assertThat(extras.getBoolean(AuthenticationActivity.EXTRA_USE_FULL_SCREEN), is(true)); + assertThat(extras.containsKey(AuthenticationActivity.EXTRA_USE_BROWSER), is(true)); + assertThat(extras.getBoolean(AuthenticationActivity.EXTRA_USE_BROWSER), is(false)); } @SuppressWarnings({"deprecation", "ThrowableResultOfMethodCallIgnored"}) @@ -1045,7 +1057,7 @@ public void shouldResumeWithRequestCodeWithResponseTypeIdToken() throws Exceptio String sentNonce = uri.getQueryParameter(KEY_NONCE); assertThat(sentState, is(not(isEmptyOrNullString()))); assertThat(sentNonce, is(not(isEmptyOrNullString()))); - Intent intent = createAuthIntent(createHash(customNonceJWT(sentNonce), null, null, null, sentState, null, null)); + Intent intent = createAuthIntent(createHash(customNonceJWT(sentNonce), null, null, null, null, sentState, null, null)); assertTrue(WebAuthProvider.resume(REQUEST_CODE, Activity.RESULT_OK, intent)); verify(callback).onSuccess(any(Credentials.class)); @@ -1065,9 +1077,10 @@ public void shouldResumeWithIntentWithResponseTypeIdToken() throws Exception { String sentNonce = uri.getQueryParameter(KEY_NONCE); assertThat(sentState, is(not(isEmptyOrNullString()))); assertThat(sentNonce, is(not(isEmptyOrNullString()))); - Intent intent = createAuthIntent(createHash(customNonceJWT(sentNonce), null, null, null, sentState, null, null)); + Intent intent = createAuthIntent(createHash(customNonceJWT(sentNonce), null, null, null, null, sentState, null, null)); assertTrue(WebAuthProvider.resume(intent)); + verify(callback).onSuccess(any(Credentials.class)); } @@ -1088,7 +1101,7 @@ public Object answer(InvocationOnMock invocation) throws Throwable { .useCodeGrant(true) .withPKCE(pkce) .start(activity, callback); - Intent intent = createAuthIntent(createHash("iToken", "aToken", null, "refresh_token", "1234567890", null, null)); + Intent intent = createAuthIntent(createHash("iToken", "aToken", null, "refresh_token", null, "1234567890", null, null)); int DEFAULT_REQUEST_CODE = 110; assertTrue(WebAuthProvider.resume(DEFAULT_REQUEST_CODE, Activity.RESULT_OK, intent)); } @@ -1096,7 +1109,8 @@ public Object answer(InvocationOnMock invocation) throws Throwable { @SuppressWarnings("deprecation") @Test public void shouldResumeWithIntentWithCodeGrant() throws Exception { - final Credentials codeCredentials = new Credentials("codeId", "codeAccess", "codeType", "codeRefresh", 9999L); + Date expiresAt = new Date(); + final Credentials codeCredentials = new Credentials("codeId", "codeAccess", "codeType", "codeRefresh", expiresAt, "codeScope"); PKCE pkce = Mockito.mock(PKCE.class); Mockito.doAnswer(new Answer() { @Override @@ -1116,7 +1130,7 @@ public Object answer(InvocationOnMock invocation) throws Throwable { String sentState = uri.getQueryParameter(KEY_STATE); assertThat(sentState, is(not(isEmptyOrNullString()))); - Intent intent = createAuthIntent(createHash("urlId", "urlAccess", "urlRefresh", "urlType", sentState, null, null)); + Intent intent = createAuthIntent(createHash("urlId", "urlAccess", "urlRefresh", "urlType", 1111L, sentState, null, null)); assertTrue(WebAuthProvider.resume(intent)); ArgumentCaptor credentialsCaptor = ArgumentCaptor.forClass(Credentials.class); @@ -1127,13 +1141,15 @@ public Object answer(InvocationOnMock invocation) throws Throwable { assertThat(credentialsCaptor.getValue().getAccessToken(), is("codeAccess")); assertThat(credentialsCaptor.getValue().getRefreshToken(), is("codeRefresh")); assertThat(credentialsCaptor.getValue().getType(), is("codeType")); - assertThat(credentialsCaptor.getValue().getExpiresIn(), is(9999L)); + assertThat(credentialsCaptor.getValue().getExpiresAt(), is(expiresAt)); + assertThat(credentialsCaptor.getValue().getScope(), is("codeScope")); } @SuppressWarnings("deprecation") @Test public void shouldResumeWithRequestCodeWithCodeGrant() throws Exception { - final Credentials codeCredentials = new Credentials("codeId", "codeAccess", "codeType", "codeRefresh", 9999L); + Date expiresAt = new Date(); + final Credentials codeCredentials = new Credentials("codeId", "codeAccess", "codeType", "codeRefresh", expiresAt, "codeScope"); PKCE pkce = Mockito.mock(PKCE.class); Mockito.doAnswer(new Answer() { @Override @@ -1153,7 +1169,7 @@ public Object answer(InvocationOnMock invocation) throws Throwable { String sentState = uri.getQueryParameter(KEY_STATE); assertThat(sentState, is(not(isEmptyOrNullString()))); - Intent intent = createAuthIntent(createHash("urlId", "urlAccess", "urlRefresh", "urlType", sentState, null, null)); + Intent intent = createAuthIntent(createHash("urlId", "urlAccess", "urlRefresh", "urlType", 1111L, sentState, null, null)); assertTrue(WebAuthProvider.resume(REQUEST_CODE, Activity.RESULT_OK, intent)); ArgumentCaptor credentialsCaptor = ArgumentCaptor.forClass(Credentials.class); @@ -1164,7 +1180,8 @@ public Object answer(InvocationOnMock invocation) throws Throwable { assertThat(credentialsCaptor.getValue().getAccessToken(), is("codeAccess")); assertThat(credentialsCaptor.getValue().getRefreshToken(), is("codeRefresh")); assertThat(credentialsCaptor.getValue().getType(), is("codeType")); - assertThat(credentialsCaptor.getValue().getExpiresIn(), is(9999L)); + assertThat(credentialsCaptor.getValue().getExpiresAt(), is(expiresAt)); + assertThat(credentialsCaptor.getValue().getScope(), is("codeScope")); } @SuppressWarnings("deprecation") @@ -1180,10 +1197,18 @@ public void shouldResumeWithIntentWithImplicitGrant() throws Exception { String sentState = uri.getQueryParameter(KEY_STATE); assertThat(sentState, is(not(isEmptyOrNullString()))); - Intent intent = createAuthIntent(createHash("urlId", "urlAccess", "urlRefresh", "urlType", sentState, null, null)); + Intent intent = createAuthIntent(createHash("urlId", "urlAccess", "urlRefresh", "urlType", 1111L, sentState, null, null)); assertTrue(WebAuthProvider.resume(intent)); - verify(callback).onSuccess(any(Credentials.class)); + ArgumentCaptor credentialsCaptor = ArgumentCaptor.forClass(Credentials.class); + verify(callback).onSuccess(credentialsCaptor.capture()); + + assertThat(credentialsCaptor.getValue(), is(notNullValue())); + assertThat(credentialsCaptor.getValue().getIdToken(), is("urlId")); + assertThat(credentialsCaptor.getValue().getAccessToken(), is("urlAccess")); + assertThat(credentialsCaptor.getValue().getRefreshToken(), is("urlRefresh")); + assertThat(credentialsCaptor.getValue().getType(), is("urlType")); + assertThat(credentialsCaptor.getValue().getExpiresIn(), is(1111L)); } @SuppressWarnings("deprecation") @@ -1199,10 +1224,43 @@ public void shouldResumeWithRequestCodeWithImplicitGrant() throws Exception { String sentState = uri.getQueryParameter(KEY_STATE); assertThat(sentState, is(not(isEmptyOrNullString()))); - Intent intent = createAuthIntent(createHash("urlId", "urlAccess", "urlRefresh", "urlType", sentState, null, null)); + Intent intent = createAuthIntent(createHash("urlId", "urlAccess", "urlRefresh", "urlType", 1111L, sentState, null, null)); assertTrue(WebAuthProvider.resume(REQUEST_CODE, Activity.RESULT_OK, intent)); - verify(callback).onSuccess(any(Credentials.class)); + ArgumentCaptor credentialsCaptor = ArgumentCaptor.forClass(Credentials.class); + verify(callback).onSuccess(credentialsCaptor.capture()); + + assertThat(credentialsCaptor.getValue(), is(notNullValue())); + assertThat(credentialsCaptor.getValue().getIdToken(), is("urlId")); + assertThat(credentialsCaptor.getValue().getAccessToken(), is("urlAccess")); + assertThat(credentialsCaptor.getValue().getRefreshToken(), is("urlRefresh")); + assertThat(credentialsCaptor.getValue().getType(), is("urlType")); + assertThat(credentialsCaptor.getValue().getExpiresIn(), is(1111L)); + } + + @Test + public void shouldCalculateExpiresAtDateOnResumeAuthentication() throws Exception { + WebAuthProvider.init(account) + .useCodeGrant(false) + .start(activity, callback, REQUEST_CODE); + WebAuthProvider.getInstance().setCurrentTimeInMillis(CURRENT_TIME_MS); + + verify(activity).startActivity(intentCaptor.capture()); + Uri uri = intentCaptor.getValue().getData(); + assertThat(uri, is(notNullValue())); + + String sentState = uri.getQueryParameter(KEY_STATE); + assertThat(sentState, is(not(isEmptyOrNullString()))); + Intent intent = createAuthIntent(createHash("urlId", "urlAccess", "urlRefresh", "urlType", 1111L, sentState, null, null)); + assertTrue(WebAuthProvider.resume(REQUEST_CODE, Activity.RESULT_OK, intent)); + + ArgumentCaptor credentialsCaptor = ArgumentCaptor.forClass(Credentials.class); + verify(callback).onSuccess(credentialsCaptor.capture()); + + assertThat(credentialsCaptor.getValue(), is(notNullValue())); + long expirationTime = CURRENT_TIME_MS + 1111L * 1000; + assertThat(credentialsCaptor.getValue().getExpiresAt(), is(notNullValue())); + assertThat(credentialsCaptor.getValue().getExpiresAt().getTime(), is(expirationTime)); } @SuppressWarnings("deprecation") @@ -1222,7 +1280,7 @@ public Object answer(InvocationOnMock invocation) throws Throwable { .useCodeGrant(true) .withPKCE(pkce) .start(activity, callback); - Intent intent = createAuthIntent(createHash("urlId", "urlAccess", "urlRefresh", "urlType", "1234567890", null, null)); + Intent intent = createAuthIntent(createHash("urlId", "urlAccess", "urlRefresh", "urlType", 1111L, "1234567890", null, null)); assertTrue(WebAuthProvider.resume(intent)); verify(callback).onFailure(dialog); @@ -1245,7 +1303,7 @@ public Object answer(InvocationOnMock invocation) throws Throwable { .useCodeGrant(true) .withPKCE(pkce) .start(activity, callback); - Intent intent = createAuthIntent(createHash("urlId", "urlAccess", "urlRefresh", "urlType", "1234567890", null, null)); + Intent intent = createAuthIntent(createHash("urlId", "urlAccess", "urlRefresh", "urlType", 1111L, "1234567890", null, null)); assertTrue(WebAuthProvider.resume(intent)); verify(callback).onFailure(exception); @@ -1258,7 +1316,7 @@ public void shouldFailToResumeWithIntentWithAccessDenied() throws Exception { .withState("1234567890") .useCodeGrant(false) .start(activity, callback); - Intent intent = createAuthIntent(createHash("iToken", "aToken", null, "refresh_token", "1234567890", "access_denied", null)); + Intent intent = createAuthIntent(createHash("iToken", "aToken", null, "refresh_token", null, "1234567890", "access_denied", null)); assertTrue(WebAuthProvider.resume(intent)); verify(callback).onFailure(authExceptionCaptor.capture()); @@ -1275,7 +1333,7 @@ public void shouldFailToResumeWithRequestCodeWithAccessDenied() throws Exception .withState("1234567890") .useCodeGrant(false) .start(activity, callback, REQUEST_CODE); - Intent intent = createAuthIntent(createHash("iToken", "aToken", null, "refresh_token", "1234567890", "access_denied", null)); + Intent intent = createAuthIntent(createHash("iToken", "aToken", null, "refresh_token", null, "1234567890", "access_denied", null)); assertTrue(WebAuthProvider.resume(REQUEST_CODE, Activity.RESULT_OK, intent)); verify(callback).onFailure(authExceptionCaptor.capture()); @@ -1292,7 +1350,7 @@ public void shouldFailToResumeWithIntentWithRuleError() throws Exception { .withState("1234567890") .useCodeGrant(false) .start(activity, callback); - Intent intent = createAuthIntent(createHash("iToken", "aToken", null, "refresh_token", "1234567890", "unauthorized", "Custom Rule Error")); + Intent intent = createAuthIntent(createHash("iToken", "aToken", null, "refresh_token", null, "1234567890", "unauthorized", "Custom Rule Error")); assertTrue(WebAuthProvider.resume(intent)); verify(callback).onFailure(authExceptionCaptor.capture()); @@ -1309,7 +1367,7 @@ public void shouldFailToResumeWithRequestCodeWithRuleError() throws Exception { .withState("1234567890") .useCodeGrant(false) .start(activity, callback, REQUEST_CODE); - Intent intent = createAuthIntent(createHash("iToken", "aToken", null, "refresh_token", "1234567890", "unauthorized", "Custom Rule Error")); + Intent intent = createAuthIntent(createHash("iToken", "aToken", null, "refresh_token", null, "1234567890", "unauthorized", "Custom Rule Error")); assertTrue(WebAuthProvider.resume(REQUEST_CODE, Activity.RESULT_OK, intent)); verify(callback).onFailure(authExceptionCaptor.capture()); @@ -1326,7 +1384,7 @@ public void shouldFailToResumeWithIntentWithConfigurationInvalid() throws Except .withState("1234567890") .useCodeGrant(false) .start(activity, callback); - Intent intent = createAuthIntent(createHash("iToken", "aToken", null, "refresh_token", "1234567890", "some other error", null)); + Intent intent = createAuthIntent(createHash("iToken", "aToken", null, "refresh_token", null, "1234567890", "some other error", null)); assertTrue(WebAuthProvider.resume(intent)); verify(callback).onFailure(authExceptionCaptor.capture()); @@ -1343,7 +1401,7 @@ public void shouldFailToResumeWithRequestCodeWithConfigurationInvalid() throws E .withState("1234567890") .useCodeGrant(false) .start(activity, callback, REQUEST_CODE); - Intent intent = createAuthIntent(createHash("iToken", "aToken", null, "refresh_token", "1234567890", "some other error", null)); + Intent intent = createAuthIntent(createHash("iToken", "aToken", null, "refresh_token", null, "1234567890", "some other error", null)); assertTrue(WebAuthProvider.resume(REQUEST_CODE, Activity.RESULT_OK, intent)); verify(callback).onFailure(authExceptionCaptor.capture()); @@ -1360,7 +1418,7 @@ public void shouldFailToResumeWithIntentWithInvalidState() throws Exception { .withState("abcdefghijk") .useCodeGrant(false) .start(activity, callback); - Intent intent = createAuthIntent(createHash("iToken", "aToken", null, "refresh_token", "1234567890", null, null)); + Intent intent = createAuthIntent(createHash("iToken", "aToken", null, "refresh_token", null, "1234567890", null, null)); assertTrue(WebAuthProvider.resume(intent)); verify(callback).onFailure(authExceptionCaptor.capture()); @@ -1377,7 +1435,7 @@ public void shouldFailToResumeWithRequestCodeWithInvalidState() throws Exception .withState("abcdefghijk") .useCodeGrant(false) .start(activity, callback, REQUEST_CODE); - Intent intent = createAuthIntent(createHash("iToken", "aToken", null, "refresh_token", "1234567890", null, null)); + Intent intent = createAuthIntent(createHash("iToken", "aToken", null, "refresh_token", null, "1234567890", null, null)); assertTrue(WebAuthProvider.resume(REQUEST_CODE, Activity.RESULT_OK, intent)); verify(callback).onFailure(authExceptionCaptor.capture()); @@ -1395,7 +1453,7 @@ public void shouldFailToResumeWithIntentWithInvalidNonce() throws Exception { .withNonce("0987654321") .withResponseType(ResponseType.ID_TOKEN) .start(activity, callback); - Intent intent = createAuthIntent(createHash("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJub25jZSI6IjEyMzQ1Njc4OTAifQ.oUb6xFIEPJQrFbel_Js4SaOwpFfM_kxHxI7xDOHgghk", null, null, null, "state", null, null)); + Intent intent = createAuthIntent(createHash("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJub25jZSI6IjEyMzQ1Njc4OTAifQ.oUb6xFIEPJQrFbel_Js4SaOwpFfM_kxHxI7xDOHgghk", null, null, null, null, "state", null, null)); assertTrue(WebAuthProvider.resume(intent)); verify(callback).onFailure(authExceptionCaptor.capture()); @@ -1413,7 +1471,7 @@ public void shouldFailToResumeWithRequestCodeWithInvalidNonce() throws Exception .withNonce("0987654321") .withResponseType(ResponseType.ID_TOKEN) .start(activity, callback, REQUEST_CODE); - Intent intent = createAuthIntent(createHash("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJub25jZSI6IjEyMzQ1Njc4OTAifQ.oUb6xFIEPJQrFbel_Js4SaOwpFfM_kxHxI7xDOHgghk", null, null, null, "state", null, null)); + Intent intent = createAuthIntent(createHash("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJub25jZSI6IjEyMzQ1Njc4OTAifQ.oUb6xFIEPJQrFbel_Js4SaOwpFfM_kxHxI7xDOHgghk", null, null, null, null, "state", null, null)); assertTrue(WebAuthProvider.resume(REQUEST_CODE, Activity.RESULT_OK, intent)); verify(callback).onFailure(authExceptionCaptor.capture()); @@ -1431,7 +1489,7 @@ public void shouldFailToResumeWithUnexpectedRequestCode() throws Exception { .useCodeGrant(false) .start(activity, callback); - Intent intent = createAuthIntent(createHash("iToken", "aToken", null, "refresh_token", "1234567890", null, null)); + Intent intent = createAuthIntent(createHash("iToken", "aToken", null, "refresh_token", null, "1234567890", null, null)); assertFalse(WebAuthProvider.resume(999, Activity.RESULT_OK, intent)); } @@ -1443,7 +1501,7 @@ public void shouldFailToResumeWithResultCancelled() throws Exception { .useCodeGrant(false) .start(activity, callback, REQUEST_CODE); - Intent intent = createAuthIntent(createHash("iToken", "aToken", null, "refresh_token", "1234567890", null, null)); + Intent intent = createAuthIntent(createHash("iToken", "aToken", null, "refresh_token", null, "1234567890", null, null)); assertFalse(WebAuthProvider.resume(REQUEST_CODE, Activity.RESULT_CANCELED, intent)); } @@ -1455,7 +1513,7 @@ public void shouldFailToResumeWithResultNotOK() throws Exception { .useCodeGrant(false) .start(activity, callback, REQUEST_CODE); - Intent intent = createAuthIntent(createHash("iToken", "aToken", null, "refresh_token", "1234567890", null, null)); + Intent intent = createAuthIntent(createHash("iToken", "aToken", null, "refresh_token", null, "1234567890", null, null)); assertFalse(WebAuthProvider.resume(REQUEST_CODE, 999, intent)); } @@ -1524,7 +1582,7 @@ public void shouldClearInstanceAfterSuccessAuthenticationWithIntent() throws Exc .start(activity, callback); assertThat(WebAuthProvider.getInstance(), is(notNullValue())); - Intent intent = createAuthIntent(createHash("iToken", "aToken", null, "refresh_token", "1234567890", null, null)); + Intent intent = createAuthIntent(createHash("iToken", "aToken", null, "refresh_token", null, "1234567890", null, null)); assertTrue(WebAuthProvider.resume(intent)); assertThat(WebAuthProvider.getInstance(), is(nullValue())); } @@ -1536,7 +1594,7 @@ public void shouldClearInstanceAfterSuccessAuthenticationWithRequestCode() throw .start(activity, callback, REQUEST_CODE); assertThat(WebAuthProvider.getInstance(), is(notNullValue())); - Intent intent = createAuthIntent(createHash("iToken", "aToken", null, "refresh_token", "1234567890", null, null)); + Intent intent = createAuthIntent(createHash("iToken", "aToken", null, "refresh_token", null, "1234567890", null, null)); assertTrue(WebAuthProvider.resume(REQUEST_CODE, Activity.RESULT_OK, intent)); assertThat(WebAuthProvider.getInstance(), is(nullValue())); } @@ -1549,7 +1607,7 @@ private Intent createAuthIntent(String hash) { return intent; } - private String createHash(@Nullable String idToken, @Nullable String accessToken, @Nullable String refreshToken, @Nullable String tokenType, @Nullable String state, @Nullable String error, @Nullable String errorDescription) { + private String createHash(@Nullable String idToken, @Nullable String accessToken, @Nullable String refreshToken, @Nullable String tokenType, @Nullable Long expiresIn, @Nullable String state, @Nullable String error, @Nullable String errorDescription) { String hash = "#"; if (accessToken != null) { hash = hash.concat("access_token=") @@ -1571,6 +1629,11 @@ private String createHash(@Nullable String idToken, @Nullable String accessToken .concat(tokenType) .concat("&"); } + if (expiresIn != null) { + hash = hash.concat("expires_in=") + .concat(String.valueOf(expiresIn)) + .concat("&"); + } if (state != null) { hash = hash.concat("state=") .concat(state) diff --git a/auth0/src/test/java/com/auth0/android/request/internal/CredentialsDeserializerTest.java b/auth0/src/test/java/com/auth0/android/request/internal/CredentialsDeserializerTest.java new file mode 100644 index 000000000..84e029c98 --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/request/internal/CredentialsDeserializerTest.java @@ -0,0 +1,38 @@ +package com.auth0.android.request.internal; + +import com.auth0.android.result.Credentials; +import com.auth0.android.result.CredentialsMock; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +import org.junit.Test; + +import java.io.FileReader; + +import static org.hamcrest.CoreMatchers.notNullValue; +import static org.hamcrest.core.Is.is; +import static org.junit.Assert.assertThat; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.spy; + +public class CredentialsDeserializerTest { + + private static final String OPEN_ID_OFFLINE_ACCESS_CREDENTIALS = "src/test/resources/credentials_openid_refresh_token.json"; + + private static final long CURRENT_TIME_MS = 1234567890000L; + + @Test + public void shouldSetExpiresAtFromExpiresIn() throws Exception { + final CredentialsDeserializer deserializer = new CredentialsDeserializer(); + final CredentialsDeserializer spy = spy(deserializer); + doReturn(CredentialsMock.CURRENT_TIME_MS).when(spy).getCurrentTimeInMillis(); + + final Gson gson = new GsonBuilder() + .registerTypeAdapter(Credentials.class, spy) + .create(); + + final Credentials credentials = gson.getAdapter(Credentials.class).fromJson(new FileReader(OPEN_ID_OFFLINE_ACCESS_CREDENTIALS)); + assertThat(credentials.getExpiresAt(), is(notNullValue())); + assertThat(credentials.getExpiresAt().getTime(), is(CURRENT_TIME_MS + 86000 * 1000)); + } +} \ No newline at end of file diff --git a/auth0/src/test/java/com/auth0/android/request/internal/CredentialsGsonTest.java b/auth0/src/test/java/com/auth0/android/request/internal/CredentialsGsonTest.java index 91b3d88a3..63bd346a1 100755 --- a/auth0/src/test/java/com/auth0/android/request/internal/CredentialsGsonTest.java +++ b/auth0/src/test/java/com/auth0/android/request/internal/CredentialsGsonTest.java @@ -32,14 +32,15 @@ public void setUp() throws Exception { } @Test - public void shouldNotFailWithEmptyJson() throws Exception { - buildCredentialsFrom(json(EMPTY_OBJECT)); + public void shouldFailWithInvalidJson() throws Exception { + expectedException.expect(JsonParseException.class); + buildCredentialsFrom(json(INVALID)); } @Test - public void shouldFailWithInvalidJson() throws Exception { + public void shouldFailWithEmptyJson() throws Exception { expectedException.expect(JsonParseException.class); - buildCredentialsFrom(json(INVALID)); + buildCredentialsFrom(json(EMPTY_OBJECT)); } @Test @@ -61,6 +62,8 @@ public void shouldReturnBasic() throws Exception { assertThat(credentials.getType(), equalTo("bearer")); assertThat(credentials.getRefreshToken(), is(nullValue())); assertThat(credentials.getExpiresIn(), is(86000L)); + assertThat(credentials.getExpiresAt(), is(notNullValue())); + assertThat(credentials.getScope(), is(nullValue())); } @Test @@ -72,6 +75,8 @@ public void shouldReturnWithIdToken() throws Exception { assertThat(credentials.getType(), equalTo("bearer")); assertThat(credentials.getRefreshToken(), is(nullValue())); assertThat(credentials.getExpiresIn(), is(86000L)); + assertThat(credentials.getExpiresAt(), is(notNullValue())); + assertThat(credentials.getScope(), is("openid profile")); } @Test @@ -83,6 +88,8 @@ public void shouldReturnWithRefreshToken() throws Exception { assertThat(credentials.getType(), equalTo("bearer")); assertThat(credentials.getRefreshToken(), is(notNullValue())); assertThat(credentials.getExpiresIn(), is(86000L)); + assertThat(credentials.getExpiresAt(), is(notNullValue())); + assertThat(credentials.getScope(), is("openid profile")); } private Credentials buildCredentialsFrom(Reader json) throws IOException { diff --git a/auth0/src/test/java/com/auth0/android/result/CredentialsMock.java b/auth0/src/test/java/com/auth0/android/result/CredentialsMock.java new file mode 100644 index 000000000..0270c406b --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/result/CredentialsMock.java @@ -0,0 +1,24 @@ +package com.auth0.android.result; + +import android.support.annotation.Nullable; + +import java.util.Date; + +@SuppressWarnings("WeakerAccess") +public class CredentialsMock extends Credentials { + + public static final long CURRENT_TIME_MS = 1234567890000L; + + public CredentialsMock(@Nullable String idToken, @Nullable String accessToken, @Nullable String type, @Nullable String refreshToken, @Nullable Long expiresIn) { + super(idToken, accessToken, type, refreshToken, expiresIn); + } + + public CredentialsMock(@Nullable String idToken, @Nullable String accessToken, @Nullable String type, @Nullable String refreshToken, @Nullable Date expiresAt, @Nullable String scope) { + super(idToken, accessToken, type, refreshToken, expiresAt, scope); + } + + @Override + long getCurrentTimeInMillis() { + return CURRENT_TIME_MS; + } +} \ No newline at end of file diff --git a/auth0/src/test/java/com/auth0/android/result/CredentialsTest.java b/auth0/src/test/java/com/auth0/android/result/CredentialsTest.java index 92f918f55..43c31dc40 100644 --- a/auth0/src/test/java/com/auth0/android/result/CredentialsTest.java +++ b/auth0/src/test/java/com/auth0/android/result/CredentialsTest.java @@ -1,43 +1,43 @@ package com.auth0.android.result; -import org.junit.Before; import org.junit.Test; +import java.util.Date; + import static org.hamcrest.core.Is.is; import static org.junit.Assert.assertThat; public class CredentialsTest { - private Credentials credentials; - - @Before - public void setUp() throws Exception { - credentials = new Credentials("idToken", "accessToken", "type", "refreshToken", 999999L); - } - @Test - public void getIdToken() throws Exception { + public void shouldCreateWithExpiresAtDateAndSetExpiresIn() throws Exception { + Date date = new Date(); + long expiresIn = (date.getTime() - CredentialsMock.CURRENT_TIME_MS) / 1000; + Credentials credentials = new CredentialsMock("idToken", "accessToken", "type", "refreshToken", date, "scope"); assertThat(credentials.getIdToken(), is("idToken")); - } - - @Test - public void getAccessToken() throws Exception { assertThat(credentials.getAccessToken(), is("accessToken")); - } - - @Test - public void getType() throws Exception { assertThat(credentials.getType(), is("type")); + assertThat(credentials.getRefreshToken(), is("refreshToken")); + assertThat(credentials.getExpiresIn(), is(expiresIn)); + assertThat(credentials.getExpiresAt(), is(date)); + assertThat(credentials.getScope(), is("scope")); } @Test - public void getRefreshToken() throws Exception { + public void shouldCreateWithExpiresInAndSetExpiresAt() throws Exception { + Credentials credentials = new CredentialsMock("idToken", "accessToken", "type", "refreshToken", 86400L); + assertThat(credentials.getIdToken(), is("idToken")); + assertThat(credentials.getAccessToken(), is("accessToken")); + assertThat(credentials.getType(), is("type")); assertThat(credentials.getRefreshToken(), is("refreshToken")); + assertThat(credentials.getExpiresIn(), is(86400L)); + Date expirationDate = new Date(CredentialsMock.CURRENT_TIME_MS + 86400L * 1000); + assertThat(credentials.getExpiresAt(), is(expirationDate)); } @Test - public void getExpiresIn() throws Exception { - assertThat(credentials.getExpiresIn(), is(999999L)); + public void getScope() throws Exception { + Credentials credentials = new Credentials("idToken", "accessToken", "type", "refreshToken", new Date(), "openid profile"); + assertThat(credentials.getScope(), is("openid profile")); } - } \ No newline at end of file diff --git a/auth0/src/test/resources/credentials_openid.json b/auth0/src/test/resources/credentials_openid.json index 154adda6a..549d054f3 100755 --- a/auth0/src/test/resources/credentials_openid.json +++ b/auth0/src/test/resources/credentials_openid.json @@ -2,5 +2,6 @@ "id_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczovL3NhbXBsZXMuYXV0aDAuY29tLyIsInN1YiI6ImF1dGgwfDUzYjk5NWY4YmNlNjhkOWZjOTAwMDk5YyIsImF1ZCI6Ikk5bWhVcmZrVEdGVldqbEVxWlNUQ0JVRkFCTGJKRkdMMyIsImV4cCI6MTQ2NTEwOTAzMywiaWF0IjoxNDY1MDczMDMzfQ.TdRc-lnVcX0LT7ZySzVysjVcYzAUIRnCPufTO8VV6g8", "access_token": "s6GS5FGJN2jfd4l6", "token_type": "bearer", - "expires_in": 86000 + "expires_in": 86000, + "scope": "openid profile" } \ No newline at end of file diff --git a/auth0/src/test/resources/credentials_openid_refresh_token.json b/auth0/src/test/resources/credentials_openid_refresh_token.json index 7ece3bbc8..90ec3756a 100755 --- a/auth0/src/test/resources/credentials_openid_refresh_token.json +++ b/auth0/src/test/resources/credentials_openid_refresh_token.json @@ -3,5 +3,6 @@ "id_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczovL3NhbXBsZXMuYXV0aDAuY29tLyIsInN1YiI6ImF1dGgwfDUzYjk5NWY4YmNlNjhkOWZjOTAwMDk5YyIsImF1ZCI6Ikk5bWhVcmZrVEdGVldqbEVxWlNUQ0JVRkFCTGJKRkdMMyIsImV4cCI6MTQ2NTEwOTAzMywiaWF0IjoxNDY1MDczMDMzfQ.TdRc-lnVcX0LT7ZySzVysjVcYzAUIRnCPufTO8VV6g8", "access_token": "s6GS5FGJN2jfd4l6", "token_type": "bearer", - "expires_in": 86000 + "expires_in": 86000, + "scope": "openid profile" } \ No newline at end of file