This snippet works with Doorkeeper and uses refresh token (with refresh token rotation).
Last active
July 15, 2023 08:29
-
-
Save burgalon/dd289d54098068701aee to your computer and use it in GitHub Desktop.
Implementing OAuth2 with AccountManager, Retrofit and Dagger
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@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); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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 file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.+' | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)); | |
} | |
} |
Thanks
How do you test the authenticator since it relies on the account manager?
Gracias!!
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
At first, I did not understand this gist but after many posts on Android Authenticator, this gist is a great resource.
Thank you for sharing.