vitorsalgado
7/16/2014 - 10:23 AM

Implementing OAuth2 with AccountManager, Retrofit and Dagger

Implementing OAuth2 with AccountManager, Retrofit and Dagger

dependencies {
    ....
    
    compile 'com.android.support:appcompat-v7:19.+'

    compile 'com.squareup.dagger:dagger:1.2.1'
    provided 'com.squareup.dagger:dagger-compiler:1.2.1'

    compile 'com.squareup:otto:1.3.+'

    compile 'com.squareup.okhttp:okhttp:1.5.+'
    compile 'com.squareup.picasso:picasso:2.2.0'
    compile 'com.squareup.retrofit:retrofit:1.5.+'
    debugCompile 'com.squareup.retrofit:retrofit-mock:1.5.+'

    compile 'com.jakewharton:butterknife:5.1.+'
    compile 'com.jakewharton.timber:timber:2.2.2'
    debugCompile 'com.jakewharton.madge:madge:1.1.1'
    debugCompile 'com.jakewharton.scalpel:scalpel:1.1.1'

    compile 'com.netflix.rxjava:rxjava-core:0.19.+'
    compile 'com.netflix.rxjava:rxjava-android:0.19.+'

    compile 'com.f2prateek.dart:dart:1.1.+'
}
// src/main/res/xml/authenticator.xml
<?xml version="1.0" encoding="utf-8"?>

<account-authenticator xmlns:android="http://schemas.android.com/apk/res/android"
    android:accountType="com.example"
    android:icon="@drawable/app_icon"
    android:label="@string/application_name"
    android:smallIcon="@drawable/app_icon" />

This snippet works with Doorkeeper and uses refresh token (with refresh token rotation).

class LoginFragment extends BaseFragment {
  @Inject AccountManager accountManager;
  @InjectView(R.id.et_email) EditText emailText;
  @InjectView(R.id.et_password) EditText passwordText;
  @InjectView(R.id.sign_in) Button signInButton;
  @Inject @ClientId String clientId;
  @Inject @ClientSecret String clientSecret;

  ...
  

  private void doLogin(final String email, String password) {
    Observable<AccessToken> accessTokenObservable =
        apiService.getAccessTokenObservable(email, password,
            clientId,
            clientSecret);

    subscribe(accessTokenObservable, new EndlessObserver<AccessToken>() {
      @Override public void onNext(AccessToken accessToken) {
        Account account = addOrFindAccount(email, accessToken.refreshToken);
        // accountManager.setUserData(account, AccountAuthenticator.USER_ID, accessToken.userId);
        accountManager.setAuthToken(account, AuthConstants.AUTHTOKEN_TYPE, accessToken.accessToken);
        finishAccountAdd(email, accessToken.accessToken, accessToken.refreshToken);
      }

      @Override public void onError(Throwable throwable) {
        Timber.e(throwable, "Could not sign in");
        Toast.makeText(getActivity(), throwable.getMessage(), Toast.LENGTH_LONG).show();
      }
    });
  }
  
  private Account addOrFindAccount(String email, String password) {
    Account[] accounts = accountManager.getAccountsByType(AuthConstants.ACCOUNT_TYPE);
    Account account = accounts.length != 0 ? accounts[0] :
        new Account(email, AuthConstants.ACCOUNT_TYPE);

    if (accounts.length == 0) {
      accountManager.addAccountExplicitly(account, password, null);
    } else {
      accountManager.setPassword(accounts[0], password);
    }
    return account;
  }

