In this codelab, you'll learn how to use enable Single Sign-on (SSO) with Chrome Custom Tabs via the AppAuth library, and optionally push managed configuration to provide a user login hint.
You can either download all the sample code to your computer...
...or clone the GitHub repository from the command line.
$ git clone https://github.com/googlecodelabs/appauth-android-codelab
First, let's see what the finished sample app looks like. With the code downloaded, the following instructions describe how to open the completed sample app in Android Studio.
appauth-android_codelab_sso_managed
directory from the sample code folder (Select the Open an existing Android Studio project option on the welcome screen, or File > Open).Now that you've seen AppAuth in action, it's time to use AppAuth for authentication in your own app.
In Android Studio open the appauth-android_codelab_init
directory from the sample code folder (File > Open).
The project won't build right away, as we have some boilerplate for AppAuth just to preserve the state and connect some buttons. You'll need to add the AppAuth gradle dependency in the next step before it can build.
Add the following line to the build.gradle
to the in the app
directory. This will make the AppAuth library available to your project.
compile 'net.openid:appauth:0.2.0'
The dependencies should now look like:
dependencies { compile fileTree(dir: 'libs', include: ['*.jar']) testCompile 'junit:junit:4.12' compile 'com.android.support:appcompat-v7:23.3.0' compile 'com.android.support:design:23.3.0' compile 'com.squareup.okhttp3:okhttp:3.2.0' compile 'com.squareup.picasso:picasso:2.5.2' compile 'net.openid:appauth:0.2.0' }
The project already had a few dependencies that we'll be using later in the demo, like OkHttp and Picasso.
Now the dependency has been added, the project should build. Sync the project and run it.
The sample app has a Sign-in button, and some UI widgets, but the button doesn't have any sign-in functionality yet, that's what we'll add in this codelab.
Create the AuthorizationServiceConfiguration
object in the AuthorizeListener::onClick
method which declares the authorization and token endpoints of the OAuth server you wish to authorize with. In our example, we will use Google, but this will work with any compliant OAuth server.
AuthorizationServiceConfiguration serviceConfiguration = new AuthorizationServiceConfiguration(
Uri.parse("https://accounts.google.com/o/oauth2/v2/auth") /* auth endpoint */,
Uri.parse("https://www.googleapis.com/oauth2/v4/token") /* token endpoint */
);
If your server supports dynamic discovery, you can also fetch this configuration dynamically with AuthorizationServiceConfiguration.fetchFromIssuer
. We'll stick with the static configuration for simplicity.
Once you have an instance of AuthorizationServiceConfiguration
, you can now build an instance of AuthorizationRequest
which describes actual authorization request, including your OAuth client id, and the scopes you are requesting. Add the following code right below the previous block.
String clientId = "511828570984-fuprh0cm7665emlne3rnf9pk34kkn86s.apps.googleusercontent.com";
Uri redirectUri = Uri.parse("com.google.codelabs.appauth:/oauth2callback");
AuthorizationRequest.Builder builder = new AuthorizationRequest.Builder(
serviceConfiguration,
clientId,
AuthorizationRequest.RESPONSE_TYPE_CODE,
redirectUri
);
builder.setScopes("profile");
AuthorizationRequest request = builder.build();
Note that for the demo we are supplying a test OAuth client id. Be sure to register your own client ID when developing your own apps, and update the clientId
and redirectUri
values with your own (and the custom scheme registered in the AndroidManifest.xml
). If using a different OAuth server, then you'll need to register a client for that server, following their documentation.
Create an instance of the AuthorizationService
. Ideally, there is one instance of AuthorizationService
per Activity
. Still in the AuthorizeListener::onClick
method, add the following code right below the code from the previous section:
AuthorizationService authorizationService = new AuthorizationService(view.getContext());
Create the PendingIntent
to handle the authorization response, then perform the authorization request with performAuthorizationRequest
. This will open the authorization request you configured previously in a Custom Tab (or the default browser if no browsers support Custom Tabs).
String action = "com.google.codelabs.appauth.HANDLE_AUTHORIZATION_RESPONSE";
Intent postAuthorizationIntent = new Intent(action);
PendingIntent pendingIntent = PendingIntent.getActivity(view.getContext(), request.hashCode(), postAuthorizationIntent, 0);
authorizationService.performAuthorizationRequest(request, pendingIntent);
The AuthorizationService is responsible for initiating the following OAuth code flow.
At this point, if you ran the code, it would perform the authorization request in the browser, using Custom Tabs if available, but the response would go nowhere.
That wouldn't be very useful! So let's add handling for the authorization response. Here we add hooks into the app in order to receive the authorization response from the browser. This involves registering for, and handling an Intent
.
First register RedirectUriReceiverActivity
with the following intent-filters in your AndroidManifest.xml
, inside the <application>
block. This registers the app to receive the OAuth2 authorization response intent from Chrome custom tabs or the system browser on your behalf.
<activity android:name="net.openid.appauth.RedirectUriReceiverActivity">
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="com.google.codelabs.appauth"/>
</intent-filter>
</activity>
Then, add a new intent-filter
to your <activity android:name=".MainActivity">
so AppAuth can pass the authorization response to your main activity.
<intent-filter>
<action android:name="com.google.codelabs.appauth.HANDLE_AUTHORIZATION_RESPONSE"/>
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
After these changes, your AndroidManifest.xml
should have two activities defined as follows:
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="com.google.codelabs.appauth.HANDLE_AUTHORIZATION_RESPONSE"/>
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
</activity>
<activity android:name="net.openid.appauth.RedirectUriReceiverActivity">
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="com.google.codelabs.appauth"/>
</intent-filter>
</activity>
Now, back in MainActivity.java
, add the following methods to your MainActivity
class to handle the intents from RedirectUriReceiverActivity.
@Override
protected void onNewIntent(Intent intent) {
checkIntent(intent);
}
private void checkIntent(@Nullable Intent intent) {
if (intent != null) {
String action = intent.getAction();
switch (action) {
case "com.google.codelabs.appauth.HANDLE_AUTHORIZATION_RESPONSE":
if (!intent.hasExtra(USED_INTENT)) {
handleAuthorizationResponse(intent);
intent.putExtra(USED_INTENT, true);
}
break;
default:
// do nothing
}
}
}
@Override
protected void onStart() {
super.onStart();
checkIntent(getIntent());
}
AppAuth provides the AuthorizationResponse
to MainActivity
, via the provided RedirectUriReceiverActivity
. This can then be used to obtain a TokenResponse
.
Obtain the AuthorizationResponse
from the Intent
passed to the MainActivity
by adding the following code to the handleAuthorizationResponse
method:
AuthorizationResponse response = AuthorizationResponse.fromIntent(intent);
AuthorizationException error = AuthorizationException.fromIntent(intent);
final AuthState authState = new AuthState(response, error);
The AuthState
object created here is a convenient way to store details from the authorization session. You can update it with the results of new OAuth responses, and persist it to store the authorization session between app starts.
Next we will exchange that authorization code for the refresh and access tokens, and update the AuthState
instance with that response. Add the following code right below the last block.
if (response != null) {
Log.i(LOG_TAG, String.format("Handled Authorization Response %s ", authState.toJsonString()));
AuthorizationService service = new AuthorizationService(this);
service.performTokenRequest(response.createTokenExchangeRequest(), new AuthorizationService.TokenResponseCallback() {
@Override
public void onTokenRequestCompleted(@Nullable TokenResponse tokenResponse, @Nullable AuthorizationException exception) {
if (exception != null) {
Log.w(LOG_TAG, "Token Exchange failed", exception);
} else {
if (tokenResponse != null) {
authState.update(tokenResponse, exception);
persistAuthState(authState);
Log.i(LOG_TAG, String.format("Token Response [ Access Token: %s, ID Token: %s ]", tokenResponse.accessToken, tokenResponse.idToken));
}
}
}
});
}
We provided the method persistAuthState
in the starter project as some boilerplate to save and load the AuthState
object. If you're adding AppAuth to your own app you may want to consider your own persistence design.
Now you have successfully configured, executed, and handled the response – let's run the sample and see what happens.
Search for "Token Response", you should see an access token, and an id token printed to the console. This means that you have successfully authorized the user.
The purpose of this this codelab to get an access token in order to make an authenticated API call – so let's actually use that accessToken
, and make an API call in the next step.
While you can get the tokens directly from the token response, those tokens expire and must be refreshed occasionally. Using AuthState
and making your REST API calls inside authState.performActionWithFreshTokens
is recommended, as it will automatically ensure that the tokens are fresh (refreshing them when needed) before executing your code.
Here we use performActionWithFreshTokens
to get a fresh access token. Add this code to the MakeApiCallListener::onClick
method.
mAuthState.performActionWithFreshTokens(mAuthorizationService, new AuthState.AuthStateAction() {
@Override public void execute(
String accessToken,
String idToken,
AuthorizationException ex) {
if (ex != null) {
// negotiation for fresh tokens failed, check ex for more details
return;
}
// use the access token to do something ...
Log.i(LOG_TAG, String.format("TODO: make an API call with [Access Token: %s, ID Token: %s]", accessToken, idToken));
}
});
Run your code. Now when you tap the "Make API Call" button, you should see a log message with the fresh access token and id token.
Let's do something a little more interesting. We're going to replace the simple example above with a real API call that does the following:
mAuthState.performActionWithFreshTokens(mAuthorizationService, new AuthState.AuthStateAction() {
@Override
public void execute(@Nullable String accessToken, @Nullable String idToken, @Nullable AuthorizationException exception) {
new AsyncTask<String, Void, JSONObject>() {
@Override
protected JSONObject doInBackground(String... tokens) {
OkHttpClient client = new OkHttpClient();
Request request = new Request.Builder()
.url("https://www.googleapis.com/oauth2/v3/userinfo")
.addHeader("Authorization", String.format("Bearer %s", tokens[0]))
.build();
try {
Response response = client.newCall(request).execute();
String jsonBody = response.body().string();
Log.i(LOG_TAG, String.format("User Info Response %s", jsonBody));
return new JSONObject(jsonBody);
} catch (Exception exception) {
Log.w(LOG_TAG, exception);
}
return null;
}
@Override
protected void onPostExecute(JSONObject userInfo) {
if (userInfo != null) {
String fullName = userInfo.optString("name", null);
String givenName = userInfo.optString("given_name", null);
String familyName = userInfo.optString("family_name", null);
String imageUrl = userInfo.optString("picture", null);
if (!TextUtils.isEmpty(imageUrl)) {
Picasso.with(mMainActivity)
.load(imageUrl)
.placeholder(R.drawable.ic_account_circle_black_48dp)
.into(mMainActivity.mProfileView);
}
if (!TextUtils.isEmpty(fullName)) {
mMainActivity.mFullName.setText(fullName);
}
if (!TextUtils.isEmpty(givenName)) {
mMainActivity.mGivenName.setText(givenName);
}
if (!TextUtils.isEmpty(familyName)) {
mMainActivity.mFamilyName.setText(familyName);
}
String message;
if (userInfo.has("error")) {
message = String.format("%s [%s]", mMainActivity.getString(R.string.request_failed), userInfo.optString("error_description", "No description"));
} else {
message = mMainActivity.getString(R.string.request_complete);
}
Snackbar.make(mMainActivity.mProfileView, message, Snackbar.LENGTH_SHORT)
.show();
}
}
}.execute(accessToken);
}
});
That was a lot of code, but it's relatively straightforward. In the performActionWithFreshTokens
method (which gives us a fresh access token to use), we kick off an async task that makes a REST call the Google Userinfo endpoint at https://www.googleapis.com/oauth2/v3/userinfo
(in the doInBackground
method), and then updates the UI with the result (in the onPostExecute
method).
You can use this pattern to make your own asynchronous API calls!
Congratulations! You have now successfully authorized the user and performed an authenticated API call!
The API call we made to get the user's profile is just an example – Google has many RESTful APIs that require user authentication, from viewing the user's Calendar, to uploading YouTube videos on their behalf. All of these instructions work with any standard OAuth provider too, so you can use this to authenticate to any provider, for example you can authorize Spotify users, and make RESTful API calls to the Spotify API. If you publish multiple apps and manage your own identities, you can use AppAuth to achieve Single Sign-on between your own apps.
You will want to serialize the AuthState
object to disk, to preserve the authorization state between app runs.
Other common uses for AppAuth include authenticating the user to your own backend by sending the idToken
obtained from the Identity provider (which could be Google, or any other OpenID Connect provider), exchanging for your own session tokens and making authenticated API calls to your backend.
For devices under enterprise management, you can make the user experience even better using a few managed configuration tricks. For example, if you make an enterprise app (with an associated SaaS service that can authenticate to many different tenants or unique identity providers), you can make use of a login hint that provisions the domain (tenant) that authenticates the end user. In other words, your enterprise mobility manager (EMM) can provision the full email address of the user to your app so all they have to do is enter their password when prompted.
What enables EMM to provision this user email address and other managed configuration is an application restrictions API. Application restrictions allow us to define a schema of key-value pairs that can be configured by an Device Policy Controller (DPC) supplied by the EMM that has special admin privileges on the device. EMMs are able to read the schema you create by making API calls to Google Play or on device, ensuring that admins can configure your application without needing to do direct integration work with EMMs.
In the app/build.gradle
file, increase the minimum SDK version to 21, which is required for managed configuration.
minSdkVersion 21
We will start by creating the schema for our managed configuration. The first step is to add a reference in the Manifest which will point to our schema. Add the following to your manifest's <application>
element
<meta-data android:name="android.content.APP_RESTRICTIONS"
android:resource="@xml/app_restrictions" />
Now we will add the schema xml file. Start by creating a new directory named xml
in your apps res directory. Once you have done so, add a new file in the directory (right click > File > New), named app_restrictions.xml
.
Populate the xml file with the following schema
<?xml version="1.0" encoding="utf-8"?>
<restrictions xmlns:android="http://schemas.android.com/apk/res/android" >
<restriction
android:key="login_hint"
android:title="@string/login_hint_title"
android:restrictionType="string"
android:description="@string/login_hint_description" />
</restrictions>
This schema includes one key value pair, of type string. When configuring in EMM consoles admins will see the title and description attributes. All values included above are mandatory.
In addition, add the following to strings.xml in the <resources>
attribute
<string name="login_hint_title">Login Hint</string>
<string name="login_hint_description">This key will allow you to send a
login hint to the identity provider such as email or username</string>
Restrictions can also be of type integer, bool, choice, multi-select, and hidden. For more details on the kinds of things you can include in a schema, see the RestrictionsManager documentation.
Now we will enable our application to read configurations set by a DPC. Add the following two methods to MainActivity. The first function retrieves app restrictions if they are set and responds accordingly. The second registers a BroadcastReceiver which listens for changes to app restrictions that are set.
private void getAppRestrictions(){
RestrictionsManager restrictionsManager =
(RestrictionsManager) this
.getSystemService(Context.RESTRICTIONS_SERVICE);
Bundle appRestrictions = restrictionsManager.getApplicationRestrictions();
if(!appRestrictions.isEmpty()){
if(appRestrictions.getBoolean(UserManager.
KEY_RESTRICTIONS_PENDING)!=true){
mLoginHint = appRestrictions.getString(LOGIN_HINT);
}
else {
Toast.makeText(this,R.string.restrictions_pending_block_user,
Toast.LENGTH_LONG).show();
finish();
}
}
}
private void registerRestrictionsReceiver(){
IntentFilter restrictionsFilter =
new IntentFilter(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED);
mRestrictionsReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
getAppRestrictions();
}
};
registerReceiver(mRestrictionsReceiver, restrictionsFilter);
}
You will also need to add LOGIN_HINT as a static member variable for MainActivity...
private final static String LOGIN_HINT = "login_hint";
and define restrictions_pending_block_user in strings.xml
<string name="restrictions_pending_block_user">This application is
waiting for further configuration details and cannot continue
to run. Please contact your IT admin for further details</string>
You will also need to add the following member variables to MainActivity, under ImageView mProfileView;
// login hint;
String mLoginHint;
// broadcast receiver for app restrictions changed broadcast
private BroadcastReceiver mRestrictionsReceiver;
Now we will use the new methods we just created. Add the following to the bottom of onCreate()
// Retrieve app restrictions and take appropriate action
getAppRestrictions();
Add the following method to the bottom of onStart()
// Register a receiver for app restrictions changed broadcast
registerRestrictionsReceiver();
Finally we will ensure that we also check for app restrictions in MainActivity's onResume()
, and unregister the BroadcastReceiver in onStop()
. Add the following to MainActivity
@Override
protected void onResume(){
super.onResume();
// Retrieve app restrictions and take appropriate action
getAppRestrictions();
// Register a receiver for app restrictions changed broadcast
registerRestrictionsReceiver();
}
@Override
protected void onStop(){
super.onStop();
// Unregister receiver for app restrictions changed broadcast
unregisterReceiver(mRestrictionsReceiver);
}
Now that we are able to get the login_hint configuration from an admin, let's use it in our authorization requests
Now that you have the managed configuration, you can include the login_hint
in the Authorization Request. We will do so by including it as an additional parameter in our AuthorizationRequest. First, add a private final MainActivity
to our AuthorizeListener so that we can access mLoginHint inside of the static class
public static class AuthorizeListener implements Button.OnClickListener {
private final MainActivity mMainActivity;
public AuthorizeListener(@NonNull MainActivity mainActivity) {
mMainActivity = mainActivity;
}
Next we will update onCreate()
to use AuthorizeListener's new constructor. Replace the code just after //
wire click listeners
with the following:
mAuthorize.setOnClickListener(new AuthorizeListener(this));
Create a new method for MainActivity that will allow us to retrieve mLoginHint
public String getLoginHint(){
return mLoginHint;
}
We are now ready to take advantage of the login hint passed in by the admin. Add the following to the AuthorizeListener
's onClick()
, just above AuthorizationRequest request = builder.build():
if(mMainActivity.getLoginHint() != null){
Map loginHintMap = new HashMap<String, String>();
loginHintMap.put(LOGIN_HINT,mMainActivity.getLoginHint());
builder.setAdditionalParameters(loginHintMap);
Log.i(LOG_TAG, String.format("login_hint: %s", mMainActivity.getLoginHint()));
Log.i(LOG_TAG, String.format("login_hint: %s", mMainActivity.getLoginHint()));
}
Now, when you launch a new OAuth Authorization Request to Google in the sample app with the login_hint
set through managed configurationset through managed configuration, if multiple users are signed-in to Google, the hinted user will be automatically selected (and the account chooser step will be skipped), and if the hinted user is not signed-in already, they will be prompted to sign-in to that account.
You can setup a setup a test managed configuration to set login_hint
managed configuration to set login_hint by taking the following steps
adb install path/to/TestDPC.apk
adb shell dpm set-device-owner com.afwsamples.testdpc/.DeviceAdminReceiver
login_hint
set you would be presented with an account chooser. With login_hint
, the hinted account should be automatically selected.Users can now authenticate to your app using Chrome Custom Tabs, and use Managed Configuration to bootstrap the process for users with managed devices.