  private void finishAccountAdd(String accountName, String authToken, String password) {
    final Intent intent = new Intent();
    intent.putExtra(AccountManager.KEY_ACCOUNT_NAME, accountName);
    intent.putExtra(AccountManager.KEY_ACCOUNT_TYPE, AuthConstants.ACCOUNT_TYPE);
    if (authToken != null)
      intent.putExtra(AccountManager.KEY_AUTHTOKEN, authToken);
    intent.putExtra(AccountManager.KEY_PASSWORD, password);
    setAccountAuthenticatorResult(intent.getExtras());
    getActivity().setResult(Activity.RESULT_OK, intent);
    getActivity().finish();

    // Go back to the main activity
    startActivity(new Intent(activityContext, MainActivity.class));
  }

}
// An activity which handles listening to AccountManager changes and invoking AuthenticatorActivity if no account are available
// Also hanldes Dagger injections, provides an Otto bus, and allows subscription to observables 
// while listening to activity lifecycle
@SuppressLint("Registered") public class BaseActivity extends FragmentActivity
    implements OnAccountsUpdateListener {
  @Inject AppContainer appContainer;
  @Inject ScopedBus bus;
  @Inject AccountManager accountManager;

  private ViewGroup container;
  private ObjectGraph activityGraph;

  private SubscriptionManager<Activity> subscriptionManager;

  @Override protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    buildActivityGraphAndInject();

    // Inject any extras
    Dart.inject(this);
  }

  private void buildActivityGraphAndInject() {
    // Create the activity graph by .plus-ing our modules onto the application graph.
    App app = App.get(this);
    activityGraph = app.getApplicationGraph().plus(getModules().toArray());

    // Inject ourselves so subclasses will have dependencies fulfilled when this method returns.
    activityGraph.inject(this);

    container = appContainer.get(this, app);
  }

  /** Inject the given object into the activity graph. */
  public void inject(Object o) {
    activityGraph.inject(o);
  }

  /**
   * A list of modules to use for the individual activity graph. Subclasses can override this
   * method to provide additional modules provided they call and include the modules returned by
   * calling {@code super.getModules()}.
   */
  protected List<Object> getModules() {
    return Arrays.<Object>asList(new ActivityModule(this));
  }

  @Override protected void onResume() {
    super.onResume();
    bus.resumed();
    bus.register(this);

    // Watch to make sure the account still exists.
    if(requireLogin()) accountManager.addOnAccountsUpdatedListener(this, null, true);
  }

  @Override protected void onPause() {
    bus.unregister(this);
    bus.paused();

    if(requireLogin()) accountManager.removeOnAccountsUpdatedListener(this);

    super.onPause();
  }

  protected boolean requireLogin() {
    return true;
  }

  @Override protected void onDestroy() {
    // Eagerly clear the reference to the activity graph to allow it to be garbage collected as
    // soon as possible.
    activityGraph = null;
    if(subscriptionManager!=null) subscriptionManager.unsubscribeAll();

    super.onDestroy();
  }

  protected void inflateLayout(int layoutResID) {
    getLayoutInflater().inflate(layoutResID, container);
    // Inject Views
    ButterKnife.inject(this);
  }

  public static BaseActivity get(Fragment fragment) {
    return (BaseActivity) fragment.getActivity();
  }

  @Override public void onAccountsUpdated(Account[] accounts) {
    for (Account account : accounts) {
      if (AuthConstants.ACCOUNT_TYPE.equals(account.type)) {
        return;
      }
    }

    // No accounts so start the authenticator activity
    Intent intent = new Intent(this, AuthenticatorActivity.class);
    startActivity(intent);
    finish();
  }

  protected <O> Subscription subscribe(final Observable<O> source, final Observer<O> observer) {
    if (subscriptionManager == null) {
      subscriptionManager = new ActivitySubscriptionManager(this);
    }
    return subscriptionManager.subscribe(source, observer);
  }
}
public interface AuthConstants {
  // Account type id
  String ACCOUNT_TYPE = "com.example";

  // Account name
  String ACCOUNT_NAME = "Example";

  // Provider ID
  String PROVIDER_AUTHORITY = "com.example.sync";

  // Auth token type
  String AUTHTOKEN_TYPE = ACCOUNT_TYPE;
}
// Retrofit interface
public interface ApiService {
  // Auth
  @FormUrlEncoded
  @POST("/oauth/token?grant_type=password") AccessToken getAccessToken(
      @Field("username") String email,
      @Field("password") String password,
      @Field("client_id") String clientId,
      @Field("client_secret") String clientSecret);

  @FormUrlEncoded
  @POST("/oauth/token?grant_type=refresh_token") AccessToken refreshAccessToken(
      @Field("refresh_token") String refreshToken,
      @Field("client_id") String clientId,
      @Field("client_secret") String clientSecret);

  @FormUrlEncoded
  @POST("/oauth/token?grant_type=password") Observable<AccessToken> getAccessTokenObservable(
      @Field("username") String email,
      @Field("password") String password,
      @Field("client_id") String clientId,
      @Field("client_secret") String clientSecret);
}
@Module(
    complete = false,
    library = true
)
public final class ApiModule {
  public static final String PRODUCTION_API_URL = "http://api.pow-app.192.168.56.1.xip.io/";
  private static final String CLIENT_ID = "CLIENT_ID";
  private static final String CLIENT_SECRET = "CLIENT_SECRET";

  @Provides @Singleton @ClientId String provideClientId() {
    return CLIENT_ID;
  }

  @Provides @Singleton @ClientSecret String provideClientSecret() {
    return CLIENT_SECRET;
  }

  @Provides @Singleton Endpoint provideEndpoint() {
    return Endpoints.newFixedEndpoint(PRODUCTION_API_URL);
  }

  @Provides @Singleton Client provideClient(OkHttpClient client) {
    return new OkClient(client);
  }

  @Provides @Singleton
  RestAdapter provideRestAdapter(Endpoint endpoint, Client client, ApiHeaders headers, Gson gson) {
    return new RestAdapter.Builder()
        .setClient(client)
        .setEndpoint(endpoint)
        .setConverter(new GsonConverter(gson))
        .setRequestInterceptor(headers)
        .setErrorHandler(new RestErrorHandler())
        .build();
  }

  @Provides @Singleton ApiService provideApiService(RestAdapter restAdapter) {
    return restAdapter.create(ApiService.class);
  }

  @Provides @Singleton ApiDatabase provideApiDatabase(ApiService service) {
    return new ApiDatabase(service);
  }
}
public final class ApiHeaders implements RequestInterceptor {

  private Application application;

  @Inject
  public ApiHeaders(Application application) {
    this.application = application;
  }

  @Override
  public void intercept(RequestFacade request) {
    AccountManager accountManager = AccountManager.get(application);
    Account[] accounts = accountManager.getAccountsByType(AuthConstants.ACCOUNT_TYPE);
    if (accounts.length != 0) {
      String token =
          accountManager.peekAuthToken(accounts[0], AuthConstants.AUTHTOKEN_TYPE);
      if (token != null) {
        request.addHeader("Authorization", "Bearer " + token);
      }
    }
    request.addHeader("Accept", "application/javascript, application/json");
  }
}
public class ApiAuthenticator implements OkAuthenticator {

  AccountManager accountManager;
  Application application;

  @Inject
  public ApiAuthenticator(Application application, AccountManager accountManager) {
    this.application = application;
    this.accountManager = accountManager;
  }

  @Override
  public Credential authenticate(Proxy proxy, URL url, List<Challenge> challenges)
      throws IOException {
    // Do not try to authenticate oauth related endpoints
    if (url.getPath().startsWith("/oauth")) return null;

    for (Challenge challenge : challenges) {
      if (challenge.getScheme().equals("Bearer")) {
        Account[] accounts = accountManager.getAccountsByType(AuthConstants.ACCOUNT_TYPE);
        if (accounts.length != 0) {
          String oldToken = accountManager.peekAuthToken(accounts[0],
              AuthConstants.AUTHTOKEN_TYPE);
          if (oldToken != null) {
            Timber.d("invalidating auth token");
            accountManager.invalidateAuthToken(AuthConstants.ACCOUNT_TYPE, oldToken);
          }
          try {
            Timber.d("calling accountManager.blockingGetAuthToken");
            String token = accountManager.blockingGetAuthToken(accounts[0],
                AuthConstants.AUTHTOKEN_TYPE, false);

            if(token==null) {
              accountManager.removeAccount(accounts[0], null, null);
            }

            // Do not retry certain URLs
            //if (url.getPath().startsWith("/donotretry")) {
            //  return null;
            //} else if (token != null) {
            if (token != null) {
              return token(token);
            }
          } catch (OperationCanceledException e) {
            e.printStackTrace();
          } catch (AuthenticatorException e) {
            e.printStackTrace();
          }
        }
      }
    }
    return null;
  }

  private Credential token(String token) {
    try {
      // TODO: when there is support for different types of Credentials, stop using reflection
      Constructor<?> constructor = Credential.class.getDeclaredConstructor(String.class);
      Assert.assertTrue(Modifier  .isPrivate(constructor.getModifiers()));
      constructor.setAccessible(true);
      return (Credential) constructor.newInstance("Bearer " + token);
    } catch (InstantiationException e) {
      e.printStackTrace();
    } catch (IllegalAccessException e) {
      e.printStackTrace();
    } catch (NoSuchMethodException e) {
      e.printStackTrace();
    } catch (InvocationTargetException e) {
      e.printStackTrace();
    }
    return null;
  }

  @Override
  public Credential authenticateProxy(Proxy proxy, URL
      url, List<Challenge> challenges) throws IOException {
    return null;
  }
}
<?xml version="1.0" encoding="utf-8"?>

<manifest
    xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.app">

    <service
        android:name=".authenticator.AccountAuthenticatorService"
        android:exported="false"
        android:process=":auth">
      <intent-filter>
        <action android:name="android.accounts.AccountAuthenticator"/>
      </intent-filter>

      <meta-data
          android:name="android.accounts.AccountAuthenticator"
          android:resource="@xml/authenticator"/>
    </service>

    <activity
        android:name=".authenticator.AuthenticatorActivity"
        android:screenOrientation="portrait"
        android:configChanges="orientation|keyboardHidden|screenSize"
        android:excludeFromRecents="true"
        android:hardwareAccelerated="true"/>

  </application>
</manifest>
public class AccountAuthenticatorService extends Service {
  private static AccountAuthenticator AUTHENTICATOR = null;

  @Override
  public IBinder onBind(Intent intent) {
    return intent.getAction().equals(ACTION_AUTHENTICATOR_INTENT) ? getAuthenticator().getIBinder() : null;
  }

  private AccountAuthenticator getAuthenticator() {
    if (AUTHENTICATOR == null)
      AUTHENTICATOR = new AccountAuthenticator(this);
    return AUTHENTICATOR;
  }
}
public class AccountAuthenticator extends AbstractAccountAuthenticator {

  private final Context context;
  @Inject @ClientId String clientId;
  @Inject @ClientSecret String clientSecret;
  @Inject ApiService apiService;

  public AccountAuthenticator(Context context) {
    super(context);

    this.context = context;
    ((App) context.getApplicationContext()).inject(this);
  }

  /*
   * The user has requested to add a new account to the system. We return an intent that will launch our login screen
   * if the user has not logged in yet, otherwise our activity will just pass the user's credentials on to the account
   * manager.
   */
  @Override
  public Bundle addAccount(AccountAuthenticatorResponse response, String accountType,
      String authTokenType, String[] requiredFeatures, Bundle options) {
    Timber.v("addAccount()");
    final Intent intent = new Intent(context, AuthenticatorActivity.class);
    intent.putExtra(AccountManager.KEY_ACCOUNT_TYPE, accountType);
    intent.putExtra(LoginFragment.PARAM_AUTHTOKEN_TYPE, authTokenType);
    intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response);
    final Bundle bundle = new Bundle();
    bundle.putParcelable(AccountManager.KEY_INTENT, intent);
    return bundle;
  }


  @Override
  public Bundle confirmCredentials(AccountAuthenticatorResponse response, Account account, Bundle options) {
    return null;
  }

  @Override
  public Bundle editProperties(AccountAuthenticatorResponse response, String accountType) {
    return null;
  }

  // See /Applications/android-sdk-macosx/samples/android-18/legacy/SampleSyncAdapter/src/com/example/android/samplesync/authenticator/Authenticator.java
  // Also take a look here https://github.com/github/android/blob/d6ba3f9fe2d88967f56e9939d8df7547127416df/app/src/main/java/com/github/mobile/accounts/AccountAuthenticator.java
  @Override
  public Bundle getAuthToken(AccountAuthenticatorResponse response, Account account, String authTokenType,
      Bundle options) throws NetworkErrorException {
    Timber.d("getAuthToken() account="+account.name+ " type="+account.type);

    final Bundle bundle = new Bundle();

    // If the caller requested an authToken type we don't support, then
    // return an error
    if (!authTokenType.equals(AUTHTOKEN_TYPE)) {
      Timber.d("invalid authTokenType" + authTokenType);
      bundle.putString(AccountManager.KEY_ERROR_MESSAGE, "invalid authTokenType");
      return bundle;
    }

    // Extract the username and password from the Account Manager, and ask
    // the server for an appropriate AuthToken
    final AccountManager accountManager = AccountManager.get(context);
    // Password is storing the refresh token
    final String password = accountManager.getPassword(account);
    if (password != null) {
      Timber.i("Trying to refresh access token");
      try {
        AccessToken accessToken = apiService.refreshAccessToken(password, clientId, clientSecret);
        if (accessToken!=null && !TextUtils.isEmpty(accessToken.accessToken)) {
          bundle.putString(AccountManager.KEY_ACCOUNT_NAME, account.name);
          bundle.putString(AccountManager.KEY_ACCOUNT_TYPE, ACCOUNT_TYPE);
          bundle.putString(AccountManager.KEY_AUTHTOKEN, accessToken.accessToken);
          accountManager.setPassword(account, accessToken.refreshToken);
          return bundle;
        }
      } catch (Exception e) {
        Timber.e(e, "Failed refreshing token.");
      }
    }

    // Otherwise... start the login intent
    Timber.i("Starting login activity");
    final Intent intent = new Intent(context, AuthenticatorActivity.class);
    intent.putExtra(LoginFragment.PARAM_USERNAME, account.name);
    intent.putExtra(LoginFragment.PARAM_AUTHTOKEN_TYPE, authTokenType);
    intent.putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response);
    bundle.putParcelable(AccountManager.KEY_INTENT, intent);
    return bundle;
  }

  @Override
  public String getAuthTokenLabel(String authTokenType) {
    return authTokenType.equals(AUTHTOKEN_TYPE) ? authTokenType : null;
  }

  @Override
  public Bundle hasFeatures(AccountAuthenticatorResponse response, Account account, String[] features)
      throws NetworkErrorException {
    final Bundle result = new Bundle();
    result.putBoolean(KEY_BOOLEAN_RESULT, false);
    return result;
  }

  @Override
  public Bundle updateCredentials(AccountAuthenticatorResponse response, Account account, String authTokenType,
      Bundle options) {
    return null;
  }
}