From a71a9bbca583996199d5bf9af3376c340339fa06 Mon Sep 17 00:00:00 2001 From: Alexandru Gologan Date: Fri, 27 Mar 2020 17:34:40 +0200 Subject: [PATCH 1/7] Add end session support Original-author: Sergiy Mokiyenko --- README.md | 72 +++ app/README-Okta.md | 4 +- app/README.md | 9 + .../net/openid/appauthdemo/Configuration.java | 14 + .../net/openid/appauthdemo/LoginActivity.java | 1 + .../net/openid/appauthdemo/TokenActivity.java | 36 +- app/res/raw/auth_config.json | 1 + .../AuthorizationManagementActivity.java | 90 ++-- .../AuthorizationManagementRequest.java | 65 +++ .../AuthorizationManagementResponse.java | 48 ++ .../appauth/AuthorizationManagementUtil.java | 88 ++++ .../openid/appauth/AuthorizationRequest.java | 39 +- .../openid/appauth/AuthorizationResponse.java | 24 +- .../openid/appauth/AuthorizationService.java | 172 ++++++- .../AuthorizationServiceConfiguration.java | 25 +- .../AuthorizationServiceDiscovery.java | 10 + .../net/openid/appauth/EndSessionRequest.java | 238 +++++++++ .../openid/appauth/EndSessionResponse.java | 200 ++++++++ .../openid/appauth/RegistrationRequest.java | 5 +- .../java/net/openid/appauth/TokenRequest.java | 5 +- .../AuthorizationManagementActivityTest.java | 450 ++++++++++++++++-- ...AuthorizationServiceConfigurationTest.java | 18 +- .../AuthorizationServiceDiscoveryTest.java | 2 + .../appauth/AuthorizationServiceTest.java | 32 ++ .../openid/appauth/EndSessionRequestTest.java | 105 ++++ .../appauth/EndSessionResponseTest.java | 139 ++++++ .../net/openid/appauth/IdTokenTest.java | 2 + .../net/openid/appauth/TestValues.java | 20 +- 28 files changed, 1776 insertions(+), 138 deletions(-) create mode 100644 library/java/net/openid/appauth/AuthorizationManagementRequest.java create mode 100644 library/java/net/openid/appauth/AuthorizationManagementResponse.java create mode 100644 library/java/net/openid/appauth/AuthorizationManagementUtil.java create mode 100644 library/java/net/openid/appauth/EndSessionRequest.java create mode 100644 library/java/net/openid/appauth/EndSessionResponse.java create mode 100644 library/javatests/net/openid/appauth/EndSessionRequestTest.java create mode 100644 library/javatests/net/openid/appauth/EndSessionResponseTest.java diff --git a/README.md b/README.md index c6e53970..a6a4999c 100644 --- a/README.md +++ b/README.md @@ -420,6 +420,78 @@ authState.performActionWithFreshTokens(service, new AuthStateAction() { }); ``` +### Ending current session (Draft) + +Given you have a logged in session and you want to end it. In that case you need to get: +- `AuthorizationServiceConfiguration` +- valid Open Id Token that you should get after authentication +- End of session URI that should be provided within you OpenId service config + +First you have to build EndSessionRequest + +```java +EndSessionRequest endSessionRequest = + new EndSessionRequest.Builder( + authorizationServiceConfiguration, + idToken, + endSessionRedirectUri + ).build(); +``` +This request can then be dispatched using one of two approaches. + +a `startActivityForResult` call using an Intent returned from the `AuthorizationService`, +or by calling `performEndSessionRequest` and providing pending intent for completion +and cancelation handling activities. + +The startActivityForResult approach is simpler to use but may require more processing of the result: + +```java +private void endSession() { + AuthorizationService authService = new AuthorizationService(this); + Intent endSessionItent = authService.getEndSessionRequestIntent(endSessionRequest); + startActivityForResult(endSessionItent, RC_END_SESSION); +} + +@Override +protected void onActivityResult(int requestCode, int resultCode, Intent data) { + if (requestCode == RC_END_SESSION) { + EndSessionResonse resp = EndSessionResonse.fromIntent(data); + AuthorizationException ex = AuthorizationException.fromIntent(data); + // ... process the response or exception ... + } else { + // ... + } +} +``` +If instead you wish to directly transition to another activity on completion or cancelation, +you can use `performEndSessionRequest`: + +```java +AuthorizationService authService = new AuthorizationService(this); + +authService.performEndSessionRequest( + endSessionRequest, + PendingIntent.getActivity(this, 0, new Intent(this, MyAuthCompleteActivity.class), 0), + PendingIntent.getActivity(this, 0, new Intent(this, MyAuthCanceledActivity.class), 0)); +``` + +End session flow will also work involving browser mechanism that is described in authorization +mechanism session. +Handling response mechanism with transition to another activity should be as follows: + + ```java +public void onCreate(Bundle b) { + EndSessionResponse resp = EndSessionResponse.fromIntent(getIntent()); + AuthorizationException ex = AuthorizationException.fromIntent(getIntent()); + if (resp != null) { + // authorization completed + } else { + // authorization failed, check ex for more details + } + // ... +} +``` + ### AuthState persistence Instances of `AuthState` keep track of the authorization and token diff --git a/app/README-Okta.md b/app/README-Okta.md index acfa2de2..d9777e6c 100644 --- a/app/README-Okta.md +++ b/app/README-Okta.md @@ -13,7 +13,8 @@ You can create an Okta developer account at [https://developer.okta.com/](https: | Setting | Value | | ------------------- | --------------------------------------------------- | | Application Name | OpenId Connect App *(must be unique)* | -| Redirect URIs | com.oktapreview.yoursubdomain://callback_url| +| Login redirect URIs | com.oktapreview.yoursubdomain://callback_url| +| Logout redirect URIs| com.oktapreview.yoursubdomain://callback_url| | Allowed grant types | Authorization Code | 1. Click **Finish** to redirect back to the *General Settings* of your application. @@ -27,6 +28,7 @@ You can create an Okta developer account at [https://developer.okta.com/](https: { "client_id": "{{YourClientID}}", "redirect_uri": "com.oktapreview.{{yourOrg}}:/oauth", +"end_session_uri": "com.oktapreview.{{yourOrg}}:/{logoutCallback}", "authorization_scope": "openid email profile", "discovery_uri": "https://{{yourOrg}}.okta.com/.well-known/openid-configuration" } diff --git a/app/README.md b/app/README.md index 48bc27ec..4b22c241 100644 --- a/app/README.md +++ b/app/README.md @@ -16,6 +16,15 @@ The configuration file MUST contain a JSON object. The following properties can The value specified here should match the value specified for `appAuthRedirectScheme` in the `build.gradle` (Module: app), so that the demo app can capture the response. + - `end_sesion_uri` (required): The redirect URI to use for receiving the end session response. + This should be a custom scheme URI (com.example.app:/oauth2redirect/example-provider). + Consult the documentation for your authorization server. + + The value specified here should match the value specified for `appAuthRedirectScheme` in the + `build.gradle` (Module: app), so that the demo app can capture the response. + + NOTE: Scheme of the URI should be the same as `redirect_uri` but callback should be different. + - `authorization_scope` (required): The scope string to use for the authorization request. For the purposes of the demo, we recommend the value "openid profile email", though any value understood by your authorization server can be used. diff --git a/app/java/net/openid/appauthdemo/Configuration.java b/app/java/net/openid/appauthdemo/Configuration.java index 0be12e09..c2daba24 100644 --- a/app/java/net/openid/appauthdemo/Configuration.java +++ b/app/java/net/openid/appauthdemo/Configuration.java @@ -62,9 +62,11 @@ public final class Configuration { private String mClientId; private String mScope; private Uri mRedirectUri; + private Uri mEndSessionUri; private Uri mDiscoveryUri; private Uri mAuthEndpointUri; private Uri mTokenEndpointUri; + private Uri mEndSessionEndpoint; private Uri mRegistrationEndpointUri; private Uri mUserInfoEndpointUri; private boolean mHttpsRequired; @@ -142,6 +144,11 @@ public Uri getDiscoveryUri() { return mDiscoveryUri; } + @Nullable + public Uri getEndSessionUri() { + return mEndSessionUri; + } + @Nullable public Uri getAuthEndpointUri() { return mAuthEndpointUri; @@ -152,6 +159,11 @@ public Uri getTokenEndpointUri() { return mTokenEndpointUri; } + @Nullable + public Uri getEndSessionEndpoint() { + return mEndSessionEndpoint; + } + @Nullable public Uri getRegistrationEndpointUri() { return mRegistrationEndpointUri; @@ -196,6 +208,7 @@ private void readConfiguration() throws InvalidConfigurationException { mClientId = getConfigString("client_id"); mScope = getRequiredConfigString("authorization_scope"); mRedirectUri = getRequiredConfigUri("redirect_uri"); + mEndSessionUri = getRequiredConfigUri("end_session_uri"); if (!isRedirectUriRegistered()) { throw new InvalidConfigurationException( @@ -210,6 +223,7 @@ private void readConfiguration() throws InvalidConfigurationException { mTokenEndpointUri = getRequiredConfigWebUri("token_endpoint_uri"); mUserInfoEndpointUri = getRequiredConfigWebUri("user_info_endpoint_uri"); + mEndSessionEndpoint = getRequiredConfigUri("end_session_endpoint"); if (mClientId == null) { mRegistrationEndpointUri = getRequiredConfigWebUri("registration_endpoint_uri"); diff --git a/app/java/net/openid/appauthdemo/LoginActivity.java b/app/java/net/openid/appauthdemo/LoginActivity.java index 02e0371d..75884d72 100644 --- a/app/java/net/openid/appauthdemo/LoginActivity.java +++ b/app/java/net/openid/appauthdemo/LoginActivity.java @@ -215,6 +215,7 @@ private void initializeAppAuth() { AuthorizationServiceConfiguration config = new AuthorizationServiceConfiguration( mConfiguration.getAuthEndpointUri(), mConfiguration.getTokenEndpointUri(), + mConfiguration.getEndSessionEndpoint(), mConfiguration.getRegistrationEndpointUri()); mAuthStateManager.replace(new AuthState(config)); diff --git a/app/java/net/openid/appauthdemo/TokenActivity.java b/app/java/net/openid/appauthdemo/TokenActivity.java index 74f27b79..4856740e 100644 --- a/app/java/net/openid/appauthdemo/TokenActivity.java +++ b/app/java/net/openid/appauthdemo/TokenActivity.java @@ -14,6 +14,7 @@ package net.openid.appauthdemo; +import android.app.Activity; import android.content.Intent; import android.net.Uri; import android.os.Bundle; @@ -36,6 +37,7 @@ import net.openid.appauth.AuthorizationService; import net.openid.appauth.AuthorizationServiceDiscovery; import net.openid.appauth.ClientAuthentication; +import net.openid.appauth.EndSessionRequest; import net.openid.appauth.TokenRequest; import net.openid.appauth.TokenResponse; @@ -66,6 +68,8 @@ public class TokenActivity extends AppCompatActivity { private static final String KEY_USER_INFO = "userInfo"; + private static final int END_SESSION_REQUEST_CODE = 911; + private AuthorizationService mAuthService; private AuthStateManager mStateManager; private final AtomicReference mUserInfoJson = new AtomicReference<>(); @@ -187,7 +191,7 @@ private void displayAuthorized() { AuthState state = mStateManager.getCurrent(); - TextView refreshTokenInfoView = (TextView) findViewById(R.id.refresh_token_info); + TextView refreshTokenInfoView = findViewById(R.id.refresh_token_info); refreshTokenInfoView.setText((state.getRefreshToken() == null) ? R.string.no_refresh_token_returned : R.string.refresh_token_returned); @@ -231,7 +235,7 @@ private void displayAuthorized() { viewProfileButton.setOnClickListener((View view) -> fetchUserInfo()); } - ((Button)findViewById(R.id.sign_out)).setOnClickListener((View view) -> signOut()); + findViewById(R.id.sign_out).setOnClickListener((View view) -> endSession()); View userInfoCard = findViewById(R.id.userinfo_card); JSONObject userInfo = mUserInfoJson.get(); @@ -381,6 +385,24 @@ private void fetchUserInfo(String accessToken, String idToken, AuthorizationExce }); } + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == END_SESSION_REQUEST_CODE && resultCode == Activity.RESULT_OK) { + signOut(); + finish(); + } else { + displayEndSessionCancelled(); + } + } + + private void displayEndSessionCancelled() { + Snackbar.make(findViewById(R.id.coordinator), + "Sign out canceled", + Snackbar.LENGTH_SHORT) + .show(); + } + @MainThread private void showSnackbar(String message) { Snackbar.make(findViewById(R.id.coordinator), @@ -389,6 +411,16 @@ private void showSnackbar(String message) { .show(); } + @MainThread + private void endSession() { + Intent endSessionEnten = mAuthService.getEndSessionRequestIntent( + new EndSessionRequest.Builder( + mStateManager.getCurrent().getAuthorizationServiceConfiguration(), + mStateManager.getCurrent().getIdToken(), + mConfiguration.getEndSessionUri()).build()); + startActivityForResult(endSessionEnten, END_SESSION_REQUEST_CODE); + } + @MainThread private void signOut() { // discard the authorization and token state, but retain the configuration and diff --git a/app/res/raw/auth_config.json b/app/res/raw/auth_config.json index 6f39e249..1796efab 100644 --- a/app/res/raw/auth_config.json +++ b/app/res/raw/auth_config.json @@ -1,6 +1,7 @@ { "client_id": "", "redirect_uri": "net.openid.appauthdemo:/oauth2redirect", + "end_session_uri":"", "authorization_scope": "openid email profile", "discovery_uri": "", "authorization_endpoint_uri": "", diff --git a/library/java/net/openid/appauth/AuthorizationManagementActivity.java b/library/java/net/openid/appauth/AuthorizationManagementActivity.java index 3eddac43..1acbad1c 100644 --- a/library/java/net/openid/appauth/AuthorizationManagementActivity.java +++ b/library/java/net/openid/appauth/AuthorizationManagementActivity.java @@ -29,10 +29,10 @@ import org.json.JSONException; /** - * Stores state and handles events related to the authorization flow. The activity is - * started by {@link AuthorizationService#performAuthorizationRequest - * AuthorizationService.performAuthorizationRequest}, and records all state pertinent to - * the authorization request before invoking the authorization intent. It also functions + * Stores state and handles events related to the authorization management flow. The activity is + * started by {@link AuthorizationService#performAuthorizationRequest} or + * {@link AuthorizationService#performEndSessionRequest}, and records all state pertinent to + * the authorization management request before invoking the authorization intent. It also functions * to control the back stack, ensuring that the authorization activity will not be reachable * via the back button after the flow completes. * @@ -71,7 +71,8 @@ * ``` * * The process begins with an activity requesting that an authorization flow be started, - * using {@link AuthorizationService#performAuthorizationRequest}. + * using {@link AuthorizationService#performAuthorizationRequest} or + * {@link AuthorizationService#performEndSessionRequest}. * * - Step 1: Using an intent derived from {@link #createStartIntent}, this activity is * started. The state delivered in this intent is recorded for future use. @@ -85,12 +86,14 @@ * activity to finish, the AuthorizationManagementActivity will be recreated or restarted. * * - Step C2a: If a cancellation PendingIntent was provided in the call to - * {@link AuthorizationService#performAuthorizationRequest}, then this is + * {@link AuthorizationService#performAuthorizationRequest} or + * {@link AuthorizationService#performEndSessionRequest}, then this is * used to invoke a cancelation activity. * * - Step C2b: If no cancellation PendingIntent was provided (legacy behavior, or * AuthorizationManagementActivity was started with an intent from - * {@link AuthorizationService#getAuthorizationRequestIntent}), then the + * {@link AuthorizationService#getAuthorizationRequestIntent} or + * @link AuthorizationService#performEndOfSessionRequest}), then the * AuthorizationManagementActivity simply finishes after calling {@link Activity#setResult}, * with {@link Activity#RESULT_CANCELED}, returning control to the activity above * it in the back stack (typically, the initiating activity). @@ -107,9 +110,10 @@ * top of the back stack. * * - Step S3a: If this activity was invoked via - * {@link AuthorizationService#performAuthorizationRequest}, then the pending intent provided + * {@link AuthorizationService#performAuthorizationRequest} or + * {@link AuthorizationService#performEndSessionRequest}, then the pending intent provided * for completion of the authorization flow is invoked, providing the decoded - * {@link AuthorizationResponse} or {@link AuthorizationException} as appropriate. + * {@link AuthorizationManagementResponse} or {@link AuthorizationException} as appropriate. * The AuthorizationManagementActivity finishes, removing itself from the back stack. * * - Step S3b: If this activity was invoked via an intent returned by @@ -138,7 +142,7 @@ public class AuthorizationManagementActivity extends Activity { private boolean mAuthorizationStarted = false; private Intent mAuthIntent; - private AuthorizationRequest mAuthRequest; + private AuthorizationManagementRequest mAuthRequest; private PendingIntent mCompleteIntent; private PendingIntent mCancelIntent; @@ -152,7 +156,7 @@ public class AuthorizationManagementActivity extends Activity { */ public static Intent createStartIntent( Context context, - AuthorizationRequest request, + AuthorizationManagementRequest request, Intent authIntent, PendingIntent completeIntent, PendingIntent cancelIntent) { @@ -167,12 +171,12 @@ public static Intent createStartIntent( /** * Creates an intent to start an authorization flow. * @param context the package context for the app. - * @param request the authorization request which is to be sent. + * @param request the authorization management request which is to be sent. * @param authIntent the intent to be used to get authorization from the user. */ public static Intent createStartForResultIntent( Context context, - AuthorizationRequest request, + AuthorizationManagementRequest request, Intent authIntent) { return createStartIntent(context, request, authIntent, null, null); } @@ -263,16 +267,7 @@ private void handleAuthorizationComplete() { } responseData.setData(responseUri); - if (mCompleteIntent != null) { - Logger.debug("Authorization complete - invoking completion intent"); - try { - mCompleteIntent.send(this, 0, responseData); - } catch (CanceledException ex) { - Logger.error("Failed to send completion intent", ex); - } - } else { - setResult(RESULT_OK, responseData); - } + sendResult(mCompleteIntent, responseData, RESULT_OK); } private void handleAuthorizationCanceled() { @@ -281,16 +276,8 @@ private void handleAuthorizationCanceled() { AuthorizationException.GeneralErrors.USER_CANCELED_AUTH_FLOW, null) .toIntent(); - if (mCancelIntent != null) { - try { - mCancelIntent.send(this, 0, cancelData); - } catch (CanceledException ex) { - Logger.error("Failed to send cancel intent", ex); - } - } else { - setResult(RESULT_CANCELED, cancelData); - Logger.debug("No cancel intent set - will return to previous activity"); - } + + sendResult(mCancelIntent, cancelData, RESULT_CANCELED); } private void extractState(Bundle state) { @@ -302,33 +289,48 @@ private void extractState(Bundle state) { mAuthIntent = state.getParcelable(KEY_AUTH_INTENT); mAuthorizationStarted = state.getBoolean(KEY_AUTHORIZATION_STARTED, false); + mCompleteIntent = state.getParcelable(KEY_COMPLETE_INTENT); + mCancelIntent = state.getParcelable(KEY_CANCEL_INTENT); try { String authRequestJson = state.getString(KEY_AUTH_REQUEST, null); mAuthRequest = authRequestJson != null - ? AuthorizationRequest.jsonDeserialize(authRequestJson) + ? AuthorizationManagementUtil.requestFrom(authRequestJson) : null; } catch (JSONException ex) { - throw new IllegalStateException("Unable to deserialize authorization request", ex); + sendResult( + mCancelIntent, + AuthorizationRequestErrors.INVALID_REQUEST.toIntent(), + RESULT_CANCELED); + } + } + + private void sendResult(PendingIntent callback, Intent cancelData, int resultCode) { + if (callback != null) { + try { + callback.send(this, 0, cancelData); + } catch (CanceledException e) { + Logger.error("Failed to send cancel intent", e); + } + } else { + setResult(resultCode, cancelData); } - mCompleteIntent = state.getParcelable(KEY_COMPLETE_INTENT); - mCancelIntent = state.getParcelable(KEY_CANCEL_INTENT); } private Intent extractResponseData(Uri responseUri) { if (responseUri.getQueryParameterNames().contains(AuthorizationException.PARAM_ERROR)) { return AuthorizationException.fromOAuthRedirect(responseUri).toIntent(); } else { - AuthorizationResponse response = new AuthorizationResponse.Builder(mAuthRequest) - .fromUri(responseUri) - .build(); + AuthorizationManagementResponse response = + AuthorizationManagementUtil.responseWith(mAuthRequest, responseUri); - if (mAuthRequest.state == null && response.state != null - || (mAuthRequest.state != null && !mAuthRequest.state.equals(response.state))) { + if (mAuthRequest.getState() == null && response.getState() != null + || (mAuthRequest.getState() != null && !mAuthRequest.getState() + .equals(response.getState()))) { Logger.warn("State returned in authorization response (%s) does not match state " + "from request (%s) - discarding response", - response.state, - mAuthRequest.state); + response.getState(), + mAuthRequest.getState()); return AuthorizationRequestErrors.STATE_MISMATCH.toIntent(); } diff --git a/library/java/net/openid/appauth/AuthorizationManagementRequest.java b/library/java/net/openid/appauth/AuthorizationManagementRequest.java new file mode 100644 index 00000000..a292ae0a --- /dev/null +++ b/library/java/net/openid/appauth/AuthorizationManagementRequest.java @@ -0,0 +1,65 @@ +/* + * Copyright 2016 The AppAuth for Android Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.openid.appauth; + +import android.net.Uri; +import android.util.Base64; + +import org.json.JSONObject; + +import java.security.SecureRandom; + +/** + * A base request for session management models + * {@link AuthorizationRequest} + * {@link EndSessionRequest} + */ +abstract class AuthorizationManagementRequest { + + private static final int STATE_LENGTH = 16; + + static String generateRandomState() { + SecureRandom sr = new SecureRandom(); + byte[] random = new byte[STATE_LENGTH]; + sr.nextBytes(random); + return Base64.encodeToString(random, Base64.NO_WRAP | Base64.NO_PADDING | Base64.URL_SAFE); + } + + /** + * Produces a JSON representation of the request for persistent storage or local transmission + * (e.g. between activities). + */ + public abstract JSONObject jsonSerialize(); + + /** + * Produces a JSON string representation of the request for persistent storage or + * local transmission (e.g. between activities). This method is just a convenience wrapper + * for {@link #jsonSerialize()}, converting the JSON object to its string form. + */ + public String jsonSerializeString() { + return jsonSerialize().toString(); + } + + /** + * An opaque value used by the client to maintain state between the request and callback. + */ + public abstract String getState(); + + /** + * Produces a request URI, that can be used to dispatch the request. + */ + public abstract Uri toUri(); + +} diff --git a/library/java/net/openid/appauth/AuthorizationManagementResponse.java b/library/java/net/openid/appauth/AuthorizationManagementResponse.java new file mode 100644 index 00000000..750a7d88 --- /dev/null +++ b/library/java/net/openid/appauth/AuthorizationManagementResponse.java @@ -0,0 +1,48 @@ +/* + * Copyright 2016 The AppAuth for Android Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.openid.appauth; + +import android.content.Intent; +import android.support.annotation.NonNull; + +import org.json.JSONObject; + +/** + * A base response for session management models + * {@link AuthorizationResponse} + * {@link EndSessionResponse} + */ +public abstract class AuthorizationManagementResponse { + + public abstract String getState(); + + public abstract Intent toIntent(); + + /** + * Produces a JSON representation of the request for persistent storage or local transmission + * (e.g. between activities). + */ + public abstract JSONObject jsonSerialize(); + + /** + * Produces a JSON representation of the end session response for persistent storage or local + * transmission (e.g. between activities). This method is just a convenience wrapper + * for {@link #jsonSerialize()}, converting the JSON object to its string form. + */ + @NonNull + public String jsonSerializeString() { + return jsonSerialize().toString(); + } +} diff --git a/library/java/net/openid/appauth/AuthorizationManagementUtil.java b/library/java/net/openid/appauth/AuthorizationManagementUtil.java new file mode 100644 index 00000000..bf7c78ad --- /dev/null +++ b/library/java/net/openid/appauth/AuthorizationManagementUtil.java @@ -0,0 +1,88 @@ +/* + * Copyright 2015 The AppAuth for Android Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.openid.appauth; + +import static net.openid.appauth.Preconditions.checkNotNull; + +import android.content.Intent; +import android.net.Uri; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; + +import org.json.JSONException; +import org.json.JSONObject; + +class AuthorizationManagementUtil { + /** + * Reads an authorization request from a JSON string representation produced by either + * {@link AuthorizationRequest#jsonSerialize()} or {@link EndSessionRequest#jsonSerialize()}. + * @throws JSONException if the provided JSON does not match the expected structure. + */ + static AuthorizationManagementRequest requestFrom(String jsonStr) + throws JSONException { + checkNotNull(jsonStr, "jsonStr can not be null"); + + JSONObject json = new JSONObject(jsonStr); + if (AuthorizationRequest.isAuthorizationRequest(json)) { + return AuthorizationRequest.jsonDeserialize(json); + } + + if (EndSessionRequest.isEndSessionRequest(json)) { + return EndSessionRequest.jsonDeserialize(json); + } + + throw new IllegalArgumentException( + "No AuthorizationManagementRequest found matching to this json schema"); + } + + /** + * Builds an AuthorizationManagementResponse from + * {@link AuthorizationManagementRequest} and {@link Uri} + */ + static AuthorizationManagementResponse responseWith( + AuthorizationManagementRequest request, Uri uri) { + if (request instanceof AuthorizationRequest) { + return new AuthorizationResponse.Builder((AuthorizationRequest) request) + .fromUri(uri) + .build(); + } + if (request instanceof EndSessionRequest) { + return new EndSessionResponse.Builder((EndSessionRequest) request) + .fromUri(uri) + .build(); + } + throw new IllegalArgumentException("Malformed request or uri"); + } + + /** + * Extracts response from an intent produced by {@link #toIntent()}. This is + * used to extract the response from the intent data passed to an activity registered as the + * handler for {@link AuthorizationService#performEndSessionRequest} + * or {@link AuthorizationService#performAuthorizationRequest}. + */ + @Nullable + static AuthorizationManagementResponse responseFrom(@NonNull Intent dataIntent) { + + if (EndSessionResponse.containsEndSessionResponse(dataIntent)) { + return EndSessionResponse.fromIntent(dataIntent); + } + + if (AuthorizationResponse.containsAuthorizationResponse(dataIntent)) { + return AuthorizationResponse.fromIntent(dataIntent); + } + + throw new IllegalArgumentException("Malformed intent"); + } +} diff --git a/library/java/net/openid/appauth/AuthorizationRequest.java b/library/java/net/openid/appauth/AuthorizationRequest.java index 0f133eb8..286e5eb2 100644 --- a/library/java/net/openid/appauth/AuthorizationRequest.java +++ b/library/java/net/openid/appauth/AuthorizationRequest.java @@ -26,14 +26,12 @@ import android.support.annotation.Nullable; import android.support.annotation.VisibleForTesting; import android.text.TextUtils; -import android.util.Base64; import net.openid.appauth.internal.UriUtil; import org.json.JSONException; import org.json.JSONObject; -import java.security.SecureRandom; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; @@ -49,7 +47,7 @@ * @see "The OAuth 2.0 Authorization Framework (RFC 6749), Section 4.1.1 * " */ -public class AuthorizationRequest { +public class AuthorizationRequest extends AuthorizationManagementRequest { /** * SHA-256 based code verifier challenge method. @@ -333,13 +331,13 @@ public static final class ResponseMode { private static final String KEY_CODE_VERIFIER_CHALLENGE_METHOD = "codeVerifierChallengeMethod"; private static final String KEY_RESPONSE_MODE = "responseMode"; private static final String KEY_ADDITIONAL_PARAMETERS = "additionalParameters"; - private static final int STATE_LENGTH = 16; /** * The service's {@link AuthorizationServiceConfiguration configuration}. * This configuration specifies how to connect to a particular OAuth provider. * Configurations may be - * {@link AuthorizationServiceConfiguration#AuthorizationServiceConfiguration(Uri, Uri, Uri)} + * {@link + * AuthorizationServiceConfiguration#AuthorizationServiceConfiguration(Uri, Uri, Uri, Uri)} * created manually}, or {@link AuthorizationServiceConfiguration#fetchFromUrl(Uri, * AuthorizationServiceConfiguration.RetrieveConfigurationCallback)} via an OpenID Connect * Discovery Document}. @@ -589,8 +587,8 @@ public Builder( setClientId(clientId); setResponseType(responseType); setRedirectUri(redirectUri); - setState(AuthorizationRequest.generateRandomState()); - setNonce(AuthorizationRequest.generateRandomState()); + setState(AuthorizationManagementRequest.generateRandomState()); + setNonce(AuthorizationManagementRequest.generateRandomState()); setCodeVerifier(CodeVerifierUtil.generateRandomCodeVerifier()); } @@ -978,9 +976,16 @@ public Set getPromptValues() { return AsciiStringListUtil.stringToSet(prompt); } + @Override + @Nullable + public String getState() { + return state; + } + /** - * Produces a request URI, that can be used to dispath the authorization request. + * Produces a request URI, that can be used to dispatch the authorization request. */ + @Override @NonNull public Uri toUri() { Uri.Builder uriBuilder = configuration.authorizationEndpoint.buildUpon() @@ -1012,6 +1017,7 @@ public Uri toUri() { * Produces a JSON representation of the authorization request for persistent storage or local * transmission (e.g. between activities). */ + @Override @NonNull public JSONObject jsonSerialize() { JSONObject json = new JSONObject(); @@ -1035,15 +1041,6 @@ public JSONObject jsonSerialize() { return json; } - /** - * Produces a JSON string representation of the authorization request for persistent storage or - * local transmission (e.g. between activities). This method is just a convenience wrapper - * for {@link #jsonSerialize()}, converting the JSON object to its string form. - */ - public String jsonSerializeString() { - return jsonSerialize().toString(); - } - /** * Reads an authorization request from a JSON string representation produced by * {@link #jsonSerialize()}. @@ -1089,10 +1086,8 @@ public static AuthorizationRequest jsonDeserialize(@NonNull String jsonStr) return jsonDeserialize(new JSONObject(jsonStr)); } - private static String generateRandomState() { - SecureRandom sr = new SecureRandom(); - byte[] random = new byte[STATE_LENGTH]; - sr.nextBytes(random); - return Base64.encodeToString(random, Base64.NO_WRAP | Base64.NO_PADDING | Base64.URL_SAFE); + static boolean isAuthorizationRequest(JSONObject json) { + return json.has(KEY_REDIRECT_URI); } + } diff --git a/library/java/net/openid/appauth/AuthorizationResponse.java b/library/java/net/openid/appauth/AuthorizationResponse.java index 84aa6bd0..7925d825 100644 --- a/library/java/net/openid/appauth/AuthorizationResponse.java +++ b/library/java/net/openid/appauth/AuthorizationResponse.java @@ -46,7 +46,7 @@ * @see "The OAuth 2.0 Authorization Framework (RFC 6749), Section 4.1.2 * " */ -public class AuthorizationResponse { +public class AuthorizationResponse extends AuthorizationManagementResponse { /** * The extra string used to store an {@link AuthorizationResponse} in an intent by @@ -470,10 +470,17 @@ public TokenRequest createTokenExchangeRequest( .build(); } + @Override + @Nullable + public String getState() { + return state; + } + /** * Produces a JSON representation of the authorization response for persistent storage or local * transmission (e.g. between activities). */ + @Override @NonNull public JSONObject jsonSerialize() { JSONObject json = new JSONObject(); @@ -490,16 +497,6 @@ public JSONObject jsonSerialize() { return json; } - /** - * Produces a JSON representation of the authorization response for persistent storage or local - * transmission (e.g. between activities). This method is just a convenience wrapper - * for {@link #jsonSerialize()}, converting the JSON object to its string form. - */ - @NonNull - public String jsonSerializeString() { - return jsonSerialize().toString(); - } - /** * Reads an authorization response from a JSON string representation produced by * {@link #jsonSerialize()}. @@ -548,6 +545,7 @@ public static AuthorizationResponse jsonDeserialize(@NonNull String jsonStr) * authorization response to the registered handler after a call to * {@link AuthorizationService#performAuthorizationRequest}. */ + @Override @NonNull public Intent toIntent() { Intent data = new Intent(); @@ -573,4 +571,8 @@ public static AuthorizationResponse fromIntent(@NonNull Intent dataIntent) { throw new IllegalArgumentException("Intent contains malformed auth response", ex); } } + + static boolean containsAuthorizationResponse(@NonNull Intent intent) { + return intent.hasExtra(EXTRA_RESPONSE); + } } diff --git a/library/java/net/openid/appauth/AuthorizationService.java b/library/java/net/openid/appauth/AuthorizationService.java index 3fd5c054..3fcafcce 100644 --- a/library/java/net/openid/appauth/AuthorizationService.java +++ b/library/java/net/openid/appauth/AuthorizationService.java @@ -225,6 +225,112 @@ public void performAuthorizationRequest( @NonNull PendingIntent completedIntent, @Nullable PendingIntent canceledIntent, @NonNull CustomTabsIntent customTabsIntent) { + performAuthManagementRequest( + request, + completedIntent, + canceledIntent, + customTabsIntent); + } + + /** + * Sends an end session request to the authorization service, using a + * [custom tab](https://developer.chrome.com/multidevice/android/customtabs) + * if available, or a browser instance. + * The parameters of this request are determined by both the authorization service + * configuration and the provided {@link EndSessionRequest request object}. Upon completion + * of this request, the provided {@link PendingIntent completion PendingIntent} will be invoked. + * If the user cancels the authorization request, the current activity will regain control. + */ + public void performEndSessionRequest( + @NonNull EndSessionRequest request, + @NonNull PendingIntent completedIntent) { + performEndSessionRequest( + request, + completedIntent, + null, + createCustomTabsIntentBuilder().build()); + } + + /** + * Sends an end session request to the authorization service, using a + * [custom tab](https://developer.chrome.com/multidevice/android/customtabs) + * if available, or a browser instance. + * The parameters of this request are determined by both the authorization service + * configuration and the provided {@link EndSessionRequest request object}. Upon completion + * of this request, the provided {@link PendingIntent completion PendingIntent} will be invoked. + * If the user cancels the authorization request, the provided + * {@link PendingIntent cancel PendingIntent} will be invoked. + */ + public void performEndSessionRequest( + @NonNull EndSessionRequest request, + @NonNull PendingIntent completedIntent, + @NonNull PendingIntent canceledIntent) { + performEndSessionRequest( + request, + completedIntent, + canceledIntent, + createCustomTabsIntentBuilder().build()); + } + + /** + * Sends an end session request to the authorization service, using a + * [custom tab](https://developer.chrome.com/multidevice/android/customtabs). + * The parameters of this request are determined by both the authorization service + * configuration and the provided {@link EndSessionRequest request object}. Upon completion + * of this request, the provided {@link PendingIntent completion PendingIntent} will be invoked. + * If the user cancels the authorization request, the current activity will regain control. + * + * @param customTabsIntent + * The intent that will be used to start the custom tab. It is recommended that this intent + * be created with the help of {@link #createCustomTabsIntentBuilder(Uri[])}, which will + * ensure that a warmed-up version of the browser will be used, minimizing latency. + */ + public void performEndSessionRequest( + @NonNull EndSessionRequest request, + @NonNull PendingIntent completedIntent, + @NonNull CustomTabsIntent customTabsIntent) { + performEndSessionRequest( + request, + completedIntent, + null, + customTabsIntent); + } + + /** + * Sends an end session request to the authorization service, using a + * [custom tab](https://developer.chrome.com/multidevice/android/customtabs). + * The parameters of this request are determined by both the authorization service + * configuration and the provided {@link EndSessionRequest request object}. Upon completion + * of this request, the provided {@link PendingIntent completion PendingIntent} will be invoked. + * If the user cancels the authorization request, the provided + * {@link PendingIntent cancel PendingIntent} will be invoked. + * + * @param customTabsIntent + * The intent that will be used to start the custom tab. It is recommended that this intent + * be created with the help of {@link #createCustomTabsIntentBuilder(Uri[])}, which will + * ensure that a warmed-up version of the browser will be used, minimizing latency. + * + * @throws android.content.ActivityNotFoundException if no suitable browser is available to + * perform the authorization flow. + */ + public void performEndSessionRequest( + @NonNull EndSessionRequest request, + @NonNull PendingIntent completedIntent, + @Nullable PendingIntent canceledIntent, + @NonNull CustomTabsIntent customTabsIntent) { + performAuthManagementRequest( + request, + completedIntent, + canceledIntent, + customTabsIntent); + } + + private void performAuthManagementRequest( + @NonNull AuthorizationManagementRequest request, + @NonNull PendingIntent completedIntent, + @Nullable PendingIntent canceledIntent, + @NonNull CustomTabsIntent customTabsIntent) { + checkNotDisposed(); checkNotNull(request); checkNotNull(completedIntent); @@ -237,6 +343,7 @@ public void performAuthorizationRequest( authIntent, completedIntent, canceledIntent)); + } /** @@ -297,6 +404,64 @@ public Intent getAuthorizationRequestIntent( return getAuthorizationRequestIntent(request, createCustomTabsIntentBuilder().build()); } + /** + * Constructs an intent that encapsulates the provided request and custom tabs intent, + * and is intended to be launched via {@link Activity#startActivityForResult}. + * The parameters of this request are determined by both the authorization service + * configuration and the provided {@link AuthorizationRequest request object}. Upon completion + * of this request, the activity that gets launched will call {@link Activity#setResult} with + * {@link Activity#RESULT_OK} and an {@link Intent} containing authorization completion + * information. If the user presses the back button or closes the browser tab, the launched + * activity will call {@link Activity#setResult} with + * {@link Activity#RESULT_CANCELED} without a data {@link Intent}. Note that + * {@link Activity#RESULT_OK} indicates the authorization request completed, + * not necessarily that it was a successful authorization. + * + * @param customTabsIntent + * The intent that will be used to start the custom tab. It is recommended that this intent + * be created with the help of {@link #createCustomTabsIntentBuilder(Uri[])}, which will + * ensure that a warmed-up version of the browser will be used, minimizing latency. + * + * @throws android.content.ActivityNotFoundException if no suitable browser is available to + * perform the authorization flow. + */ + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + public Intent getEndSessionRequestIntent( + @NonNull EndSessionRequest request, + @NonNull CustomTabsIntent customTabsIntent) { + + Intent authIntent = prepareAuthorizationRequestIntent(request, customTabsIntent); + return AuthorizationManagementActivity.createStartForResultIntent( + mContext, + request, + authIntent); + } + + /** + * Constructs an intent that encapsulates the provided request and a default custom tabs intent, + * and is intended to be launched via {@link Activity#startActivityForResult} + * When started, the intent launches an {@link Activity} that sends an authorization request + * to the authorization service, using a + * [custom tab](https://developer.chrome.com/multidevice/android/customtabs). + * The parameters of this request are determined by both the authorization service + * configuration and the provided {@link EndSessionRequest request object}. Upon completion + * of this request, the activity that gets launched will call {@link Activity#setResult} with + * {@link Activity#RESULT_OK} and an {@link Intent} containing authorization completion + * information. If the user presses the back button or closes the browser tab, the launched + * activity will call {@link Activity#setResult} with + * {@link Activity#RESULT_CANCELED} without a data {@link Intent}. Note that + * {@link Activity#RESULT_OK} indicates the authorization request completed, + * not necessarily that it was a successful authorization. + * + * @throws android.content.ActivityNotFoundException if no suitable browser is available to + * perform the authorization flow. + */ + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + public Intent getEndSessionRequestIntent( + @NonNull EndSessionRequest request) { + return getEndSessionRequestIntent(request, createCustomTabsIntentBuilder().build()); + } + /** * Sends a request to the authorization service to exchange a code granted as part of an * authorization request for a token. The result of this request will be sent to the provided @@ -366,7 +531,7 @@ private void checkNotDisposed() { } private Intent prepareAuthorizationRequestIntent( - AuthorizationRequest request, + AuthorizationManagementRequest request, CustomTabsIntent customTabsIntent) { checkNotDisposed(); @@ -388,8 +553,9 @@ private Intent prepareAuthorizationRequestIntent( intent.getPackage(), mBrowser.useCustomTab.toString()); - Logger.debug("Initiating authorization request to %s", - request.configuration.authorizationEndpoint); + //TODO fix logger for configuration + //Logger.debug("Initiating authorization request to %s" + //request.configuration.authorizationEndpoint); return intent; } diff --git a/library/java/net/openid/appauth/AuthorizationServiceConfiguration.java b/library/java/net/openid/appauth/AuthorizationServiceConfiguration.java index 5d2b0e9a..f73a7a2c 100644 --- a/library/java/net/openid/appauth/AuthorizationServiceConfiguration.java +++ b/library/java/net/openid/appauth/AuthorizationServiceConfiguration.java @@ -63,6 +63,7 @@ public class AuthorizationServiceConfiguration { private static final String KEY_TOKEN_ENDPOINT = "tokenEndpoint"; private static final String KEY_REGISTRATION_ENDPOINT = "registrationEndpoint"; private static final String KEY_DISCOVERY_DOC = "discoveryDoc"; + private static final String KEY_END_SESSION_ENPOINT = "endSessionEndpoint"; /** * The authorization service's endpoint. @@ -76,6 +77,12 @@ public class AuthorizationServiceConfiguration { @NonNull public final Uri tokenEndpoint; + /** + * The end session service's endpoint; + */ + @Nullable + public final Uri endSessionEndpoint; + /** * The authorization service's client registration endpoint. */ @@ -101,28 +108,31 @@ public class AuthorizationServiceConfiguration { public AuthorizationServiceConfiguration( @NonNull Uri authorizationEndpoint, @NonNull Uri tokenEndpoint) { - this(authorizationEndpoint, tokenEndpoint, null); + this(authorizationEndpoint, tokenEndpoint, null, null); } /** * Creates a service configuration for a basic OAuth2 provider. - * - * @param authorizationEndpoint The + * @param authorizationEndpoint The * [authorization endpoint URI](https://tools.ietf.org/html/rfc6749#section-3.1) * for the service. * @param tokenEndpoint The * [token endpoint URI](https://tools.ietf.org/html/rfc6749#section-3.2) * for the service. + * @param endSessionEndpoint The + * [end session endpoint URI](https://tools.ietf.org/html/rfc6749#section-2.2) + * for the service. * @param registrationEndpoint The optional * [client registration endpoint URI](https://tools.ietf.org/html/rfc7591#section-3) - * for the service. */ public AuthorizationServiceConfiguration( @NonNull Uri authorizationEndpoint, @NonNull Uri tokenEndpoint, + @Nullable Uri endSessionEndpoint, @Nullable Uri registrationEndpoint) { this.authorizationEndpoint = checkNotNull(authorizationEndpoint); this.tokenEndpoint = checkNotNull(tokenEndpoint); + this.endSessionEndpoint = endSessionEndpoint; this.registrationEndpoint = registrationEndpoint; this.discoveryDoc = null; } @@ -140,6 +150,7 @@ public AuthorizationServiceConfiguration( this.authorizationEndpoint = discoveryDoc.getAuthorizationEndpoint(); this.tokenEndpoint = discoveryDoc.getTokenEndpoint(); this.registrationEndpoint = discoveryDoc.getRegistrationEndpoint(); + this.endSessionEndpoint = discoveryDoc.getEndSessionEndpoint(); } /** @@ -155,6 +166,8 @@ public JSONObject toJson() { } if (discoveryDoc != null) { JsonUtil.put(json, KEY_DISCOVERY_DOC, discoveryDoc.docJson); + } if (endSessionEndpoint != null) { + JsonUtil.put(json, KEY_END_SESSION_ENPOINT, endSessionEndpoint.toString()); } return json; } @@ -193,7 +206,9 @@ public static AuthorizationServiceConfiguration fromJson(@NonNull JSONObject jso return new AuthorizationServiceConfiguration( JsonUtil.getUri(json, KEY_AUTHORIZATION_ENDPOINT), JsonUtil.getUri(json, KEY_TOKEN_ENDPOINT), - JsonUtil.getUriIfDefined(json, KEY_REGISTRATION_ENDPOINT)); + JsonUtil.getUriIfDefined(json, KEY_END_SESSION_ENPOINT), + JsonUtil.getUriIfDefined(json, + KEY_REGISTRATION_ENDPOINT)); } } diff --git a/library/java/net/openid/appauth/AuthorizationServiceDiscovery.java b/library/java/net/openid/appauth/AuthorizationServiceDiscovery.java index f74e0927..3c799643 100644 --- a/library/java/net/openid/appauth/AuthorizationServiceDiscovery.java +++ b/library/java/net/openid/appauth/AuthorizationServiceDiscovery.java @@ -51,6 +51,9 @@ public class AuthorizationServiceDiscovery { @VisibleForTesting static final UriField TOKEN_ENDPOINT = uri("token_endpoint"); + @VisibleForTesting + static final UriField END_SESSION_ENPDINT = uri("end_session_endpoint"); + @VisibleForTesting static final UriField USERINFO_ENDPOINT = uri("userinfo_endpoint"); @@ -259,6 +262,13 @@ public Uri getTokenEndpoint() { return get(TOKEN_ENDPOINT); } + /** + * The OAuth 2 emd session endpoint URI. Not specified test OAuth implementation + */ + public Uri getEndSessionEndpoint() { + return get(END_SESSION_ENPDINT); + } + /** * The OpenID Connect UserInfo endpoint URI. */ diff --git a/library/java/net/openid/appauth/EndSessionRequest.java b/library/java/net/openid/appauth/EndSessionRequest.java new file mode 100644 index 00000000..69d73f15 --- /dev/null +++ b/library/java/net/openid/appauth/EndSessionRequest.java @@ -0,0 +1,238 @@ +/* + * Copyright 2016 The AppAuth for Android Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.openid.appauth; + +import static net.openid.appauth.Preconditions.checkNotEmpty; +import static net.openid.appauth.Preconditions.checkNotNull; + +import android.net.Uri; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; + +import org.json.JSONException; +import org.json.JSONObject; + +/** + * An OpenID end session request. + * + * NOTE: That is a draft implementation + * + * @see "OpenID Connect Session Management 1.0 - draft 28, 5 RP-Initiated Logout + * " + */ +public class EndSessionRequest extends AuthorizationManagementRequest { + + private static final String PARAM_ID_TOKEN_HINT = "id_token_hint"; + private static final String PARAM_REDIRECT_URI = "post_logout_redirect_uri"; + private static final String PARAM_STATE = "state"; + private static final String KEY_CONFIGURATION = "configuration"; + + @VisibleForTesting + static final String KEY_ID_TOKEN_HINT = "id_token_hint"; + @VisibleForTesting + static final String KEY_REDIRECT_URI = "post_logout_redirect_uri"; + @VisibleForTesting + static final String KEY_STATE = "state"; + + /** + * The service's {@link AuthorizationServiceConfiguration configuration}. + * This configuration specifies how to connect to a particular OAuth provider. + * Configurations may be + * {@link + * AuthorizationServiceConfiguration#AuthorizationServiceConfiguration(Uri, Uri, Uri, Uri)} + * created manually}, or {@link AuthorizationServiceConfiguration#fetchFromUrl(Uri, + * AuthorizationServiceConfiguration.RetrieveConfigurationCallback)} via an OpenID Connect + * Discovery Document}. + */ + @NonNull + public final AuthorizationServiceConfiguration configuration; + + /** + * An OpenID Connect ID Token. Contains claims about the authentication of an End-User by an + * Authorization Server. + * + * @see "OpenID Connect Session Management 1.0 - draft 28, 5 RP-Initiated Logout + * " + * @see "OpenID Connect Core ID Token, Section 2 + * " + */ + @NonNull + public final String idToken; + + /** + * The client's redirect URI. + * + * @see "OpenID Connect Session Management 1.0 - draft 28, 5.1. Redirection to RP After Logout + * " + */ + @NonNull + public final Uri redirectUri; + + /** + * An opaque value used by the client to maintain state between the request and callback. If + * this value is not explicitly set, this library will automatically add state and perform + * appropriate validation of the state in the authorization response. It is recommended that + * the default implementation of this parameter be used wherever possible. Typically used to + * prevent CSRF attacks, as recommended in + * + * @see "OpenID Connect Session Management 1.0 - draft 28, 5 RP-Initiated Logout + * " + * @see "The OAuth 2.0 Authorization Framework (RFC 6749), Section 5.3.5 + * " + */ + @NonNull + public final String state; + + /** + * Creates instances of {@link EndSessionRequest}. + */ + public static final class Builder { + + @NonNull + private AuthorizationServiceConfiguration mConfiguration; + + @NonNull + private String mIdToken; + + @NonNull + private Uri mRedirectUri; + + @NonNull + private String mState; + + public Builder( + @NonNull AuthorizationServiceConfiguration configuration, + @NonNull String idToken, + @NonNull Uri redirectUri) { + setAuthorizationServiceConfiguration(configuration); + setIdToken(idToken); + setRedirectUri(redirectUri); + setState(AuthorizationManagementRequest.generateRandomState()); + } + + /** + * Specifies the service configuration to be used in dispatching this request. + */ + public Builder setAuthorizationServiceConfiguration( + @NonNull AuthorizationServiceConfiguration configuration) { + mConfiguration = checkNotNull(configuration, "configuration cannot be null"); + return this; + } + + public Builder setIdToken(@NonNull String idToken) { + mIdToken = checkNotEmpty(idToken, "idToken cannot be null or empty"); + return this; + } + + public Builder setRedirectUri(@Nullable Uri redirectUri) { + mRedirectUri = checkNotNull(redirectUri, "redirect Uri cannot be null"); + return this; + } + + public Builder setState(@NonNull String state) { + mState = checkNotEmpty(state, "state cannot be null or empty"); + return this; + } + + /** + * Constructs an end session request. All fields must be set. + * Failure to specify any of these parameters will result in a runtime exception. + */ + @NonNull + public EndSessionRequest build() { + return new EndSessionRequest( + mConfiguration, + mIdToken, + mRedirectUri, + mState); + } + } + + @VisibleForTesting + private EndSessionRequest( + @NonNull AuthorizationServiceConfiguration configuration, + @NonNull String idToken, + @NonNull Uri redirectUri, + @NonNull String state) { + this.configuration = configuration; + this.idToken = idToken; + this.redirectUri = redirectUri; + this.state = state; + } + + @Override + @NonNull + public String getState() { + return state; + } + + @Override + public Uri toUri() { + Uri.Builder uriBuilder = configuration.endSessionEndpoint.buildUpon() + .appendQueryParameter(PARAM_REDIRECT_URI, redirectUri.toString()) + .appendQueryParameter(PARAM_ID_TOKEN_HINT, idToken) + .appendQueryParameter(PARAM_STATE, state); + return uriBuilder.build(); + } + + /** + * Produces a JSON representation of the end session request for persistent storage or local + * transmission (e.g. between activities). + */ + @Override + public JSONObject jsonSerialize() { + JSONObject json = new JSONObject(); + JsonUtil.put(json, KEY_CONFIGURATION, configuration.toJson()); + JsonUtil.put(json, KEY_ID_TOKEN_HINT, idToken); + JsonUtil.put(json, KEY_REDIRECT_URI, redirectUri.toString()); + JsonUtil.put(json, KEY_STATE, state); + return json; + } + + /** + * Reads an authorization request from a JSON string representation produced by + * {@link #jsonSerialize()}. + * @throws JSONException if the provided JSON does not match the expected structure. + */ + public static EndSessionRequest jsonDeserialize(@NonNull JSONObject jsonObject) + throws JSONException { + checkNotNull(jsonObject, "json cannot be null"); + return new EndSessionRequest( + AuthorizationServiceConfiguration.fromJson(jsonObject.getJSONObject(KEY_CONFIGURATION)), + JsonUtil.getString(jsonObject, KEY_ID_TOKEN_HINT), + JsonUtil.getUri(jsonObject, KEY_REDIRECT_URI), + JsonUtil.getString(jsonObject, KEY_STATE) + ); + } + + /** + * Reads an authorization request from a JSON string representation produced by + * {@link #jsonSerializeString()}. This method is just a convenience wrapper for + * {@link #jsonDeserialize(JSONObject)}, converting the JSON string to its JSON object form. + * @throws JSONException if the provided JSON does not match the expected structure. + */ + @NonNull + public static EndSessionRequest jsonDeserialize(@NonNull String jsonStr) + throws JSONException { + checkNotNull(jsonStr, "json string cannot be null"); + return jsonDeserialize(new JSONObject(jsonStr)); + } + + static boolean isEndSessionRequest(JSONObject json) { + return json.has(KEY_REDIRECT_URI); + } + +} diff --git a/library/java/net/openid/appauth/EndSessionResponse.java b/library/java/net/openid/appauth/EndSessionResponse.java new file mode 100644 index 00000000..7aff586f --- /dev/null +++ b/library/java/net/openid/appauth/EndSessionResponse.java @@ -0,0 +1,200 @@ +/* + * Copyright 2016 The AppAuth for Android Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + +package net.openid.appauth; + +import static net.openid.appauth.Preconditions.checkNotEmpty; +import static net.openid.appauth.Preconditions.checkNotNull; + +import android.content.Intent; +import android.net.Uri; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; + +import org.json.JSONException; +import org.json.JSONObject; + +/** + * A response to end session request. + * + * @see EndSessionRequest + * @see "OpenID Connect Session Management 1.0 - draft 28, 5 RP-Initiated Logout + * " + */ +public class EndSessionResponse extends AuthorizationManagementResponse { + + /** + * The extra string used to store an {@link EndSessionResponse} in an intent by + * {@link #toIntent()}. + */ + public static final String EXTRA_RESPONSE = "net.openid.appauth.EndSessionResponse"; + + @VisibleForTesting + static final String KEY_REQUEST = "request"; + + @VisibleForTesting + static final String KEY_STATE = "state"; + + /** + * The end session request associated with this response. + */ + @NonNull + public final EndSessionRequest request; + + /** + * The returned state parameter, which must match the value specified in the request. + * AppAuth for Android ensures that this is the case. + */ + @NonNull + public final String state; + + /** + * Creates instances of {@link EndSessionResponse}. + */ + public static final class Builder { + @NonNull + private EndSessionRequest mRequest; + + @NonNull + private String mState; + + + public Builder(@NonNull EndSessionRequest request) { + setRequest(request); + } + + Builder fromUri(@NonNull Uri uri) { + setState(uri.getQueryParameter(EndSessionRequest.KEY_STATE)); + return this; + } + + public Builder setRequest(@NonNull EndSessionRequest request) { + mRequest = checkNotNull(request, "request cannot be null"); + return this; + } + + public Builder setState(@NonNull String state) { + mState = checkNotEmpty(state, "state cannot be null or empty"); + return this; + } + + /** + * Builds the response object. + */ + @NonNull + public EndSessionResponse build() { + return new EndSessionResponse( + mRequest, + mState); + } + } + + private EndSessionResponse( + @NonNull EndSessionRequest request, + @NonNull String state) { + this.request = request; + this.state = state; + } + + @Override + @NonNull + public String getState() { + return state; + } + + /** + * Produces a JSON representation of the end session response for persistent storage or local + * transmission (e.g. between activities). + */ + @Override + @NonNull + public JSONObject jsonSerialize() { + JSONObject json = new JSONObject(); + JsonUtil.put(json, KEY_REQUEST, request.jsonSerialize()); + JsonUtil.putIfNotNull(json, KEY_STATE, state); + return json; + } + + /** + * Reads an end session response from a JSON string representation produced by + * {@link #jsonSerialize()}. + * + * @throws JSONException if the provided JSON does not match the expected structure. + */ + @NonNull + public static EndSessionResponse jsonDeserialize(@NonNull JSONObject json) + throws JSONException { + if (!json.has(KEY_REQUEST)) { + throw new IllegalArgumentException( + "authorization request not provided and not found in JSON"); + } + + EndSessionRequest request = + EndSessionRequest.jsonDeserialize(json.getJSONObject(KEY_REQUEST)); + + return new EndSessionResponse( + request, + JsonUtil.getString(json, KEY_STATE) + ); + } + + /** + * Reads an end session response from a JSON string representation produced by + * {@link #jsonSerializeString()}. This method is just a convenience wrapper for + * {@link #jsonDeserialize(JSONObject)}, converting the JSON string to its JSON object form. + * + * @throws JSONException if the provided JSON does not match the expected structure. + */ + @NonNull + public static EndSessionResponse jsonDeserialize(@NonNull String jsonStr) + throws JSONException { + return jsonDeserialize(new JSONObject(jsonStr)); + } + + /** + * Produces an intent containing this end session response. This is used to deliver the + * end session response to the registered handler after a call to + * {@link AuthorizationService#performEndSessionRequest}. + */ + @Override + public Intent toIntent() { + Intent data = new Intent(); + data.putExtra(EXTRA_RESPONSE, this.jsonSerializeString()); + return data; + } + + /** + * Extracts an end session response from an intent produced by {@link #toIntent()}. This is + * used to extract the response from the intent data passed to an activity registered as the + * handler for {@link AuthorizationService#performEndSessionRequest}. + */ + @Nullable + public static EndSessionResponse fromIntent(@NonNull Intent dataIntent) { + checkNotNull(dataIntent, "dataIntent must not be null"); + if (!dataIntent.hasExtra(EXTRA_RESPONSE)) { + return null; + } + + try { + return EndSessionResponse.jsonDeserialize(dataIntent.getStringExtra(EXTRA_RESPONSE)); + } catch (JSONException ex) { + throw new IllegalArgumentException("Intent contains malformed auth response", ex); + } + } + + static boolean containsEndSessionResponse(@NonNull Intent intent) { + return intent.hasExtra(EXTRA_RESPONSE); + } +} diff --git a/library/java/net/openid/appauth/RegistrationRequest.java b/library/java/net/openid/appauth/RegistrationRequest.java index d2c577fc..acc09193 100644 --- a/library/java/net/openid/appauth/RegistrationRequest.java +++ b/library/java/net/openid/appauth/RegistrationRequest.java @@ -79,8 +79,9 @@ public class RegistrationRequest { * The service's {@link AuthorizationServiceConfiguration configuration}. * This configuration specifies how to connect to a particular OAuth provider. * Configurations may be - * {@link AuthorizationServiceConfiguration#AuthorizationServiceConfiguration(Uri, - * Uri, Uri) created manually}, or + * {@link + * AuthorizationServiceConfiguration#AuthorizationServiceConfiguration(Uri, Uri, Uri, Uri) + * created manually}, or * {@link AuthorizationServiceConfiguration#fetchFromUrl(Uri, * AuthorizationServiceConfiguration.RetrieveConfigurationCallback) * via an OpenID Connect Discovery Document}. diff --git a/library/java/net/openid/appauth/TokenRequest.java b/library/java/net/openid/appauth/TokenRequest.java index e86fcb0b..ab7aef4e 100644 --- a/library/java/net/openid/appauth/TokenRequest.java +++ b/library/java/net/openid/appauth/TokenRequest.java @@ -118,8 +118,9 @@ public class TokenRequest { * The service's {@link AuthorizationServiceConfiguration configuration}. * This configuration specifies how to connect to a particular OAuth provider. * Configurations may be - * {@link AuthorizationServiceConfiguration#AuthorizationServiceConfiguration(Uri, - * Uri, Uri) created manually}, or + * {@link + * AuthorizationServiceConfiguration#AuthorizationServiceConfiguration(Uri, Uri, Uri, Uri) + * created manually}, or * {@link AuthorizationServiceConfiguration#fetchFromUrl(Uri, * AuthorizationServiceConfiguration.RetrieveConfigurationCallback) * via an OpenID Connect Discovery Document}. diff --git a/library/javatests/net/openid/appauth/AuthorizationManagementActivityTest.java b/library/javatests/net/openid/appauth/AuthorizationManagementActivityTest.java index 84245036..84569d1b 100644 --- a/library/javatests/net/openid/appauth/AuthorizationManagementActivityTest.java +++ b/library/javatests/net/openid/appauth/AuthorizationManagementActivityTest.java @@ -41,23 +41,30 @@ public class AuthorizationManagementActivityTest { private AuthorizationRequest mAuthRequest; + private EndSessionRequest mEndSessionRequest; private Intent mAuthIntent; private Intent mCompleteIntent; private PendingIntent mCompletePendingIntent; private Intent mCancelIntent; private PendingIntent mCancelPendingIntent; - private Intent mStartIntentWithPendings; - private Intent mStartIntentWithPendingsWithoutCancel; - private Intent mStartForResultIntent; + private Intent mStartAuthIntentWithPendings; + private Intent mStartAuthIntentWithPendingsWithoutCancel; + private Intent mStartAuthForResultIntent; + private Intent mEndSessionIntentWithPendings; + private Intent mEndSessionIntentWithPendingsWithoutCancel; + private Intent mEndSessionForResultIntent; private ActivityController mController; private AuthorizationManagementActivity mActivity; private ShadowActivity mActivityShadow; private Uri mSuccessAuthRedirect; private Uri mErrorAuthRedirect; + private Uri mSuccessEndSessionRedirect; + private Uri mErrorEndSessionRedirect; @Before public void setUp() { mAuthRequest = TestValues.getTestAuthRequest(); + mEndSessionRequest = TestValues.getTestEndSessionRequest(); mAuthIntent = new Intent("AUTH"); mCompleteIntent = new Intent("COMPLETE"); @@ -67,17 +74,28 @@ public void setUp() { mCancelPendingIntent = PendingIntent.getActivity(RuntimeEnvironment.application, 0, mCancelIntent, 0); - mStartIntentWithPendings = + mStartAuthIntentWithPendings = createStartIntentWithPendingIntents(mAuthRequest, mCancelPendingIntent); - mStartIntentWithPendingsWithoutCancel = + mStartAuthIntentWithPendingsWithoutCancel = createStartIntentWithPendingIntents(mAuthRequest, null); - mStartForResultIntent = createStartForResultIntent(mAuthRequest); + mStartAuthForResultIntent = createStartForResultIntent(mAuthRequest); + + mEndSessionIntentWithPendings = + createStartIntentWithPendingIntents(mEndSessionRequest, mCancelPendingIntent); + mEndSessionIntentWithPendingsWithoutCancel = + createStartIntentWithPendingIntents(mEndSessionRequest, null); + mEndSessionForResultIntent = + createStartForResultIntent(mEndSessionRequest); mSuccessAuthRedirect = mAuthRequest.redirectUri.buildUpon() - .appendQueryParameter(AuthorizationResponse.KEY_STATE, mAuthRequest.state) + .appendQueryParameter(AuthorizationResponse.KEY_STATE, mAuthRequest.getState()) .appendQueryParameter(AuthorizationResponse.KEY_AUTHORIZATION_CODE, "12345") .build(); + mSuccessEndSessionRedirect = mEndSessionRequest.redirectUri.buildUpon() + .appendQueryParameter(EndSessionRequest.KEY_STATE, mEndSessionRequest.getState()) + .build(); + mErrorAuthRedirect = mAuthRequest.redirectUri.buildUpon() .appendQueryParameter( AuthorizationException.PARAM_ERROR, @@ -87,11 +105,19 @@ public void setUp() { AuthorizationRequestErrors.ACCESS_DENIED.errorDescription) .build(); - instantiateActivity(mStartIntentWithPendings); + mErrorEndSessionRedirect = mEndSessionRequest.redirectUri.buildUpon() + .appendQueryParameter(AuthorizationException.PARAM_ERROR, + AuthorizationRequestErrors.ACCESS_DENIED.error) + .appendQueryParameter( + AuthorizationException.PARAM_ERROR_DESCRIPTION, + AuthorizationRequestErrors.ACCESS_DENIED.errorDescription) + .build(); + + instantiateActivity(mStartAuthIntentWithPendings); } private Intent createStartIntentWithPendingIntents( - AuthorizationRequest authRequest, + AuthorizationManagementRequest authRequest, PendingIntent cancelIntent) { return AuthorizationManagementActivity.createStartIntent( RuntimeEnvironment.application, @@ -102,7 +128,7 @@ private Intent createStartIntentWithPendingIntents( } private Intent createStartForResultIntent( - AuthorizationRequest authRequest) { + AuthorizationManagementRequest authRequest) { return AuthorizationManagementActivity.createStartForResultIntent( RuntimeEnvironment.application, authRequest, @@ -119,9 +145,9 @@ private void instantiateActivity(Intent managementIntent) { } @Test - public void testSuccessFlow_withPendingIntentsAndWithoutDestroy_shouldSendCompleteIntent() { + public void testLoginSuccessFlow_withPendingIntentsAndWithoutDestroy_shouldSendCompleteIntent() { // start the flow - instantiateActivity(mStartIntentWithPendings); + instantiateActivity(mStartAuthIntentWithPendings); mController.create().start().resume(); // an activity should be started for auth @@ -149,9 +175,38 @@ public void testSuccessFlow_withPendingIntentsAndWithoutDestroy_shouldSendComple } @Test - public void testSuccessFlow_withoutPendingIntentsAndWithoutDestroy_shouldSetResult() { + public void testEndOfSessionSuccessFlow_withPendingIntentsAndWithoutDestroy_shouldSendCompleteIntent() { + // start the flow + instantiateActivity(mEndSessionIntentWithPendings); + mController.create().start().resume(); + + // an activity should be started for auth + assertThat(mActivityShadow.getNextStartedActivity()).hasAction("AUTH"); + + // the management activity will be paused while the authorization flow is running. + // if there is no memory pressure, the activity will remain in the paused state. + mController.pause(); + + // on completion of the authorization activity, the result will be forwarded to the + // management activity via newIntent + mController.newIntent(AuthorizationManagementActivity.createResponseHandlingIntent( + RuntimeEnvironment.application, + mSuccessEndSessionRedirect)); + + // the management activity is then resumed + mController.resume(); + + // after which the completion intent should be fired + assertThat(mActivityShadow.getNextStartedActivity()) + .hasAction("COMPLETE") + .hasData(mSuccessEndSessionRedirect); + assertThat(mActivity).isFinishing(); + } + + @Test + public void testLoginSuccessFlow_withoutPendingIntentsAndWithoutDestroy_shouldSetResult() { // start the flow - instantiateActivity(mStartForResultIntent); + instantiateActivity(mStartAuthForResultIntent); mController.create().start().resume(); // an activity should be started for auth @@ -176,9 +231,36 @@ public void testSuccessFlow_withoutPendingIntentsAndWithoutDestroy_shouldSetResu } @Test - public void testSuccessFlow_withPendingIntentsAndWithDestroy_shouldSendCompleteIntent() { + public void testEndSessionSuccessFlow_withoutPendingIntentsAndWithoutDestroy_shouldSetResult() { // start the flow - instantiateActivity(mStartIntentWithPendings); + instantiateActivity(mEndSessionForResultIntent); + mController.create().start().resume(); + + // an activity should be started for auth + assertThat(mActivityShadow.getNextStartedActivity()).hasAction("AUTH"); + + // the management activity will be paused while the authorization flow is running. + // if there is no memory pressure, the activity will remain in the paused state. + mController.pause(); + + // on completion of the authorization activity, the result will be forwarded to the + // management activity via newIntent + mController.newIntent(AuthorizationManagementActivity.createResponseHandlingIntent( + RuntimeEnvironment.application, + mSuccessEndSessionRedirect)); + + // the management activity is then resumed + mController.resume(); + + // and then sets a result before finishing as there is no completion intent + assertThat(mActivityShadow.getResultCode()).isEqualTo(RESULT_OK); + assertThat(mActivity).isFinishing(); + } + + @Test + public void testLoginSuccessFlow_withPendingIntentsAndWithDestroy_shouldSendCompleteIntent() { + // start the flow + instantiateActivity(mStartAuthIntentWithPendings); mController.create().start().resume(); // an activity should be started for auth @@ -190,7 +272,7 @@ public void testSuccessFlow_withPendingIntentsAndWithDestroy_shouldSendCompleteI mController.pause().stop().saveInstanceState(savedState).destroy(); // on completion of the authorization activity, a new management activity will be created - instantiateActivity(mStartIntentWithPendings); + instantiateActivity(mStartAuthIntentWithPendings); mController.create(savedState).start(); // the authorization redirect will be forwarded via a new intent @@ -210,9 +292,42 @@ public void testSuccessFlow_withPendingIntentsAndWithDestroy_shouldSendCompleteI } @Test - public void testSuccessFlow_withoutPendingIntentsAndWithDestroy_shouldSetResult() { + public void testEndSessionSuccessFlow_withPendingIntentsAndWithDestroy_shouldSendCompleteIntent() { + // start the flow + instantiateActivity(mEndSessionIntentWithPendings); + mController.create().start().resume(); + + // an activity should be started for auth + assertThat(mActivityShadow.getNextStartedActivity()).hasAction("AUTH"); + + // on a device under memory pressure, the management activity will be destroyed, but + // it will be able to save its state + Bundle savedState = new Bundle(); + mController.pause().stop().saveInstanceState(savedState).destroy(); + + // on completion of the authorization activity, a new management activity will be created + instantiateActivity(mStartAuthIntentWithPendings); + mController.create(savedState).start(); + + // the authorization redirect will be forwarded via a new intent + mController.newIntent(AuthorizationManagementActivity.createResponseHandlingIntent( + RuntimeEnvironment.application, + mSuccessAuthRedirect)); + + // the management activity is then resumed + mController.resume(); + + // after which the completion intent should be fired + assertThat(mActivityShadow.getNextStartedActivity()) + .hasAction("COMPLETE") + .hasData(mSuccessAuthRedirect); + assertThat(mActivity).isFinishing(); + } + + @Test + public void testLoginSuccessFlow_withoutPendingIntentsAndWithDestroy_shouldSetResult() { // start the flow - instantiateActivity(mStartForResultIntent); + instantiateActivity(mStartAuthForResultIntent); mController.create().start().resume(); // an activity should be started for auth @@ -224,7 +339,7 @@ public void testSuccessFlow_withoutPendingIntentsAndWithDestroy_shouldSetResult( mController.pause().stop().saveInstanceState(savedState).destroy(); // on completion of the authorization activity, a new management activity will be created - instantiateActivity(mStartIntentWithPendings); + instantiateActivity(mStartAuthIntentWithPendings); mController.create(savedState).start(); // the authorization redirect will be forwarded via a new intent @@ -241,9 +356,40 @@ public void testSuccessFlow_withoutPendingIntentsAndWithDestroy_shouldSetResult( } @Test - public void testFailureFlow_withPendingIntentsAndWithoutDestroy_shouldSendCompleteIntent() { + public void testEndSessionSuccessFlow_withoutPendingIntentsAndWithDestroy_shouldSetResult() { + // start the flow + instantiateActivity(mEndSessionForResultIntent); + mController.create().start().resume(); + + // an activity should be started for auth + assertThat(mActivityShadow.getNextStartedActivity()).hasAction("AUTH"); + + // on a device under memory pressure, the management activity will be destroyed, but + // it will be able to save its state + Bundle savedState = new Bundle(); + mController.pause().stop().saveInstanceState(savedState).destroy(); + + // on completion of the authorization activity, a new management activity will be created + instantiateActivity(mStartAuthIntentWithPendings); + mController.create(savedState).start(); + + // the authorization redirect will be forwarded via a new intent + mController.newIntent(AuthorizationManagementActivity.createResponseHandlingIntent( + RuntimeEnvironment.application, + mSuccessEndSessionRedirect)); + + // the management activity is then resumed + mController.resume(); + + // and then sets a result before finishing as there is no completion intent + assertThat(mActivityShadow.getResultCode()).isEqualTo(RESULT_OK); + assertThat(mActivity).isFinishing(); + } + + @Test + public void testLoginFailureFlow_withPendingIntentsAndWithoutDestroy_shouldSendCompleteIntent() { // start the flow - instantiateActivity(mStartIntentWithPendings); + instantiateActivity(mStartAuthIntentWithPendings); mController.create().start().resume(); // an activity should be started for auth @@ -270,9 +416,38 @@ public void testFailureFlow_withPendingIntentsAndWithoutDestroy_shouldSendComple } @Test - public void testFailureFlow_withoutPendingIntentsAndWithoutDestroy_shouldSetResult() { + public void testEndSessionFailureFlow_withPendingIntentsAndWithoutDestroy_shouldSendCompleteIntent() { // start the flow - instantiateActivity(mStartForResultIntent); + instantiateActivity(mEndSessionIntentWithPendings); + mController.create().start().resume(); + + // an activity should be started for auth + assertThat(mActivityShadow.getNextStartedActivity()).hasAction("AUTH"); + + // the management activity will be paused while the authorization flow is running. + // if there is no memory pressure, the activity will remain in the paused state. + mController.pause(); + + // the authorization redirect will be forwarded via a new intent + mController.newIntent(AuthorizationManagementActivity.createResponseHandlingIntent( + RuntimeEnvironment.application, + mErrorEndSessionRedirect)); + + // the management activity is then resumed + mController.resume(); + + // after which the completion intent should be fired + assertThat(mActivityShadow.getNextStartedActivity()) + .hasAction("COMPLETE") + .hasData(mErrorEndSessionRedirect) + .hasExtra(AuthorizationException.EXTRA_EXCEPTION); + assertThat(mActivity).isFinishing(); + } + + @Test + public void testLoginFailureFlow_withoutPendingIntentsAndWithoutDestroy_shouldSetResult() { + // start the flow + instantiateActivity(mStartAuthForResultIntent); mController.create().start().resume(); // an activity should be started for auth @@ -298,8 +473,36 @@ public void testFailureFlow_withoutPendingIntentsAndWithoutDestroy_shouldSetResu } @Test - public void testMismatchedState_withPendingIntentsAndResponseDiffersFromRequest() { - emulateFlowToAuthorizationActivityLaunch(mStartIntentWithPendings); + public void testEndSessionFailureFlow_withoutPendingIntentsAndWithoutDestroy_shouldSetResult() { + // start the flow + instantiateActivity(mEndSessionForResultIntent); + mController.create().start().resume(); + + // an activity should be started for auth + assertThat(mActivityShadow.getNextStartedActivity()).hasAction("AUTH"); + + // the management activity will be paused while the authorization flow is running. + // if there is no memory pressure, the activity will remain in the paused state. + mController.pause(); + + // the authorization redirect will be forwarded via a new intent + mController.newIntent(AuthorizationManagementActivity.createResponseHandlingIntent( + RuntimeEnvironment.application, + mErrorAuthRedirect)); + + // the management activity is then resumed + mController.resume(); + + // after which the completion intent should be fired + assertThat(mActivityShadow.getResultCode()).isEqualTo(RESULT_OK); + Intent resultIntent = mActivityShadow.getResultIntent(); + assertThat(resultIntent.getData()).isEqualTo(mErrorEndSessionRedirect); + assertThat(mActivity).isFinishing(); + } + + @Test + public void testLoginMismatchedState_withPendingIntentsAndResponseDiffersFromRequest() { + emulateFlowToAuthorizationActivityLaunch(mStartAuthIntentWithPendings); Uri authResponseUri = mAuthRequest.redirectUri.buildUpon() .appendQueryParameter(AuthorizationResponse.KEY_STATE, "differentState") @@ -322,8 +525,31 @@ public void testMismatchedState_withPendingIntentsAndResponseDiffersFromRequest( } @Test - public void testMismatchedState_withoutPendingIntentsAndResponseDiffersFromRequest() { - emulateFlowToAuthorizationActivityLaunch(mStartForResultIntent); + public void testEndSessionnMismatchedState_withPendingIntentsAndResponseDiffersFromRequest() { + emulateFlowToAuthorizationActivityLaunch(mEndSessionIntentWithPendings); + + Uri authResponseUri = mEndSessionRequest.redirectUri.buildUpon() + .appendQueryParameter(EndSessionRequest.KEY_STATE, "differentState") + .build(); + + Intent nextStartedActivity = emulateAuthorizationResponseReceived( + AuthorizationManagementActivity.createResponseHandlingIntent( + RuntimeEnvironment.application, + authResponseUri)); + + // the next activity should be from the completion intent, carrying an error + assertThat(nextStartedActivity) + .hasAction("COMPLETE") + .hasData(authResponseUri) + .hasExtra(AuthorizationException.EXTRA_EXCEPTION); + + assertThat(AuthorizationException.fromIntent(nextStartedActivity)) + .isEqualTo(AuthorizationRequestErrors.STATE_MISMATCH); + } + + @Test + public void testLoginMismatchedState_withoutPendingIntentsAndResponseDiffersFromRequest() { + emulateFlowToAuthorizationActivityLaunch(mStartAuthForResultIntent); Uri authResponseUri = mAuthRequest.redirectUri.buildUpon() .appendQueryParameter(AuthorizationResponse.KEY_STATE, "differentState") @@ -351,7 +577,35 @@ public void testMismatchedState_withoutPendingIntentsAndResponseDiffersFromReque } @Test - public void testMismatchedState_withPendingIntentsAndNoStateInRequestWithStateInResponse() { + public void testEndSessionMismatchedState_withoutPendingIntentsAndResponseDiffersFromRequest() { + emulateFlowToAuthorizationActivityLaunch(mEndSessionForResultIntent); + + Uri authResponseUri = mEndSessionRequest.redirectUri.buildUpon() + .appendQueryParameter(AuthorizationResponse.KEY_STATE, "differentState") + .build(); + + // the authorization redirect will be forwarded via a new intent + mController.newIntent(AuthorizationManagementActivity.createResponseHandlingIntent( + RuntimeEnvironment.application, + authResponseUri)); + + // the management activity is then resumed + mController.resume(); + + // no completion intent, so exception should be passed to calling activity + // via the result intent supplied to setResult + Intent resultIntent = mActivityShadow.getResultIntent(); + + assertThat(resultIntent) + .hasData(authResponseUri) + .hasExtra(AuthorizationException.EXTRA_EXCEPTION); + + assertThat(AuthorizationException.fromIntent(resultIntent)) + .isEqualTo(AuthorizationRequestErrors.STATE_MISMATCH); + } + + @Test + public void testLoginMismatchedState_withPendingIntentsAndNoStateInRequestWithStateInResponse() { AuthorizationRequest request = new AuthorizationRequest.Builder( TestValues.getTestServiceConfig(), TestValues.TEST_CLIENT_ID, @@ -379,7 +633,33 @@ public void testMismatchedState_withPendingIntentsAndNoStateInRequestWithStateIn } @Test - public void testMismatchedState_withoutPendingIntentsAndNoStateInRequestWithStateInResponse() { + public void testEndSessionMismatchedState_withPendingIntentsAndNoStateInRequestWithStateInResponse() { + EndSessionRequest request = new EndSessionRequest.Builder( + TestValues.getTestServiceConfig(), + TestValues.TEST_ID_TOKEN, + TestValues.TEST_APP_REDIRECT_URI) + .setState("state") + .build(); + + Intent startIntent = createStartIntentWithPendingIntents(request, mCancelPendingIntent); + emulateFlowToAuthorizationActivityLaunch(startIntent); + + Uri authResponseUri = mEndSessionRequest.redirectUri.buildUpon() + .appendQueryParameter(AuthorizationResponse.KEY_STATE, "differentState") + .build(); + + // the next activity should be from the completion intent, carrying an error + Intent nextStartedActivity = emulateAuthorizationResponseReceived( + AuthorizationManagementActivity.createResponseHandlingIntent( + RuntimeEnvironment.application, + authResponseUri)); + + assertThat(AuthorizationException.fromIntent(nextStartedActivity)) + .isEqualTo(AuthorizationRequestErrors.STATE_MISMATCH); + } + + @Test + public void testLoginMismatchedState_withoutPendingIntentsAndNoStateInRequestWithStateInResponse() { AuthorizationRequest request = new AuthorizationRequest.Builder( TestValues.getTestServiceConfig(), TestValues.TEST_CLIENT_ID, @@ -410,19 +690,58 @@ public void testMismatchedState_withoutPendingIntentsAndNoStateInRequestWithStat } @Test - public void testInvalidResponse() { - emulateFlowToAuthorizationActivityLaunch(mStartIntentWithPendings); + public void testEndSessionMismatchedState_withoutPendingIntentsAndNoStateInRequestWithStateInResponse() { + EndSessionRequest request = new EndSessionRequest.Builder( + TestValues.getTestServiceConfig(), + TestValues.TEST_ID_TOKEN, + TestValues.TEST_APP_REDIRECT_URI) + .setState("state") + .build(); - Uri authResponseUri = mAuthRequest.redirectUri.buildUpon() - .appendQueryParameter(AuthorizationResponse.KEY_STATE, "differentState") - .appendQueryParameter(AuthorizationResponse.KEY_AUTHORIZATION_CODE, "12345") - .build(); + Intent startIntent = createStartForResultIntent(request); + emulateFlowToAuthorizationActivityLaunch(startIntent); + + Uri authResponseUri = mEndSessionRequest.redirectUri.buildUpon() + .appendQueryParameter(AuthorizationResponse.KEY_STATE, "differentState") + .build(); + + // the authorization redirect will be forwarded via a new intent + mController.newIntent(AuthorizationManagementActivity.createResponseHandlingIntent( + RuntimeEnvironment.application, + authResponseUri)); + + // the management activity is then resumed + mController.resume(); + + Intent resultIntent = mActivityShadow.getResultIntent(); + assertThat(AuthorizationException.fromIntent(resultIntent)) + .isEqualTo(AuthorizationRequestErrors.STATE_MISMATCH); + } + + @Test + public void testLoginCancelFlow_withPendingIntentsAndWithoutDestroy() { + // start the flow + instantiateActivity(mStartAuthIntentWithPendings); + mController.create().start().resume(); + + // an activity should be started for auth + assertThat(mActivityShadow.getNextStartedActivity()).isNotNull(); + + // the management activity will be paused while this auth intent is running + mController.pause(); + + // when the user cancels the auth intent, the management activity will be resumed + mController.resume(); + + // at which point the cancel intent should be fired + assertThat(mActivityShadow.getNextStartedActivity()).hasAction("CANCEL"); + assertThat(mActivity).isFinishing(); } @Test - public void testCancelFlow_withPendingIntentsAndWithoutDestroy() { + public void testEndSessionCancelFlow_withPendingIntentsAndWithoutDestroy() { // start the flow - instantiateActivity(mStartIntentWithPendings); + instantiateActivity(mEndSessionIntentWithPendings); mController.create().start().resume(); // an activity should be started for auth @@ -440,9 +759,9 @@ public void testCancelFlow_withPendingIntentsAndWithoutDestroy() { } @Test - public void testCancelFlow_withoutPendingIntentsAndWithoutDestroy() { + public void testLoginCancelFlow_withoutPendingIntentsAndWithoutDestroy() { // start the flow - instantiateActivity(mStartForResultIntent); + instantiateActivity(mStartAuthForResultIntent); mController.create().start().resume(); // an activity should be started for auth @@ -467,9 +786,56 @@ public void testCancelFlow_withoutPendingIntentsAndWithoutDestroy() { } @Test - public void testCancelFlow_withCompletionIntentButNoCancelIntent() { + public void testEndSessionCancelFlow_withoutPendingIntentsAndWithoutDestroy() { + // start the flow + instantiateActivity(mEndSessionForResultIntent); + mController.create().start().resume(); + + // an activity should be started for auth + assertThat(mActivityShadow.getNextStartedActivity()).isNotNull(); + + // the management activity will be paused while this auth intent is running + mController.pause(); + + // when the user cancels the auth intent, the management activity will be resumed + mController.resume(); + + // at which point the cancel intent should be fired + assertThat(mActivityShadow.getResultCode()).isEqualTo(RESULT_CANCELED); + + Intent resultIntent = mActivityShadow.getResultIntent(); + assertThat(resultIntent).hasExtra(AuthorizationException.EXTRA_EXCEPTION); + + assertThat(AuthorizationException.fromIntent(resultIntent)) + .isEqualTo(AuthorizationException.GeneralErrors.USER_CANCELED_AUTH_FLOW); + + assertThat(mActivity).isFinishing(); + } + + @Test + public void testLoginCancelFlow_withCompletionIntentButNoCancelIntent() { + // start the flow + instantiateActivity(mStartAuthIntentWithPendingsWithoutCancel); + mController.create().start().resume(); + + // an activity should be started for auth + assertThat(mActivityShadow.getNextStartedActivity()).isNotNull(); + + // the management activity will be paused while this auth intent is running + mController.pause(); + + // when the user cancels the auth intent, the management activity will be resumed + mController.resume(); + + // as there is no cancel intent, the activity simply finishes + assertThat(mActivityShadow.getNextStartedActivity()).isNull(); + assertThat(mActivity).isFinishing(); + } + + @Test + public void testEndSessionCancelFlow_withCompletionIntentButNoCancelIntent() { // start the flow - instantiateActivity(mStartIntentWithPendingsWithoutCancel); + instantiateActivity(mEndSessionIntentWithPendingsWithoutCancel); mController.create().start().resume(); // an activity should be started for auth diff --git a/library/javatests/net/openid/appauth/AuthorizationServiceConfigurationTest.java b/library/javatests/net/openid/appauth/AuthorizationServiceConfigurationTest.java index e3eb10de..9e99731f 100644 --- a/library/javatests/net/openid/appauth/AuthorizationServiceConfigurationTest.java +++ b/library/javatests/net/openid/appauth/AuthorizationServiceConfigurationTest.java @@ -52,6 +52,7 @@ public class AuthorizationServiceConfigurationTest { private static final String TEST_ISSUER = "test_issuer"; private static final String TEST_AUTH_ENDPOINT = "https://test.openid.com/o/oauth/auth"; private static final String TEST_TOKEN_ENDPOINT = "https://test.openid.com/o/oauth/token"; + private static final String TEST_END_SESSION_ENDPOINT = "https://test.openid.com/o/oauth/logout"; private static final String TEST_REGISTRATION_ENDPOINT = "https://test.openid.com/o/oauth/registration"; private static final String TEST_USERINFO_ENDPOINT = "https://test.openid.com/o/oauth/userinfo"; private static final String TEST_JWKS_URI = "https://test.openid.com/o/oauth/jwks"; @@ -116,6 +117,7 @@ public void setUp() throws Exception { mConfig = new AuthorizationServiceConfiguration( Uri.parse(TEST_AUTH_ENDPOINT), Uri.parse(TEST_TOKEN_ENDPOINT), + Uri.parse(TEST_END_SESSION_ENDPOINT), Uri.parse(TEST_REGISTRATION_ENDPOINT)); when(mConnectionBuilder.openConnection(any(Uri.class))).thenReturn(mHttpConnection); } @@ -137,11 +139,25 @@ public void testSerializationWithoutRegistrationEndpoint() throws Exception { AuthorizationServiceConfiguration config = new AuthorizationServiceConfiguration( Uri.parse(TEST_AUTH_ENDPOINT), Uri.parse(TEST_TOKEN_ENDPOINT), - null); + Uri.parse(TEST_END_SESSION_ENDPOINT), null); AuthorizationServiceConfiguration deserialized = AuthorizationServiceConfiguration .fromJson(config.toJson()); assertThat(deserialized.authorizationEndpoint).isEqualTo(config.authorizationEndpoint); assertThat(deserialized.tokenEndpoint).isEqualTo(config.tokenEndpoint); + assertThat(deserialized.endSessionEndpoint).isEqualTo(config.endSessionEndpoint); + assertThat(deserialized.registrationEndpoint).isNull(); + } + + @Test + public void testSerializationWithoutRegistrationEndpointAndEndSessionEndpoint() throws Exception { + AuthorizationServiceConfiguration config = new AuthorizationServiceConfiguration( + Uri.parse(TEST_AUTH_ENDPOINT), + Uri.parse(TEST_TOKEN_ENDPOINT)); + AuthorizationServiceConfiguration deserialized = AuthorizationServiceConfiguration + .fromJson(config.toJson()); + assertThat(deserialized.authorizationEndpoint).isEqualTo(config.authorizationEndpoint); + assertThat(deserialized.tokenEndpoint).isEqualTo(config.tokenEndpoint); + assertThat(deserialized.endSessionEndpoint).isNull(); assertThat(deserialized.registrationEndpoint).isNull(); } diff --git a/library/javatests/net/openid/appauth/AuthorizationServiceDiscoveryTest.java b/library/javatests/net/openid/appauth/AuthorizationServiceDiscoveryTest.java index 4441e064..30b88392 100644 --- a/library/javatests/net/openid/appauth/AuthorizationServiceDiscoveryTest.java +++ b/library/javatests/net/openid/appauth/AuthorizationServiceDiscoveryTest.java @@ -38,6 +38,7 @@ public class AuthorizationServiceDiscoveryTest { static final String TEST_TOKEN_ENDPOINT = "http://test.openid.com/o/oauth/token"; static final String TEST_USERINFO_ENDPOINT = "http://test.openid.com/o/oauth/userinfo"; static final String TEST_REGISTRATION_ENDPOINT = "http://test.openid.com/o/oauth/register"; + static final String TEST_END_SESSION_ENDPOINT = "http://test.openid.com/o/oauth/logout"; static final String TEST_JWKS_URI = "http://test.openid.com/o/oauth/jwks"; static final List TEST_RESPONSE_TYPES_SUPPORTED = Arrays.asList("code", "token"); static final List TEST_SUBJECT_TYPES_SUPPORTED = Arrays.asList("public"); @@ -53,6 +54,7 @@ public class AuthorizationServiceDiscoveryTest { TEST_TOKEN_ENDPOINT, TEST_USERINFO_ENDPOINT, TEST_REGISTRATION_ENDPOINT, + TEST_END_SESSION_ENDPOINT, TEST_JWKS_URI, TEST_RESPONSE_TYPES_SUPPORTED, TEST_SUBJECT_TYPES_SUPPORTED, diff --git a/library/javatests/net/openid/appauth/AuthorizationServiceTest.java b/library/javatests/net/openid/appauth/AuthorizationServiceTest.java index 078c5046..84bff307 100644 --- a/library/javatests/net/openid/appauth/AuthorizationServiceTest.java +++ b/library/javatests/net/openid/appauth/AuthorizationServiceTest.java @@ -70,6 +70,8 @@ import static net.openid.appauth.TestValues.getTestAuthCodeExchangeRequest; import static net.openid.appauth.TestValues.getTestAuthCodeExchangeRequestBuilder; import static net.openid.appauth.TestValues.getTestAuthRequestBuilder; +import static net.openid.appauth.TestValues.getTestEndSessionRequest; +import static net.openid.appauth.TestValues.getTestEndSessionRequestBuilder; import static net.openid.appauth.TestValues.getTestIdTokenWithNonce; import static net.openid.appauth.TestValues.getTestRegistrationRequest; import static org.assertj.core.api.Assertions.assertThat; @@ -157,6 +159,17 @@ public void testAuthorizationRequest_withSpecifiedState() throws Exception { assertEquals(request.toUri().toString(), intent.getData().toString()); } + @Test + public void testEndSessionRequest_withSpecifiedState() throws Exception { + EndSessionRequest request = getTestEndSessionRequestBuilder() + .setState(TEST_STATE) + .build(); + mService.performEndSessionRequest(request, mPendingIntent); + Intent intent = captureAuthRequestIntent(); + assertRequestIntent(intent, null); + assertEquals(request.toUri().toString(), intent.getData().toString()); + } + @Test public void testAuthorizationRequest_withSpecifiedNonce() throws Exception { AuthorizationRequest request = getTestAuthRequestBuilder() @@ -189,12 +202,31 @@ public void testAuthorizationRequest_customization() throws Exception { assertColorMatch(intent, Color.GREEN); } + @Test + public void testEndSessionRequest_customization() throws Exception { + CustomTabsIntent customTabsIntent = new CustomTabsIntent.Builder() + .setToolbarColor(Color.GREEN) + .build(); + mService.performEndSessionRequest( + getTestEndSessionRequest(), + mPendingIntent, + customTabsIntent); + Intent intent = captureAuthRequestIntent(); + assertColorMatch(intent, Color.GREEN); + } + @Test(expected = IllegalStateException.class) public void testAuthorizationRequest_afterDispose() throws Exception { mService.dispose(); mService.performAuthorizationRequest(getTestAuthRequestBuilder().build(), mPendingIntent); } + @Test(expected = IllegalStateException.class) + public void testEndSessionRequest_afterDispose() throws Exception { + mService.dispose(); + mService.performEndSessionRequest(getTestEndSessionRequest(), mPendingIntent); + } + @Test public void testGetAuthorizationRequestIntent_preservesRequest() { AuthorizationRequest request = getTestAuthRequestBuilder().build(); diff --git a/library/javatests/net/openid/appauth/EndSessionRequestTest.java b/library/javatests/net/openid/appauth/EndSessionRequestTest.java new file mode 100644 index 00000000..5d14e945 --- /dev/null +++ b/library/javatests/net/openid/appauth/EndSessionRequestTest.java @@ -0,0 +1,105 @@ +package net.openid.appauth; + +import static net.openid.appauth.TestValues.TEST_APP_REDIRECT_URI; +import static net.openid.appauth.TestValues.TEST_ID_TOKEN; +import static net.openid.appauth.TestValues.getTestServiceConfig; + +import android.net.Uri; + +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import static org.junit.Assert.*; +import static org.assertj.core.api.Assertions.assertThat; + +@RunWith(RobolectricTestRunner.class) +@Config(constants = BuildConfig.class, sdk=16) +public class EndSessionRequestTest { + + /* ********************************** Builder() ***********************************************/ + @Test(expected = NullPointerException.class) + public void testBuilder_nullConfiguration() { + new EndSessionRequest.Builder( + null, + TEST_ID_TOKEN, + TEST_APP_REDIRECT_URI); + } + + @Test(expected = NullPointerException.class) + public void testBuilder_nullIdToken() { + new EndSessionRequest.Builder( + getTestServiceConfig(), + null, + TEST_APP_REDIRECT_URI); + } + + @Test(expected = IllegalArgumentException.class) + public void testBuilder_emptyIdToken() { + new EndSessionRequest.Builder( + getTestServiceConfig(), + "", + TEST_APP_REDIRECT_URI); + } + + @Test(expected = NullPointerException.class) + public void testBuilder_nullRedirectUri() { + new EndSessionRequest.Builder( + getTestServiceConfig(), + TEST_ID_TOKEN, + null); + } + + @Test + public void testState_notNull() { + EndSessionRequest request = new EndSessionRequest.Builder( + getTestServiceConfig(), + TEST_ID_TOKEN, + TEST_APP_REDIRECT_URI).build(); + assertNotNull(request.getState()); + } + + @Test + public void testToUri() { + EndSessionRequest request = TestValues.getTestEndSessionRequest(); + Uri requestUri = request.toUri(); + + assertThat(requestUri.getQueryParameter(EndSessionRequest.KEY_ID_TOKEN_HINT)) + .isEqualTo(request.idToken); + assertThat(requestUri.getQueryParameter(EndSessionRequest.KEY_REDIRECT_URI)) + .isEqualTo(request.redirectUri.toString()); + assertThat(requestUri.getQueryParameter(EndSessionRequest.KEY_STATE)) + .isEqualTo(request.state); + + } + + @Test + public void testJsonSerialize() throws Exception { + EndSessionRequest resquest = TestValues.getTestEndSessionRequest(); + EndSessionRequest copy = serializeDeserialize(resquest); + assertThat(copy.idToken).isEqualTo(TEST_ID_TOKEN); + assertThat(copy.state).isEqualTo(resquest.state); + assertThat(copy.redirectUri).isEqualTo(resquest.redirectUri); + } + + @Test + public void testIsEndSessionRequestSuccess() { + JSONObject json = TestValues.getTestEndSessionRequest().jsonSerialize(); + assertTrue(EndSessionRequest.isEndSessionRequest(json)); + } + + @Test + public void testIsEndSessionRequestFailure() { + JSONObject json = TestValues.getTestAuthRequestBuilder().build().jsonSerialize(); + assertFalse(EndSessionRequest.isEndSessionRequest(json)); + } + + private EndSessionRequest serializeDeserialize(EndSessionRequest request) + throws JSONException { + return EndSessionRequest.jsonDeserialize(request.jsonSerializeString()); + } + +} diff --git a/library/javatests/net/openid/appauth/EndSessionResponseTest.java b/library/javatests/net/openid/appauth/EndSessionResponseTest.java new file mode 100644 index 00000000..82a8ad6f --- /dev/null +++ b/library/javatests/net/openid/appauth/EndSessionResponseTest.java @@ -0,0 +1,139 @@ +package net.openid.appauth; + +import android.content.Intent; +import android.net.Uri; + +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import static org.assertj.core.api.Assertions.assertThat; + +@RunWith(RobolectricTestRunner.class) +@Config(constants = BuildConfig.class, sdk=16) +public class EndSessionResponseTest { + + private static final EndSessionRequest TEST_REQUEST = TestValues.getTestEndSessionRequest(); + + + @Test(expected = NullPointerException.class) + public void testBuilder_nullRequest(){ + new EndSessionResponse.Builder(null) + .setState(TEST_REQUEST.state); + } + + @Test(expected = NullPointerException.class) + public void testBuilder_nullState(){ + new EndSessionResponse.Builder(TEST_REQUEST) + .setState(null); + } + + @Test + public void testIntentSerializeDeserialize(){ + EndSessionResponse endSessionResponse = + new EndSessionResponse.Builder(TEST_REQUEST) + .setState(TEST_REQUEST.state) + .build(); + + Intent endSessionIntent = endSessionResponse.toIntent(); + + EndSessionResponse deserializeResponse = EndSessionResponse.fromIntent(endSessionIntent); + + assertThat(deserializeResponse).isNotNull(); + assertThat(deserializeResponse.state).isEqualTo(endSessionResponse.request.state); + assertThat(deserializeResponse.request.redirectUri) + .isEqualTo(endSessionResponse.request.redirectUri); + assertThat(deserializeResponse.request.configuration.endSessionEndpoint) + .isEqualTo(endSessionResponse.request.configuration.endSessionEndpoint); + assertThat(deserializeResponse.request.state) + .isEqualTo(endSessionResponse.request.state); + assertThat(deserializeResponse.request.idToken) + .isEqualTo(endSessionResponse.request.idToken); + } + + @Test + public void testIntentSerializeNull(){ + EndSessionResponse deserializeResponse = EndSessionResponse.fromIntent(new Intent()); + assertThat(deserializeResponse).isNull(); + } + + @Test (expected = IllegalArgumentException.class) + public void testIntentDeserializeError(){ + Intent intent = new Intent(); + intent.putExtra(EndSessionResponse.EXTRA_RESPONSE, ""); + EndSessionResponse.fromIntent(intent); + } + + @Test + public void testJsonSerializeDeserialize() throws JSONException { + EndSessionResponse endSessionResponse = + new EndSessionResponse.Builder(TEST_REQUEST) + .setState(TEST_REQUEST.state) + .build(); + + JSONObject endSessionResponseJson = endSessionResponse.jsonSerialize(); + + EndSessionResponse deserializeResponse = + EndSessionResponse.jsonDeserialize(endSessionResponseJson); + + assertThat(deserializeResponse).isNotNull(); + assertThat(deserializeResponse.state).isEqualTo(endSessionResponse.state); + assertThat(deserializeResponse.request.redirectUri) + .isEqualTo(endSessionResponse.request.redirectUri); + assertThat(deserializeResponse.request.configuration.endSessionEndpoint) + .isEqualTo(endSessionResponse.request.configuration.endSessionEndpoint); + assertThat(deserializeResponse.request.state) + .isEqualTo(endSessionResponse.request.state); + assertThat(deserializeResponse.request.idToken) + .isEqualTo(endSessionResponse.request.idToken); + } + + @Test + public void testFromRequestAndUri_Success(){ + EndSessionResponse endSessionResponse = + new EndSessionResponse.Builder(TEST_REQUEST) + .setState(TEST_REQUEST.state) + .build(); + + Uri endSessionUri = new Uri.Builder() + .appendQueryParameter(EndSessionResponse.KEY_STATE, TEST_REQUEST.state) + .build(); + + EndSessionResponse endSessionResponseDeserialized = + new EndSessionResponse.Builder(TEST_REQUEST) + .fromUri(endSessionUri) + .build(); + assertThat(endSessionResponseDeserialized).isNotNull(); + assertThat(endSessionResponseDeserialized.state).isEqualTo(endSessionResponse.state); + assertThat(endSessionResponseDeserialized.request.redirectUri) + .isEqualTo(endSessionResponse.request.redirectUri); + assertThat(endSessionResponseDeserialized.request.configuration.endSessionEndpoint) + .isEqualTo(endSessionResponse.request.configuration.endSessionEndpoint); + assertThat(endSessionResponseDeserialized.request.state) + .isEqualTo(endSessionResponse.request.state); + assertThat(endSessionResponseDeserialized.request.idToken) + .isEqualTo(endSessionResponse.request.idToken); + } + + @Test + public void testIntent_containsEndSessionResponse_True() { + EndSessionResponse endSessionResponse = + new EndSessionResponse.Builder(TEST_REQUEST) + .setState(TEST_REQUEST.state) + .build(); + + Intent endSessionIntent = endSessionResponse.toIntent(); + + assertThat(EndSessionResponse.containsEndSessionResponse(endSessionIntent)).isTrue(); + } + + @Test + public void testIntent_containsEndSessionResponse_False() { + Intent intent = new Intent(); + assertThat(EndSessionResponse.containsEndSessionResponse(intent)).isFalse(); + } + +} diff --git a/library/javatests/net/openid/appauth/IdTokenTest.java b/library/javatests/net/openid/appauth/IdTokenTest.java index 3579bbb7..e9d86ac4 100644 --- a/library/javatests/net/openid/appauth/IdTokenTest.java +++ b/library/javatests/net/openid/appauth/IdTokenTest.java @@ -18,6 +18,7 @@ import static net.openid.appauth.AuthorizationServiceDiscoveryTest.TEST_AUTHORIZATION_ENDPOINT; import static net.openid.appauth.AuthorizationServiceDiscoveryTest.TEST_CLAIMS_SUPPORTED; +import static net.openid.appauth.AuthorizationServiceDiscoveryTest.TEST_END_SESSION_ENDPOINT; import static net.openid.appauth.AuthorizationServiceDiscoveryTest.TEST_ID_TOKEN_SIGNING_ALG_VALUES; import static net.openid.appauth.AuthorizationServiceDiscoveryTest.TEST_JWKS_URI; import static net.openid.appauth.AuthorizationServiceDiscoveryTest.TEST_REGISTRATION_ENDPOINT; @@ -369,6 +370,7 @@ private String getDiscoveryDocJsonWithIssuer(String issuer) { TEST_TOKEN_ENDPOINT, TEST_USERINFO_ENDPOINT, TEST_REGISTRATION_ENDPOINT, + TEST_END_SESSION_ENDPOINT, TEST_JWKS_URI, TEST_RESPONSE_TYPES_SUPPORTED, TEST_SUBJECT_TYPES_SUPPORTED, diff --git a/library/javatests/net/openid/appauth/TestValues.java b/library/javatests/net/openid/appauth/TestValues.java index 8e8b10f7..e019149b 100644 --- a/library/javatests/net/openid/appauth/TestValues.java +++ b/library/javatests/net/openid/appauth/TestValues.java @@ -71,6 +71,7 @@ static String getDiscoveryDocumentJson( String tokenEndpoint, String userInfoEndpoint, String registrationEndpoint, + String endSessionEndpoint, String jwksUri, List responseTypesSupported, List subjectTypesSupported, @@ -84,6 +85,7 @@ static String getDiscoveryDocumentJson( + " \"authorization_endpoint\": \"" + authorizationEndpoint + "\",\n" + " \"token_endpoint\": \"" + tokenEndpoint + "\",\n" + " \"userinfo_endpoint\": \"" + userInfoEndpoint + "\",\n" + + " \"end_session_endpoint\": \"" + endSessionEndpoint + "\",\n" + " \"registration_endpoint\": \"" + registrationEndpoint + "\",\n" + " \"jwks_uri\": \"" + jwksUri + "\",\n" + " \"response_types_supported\": " + toJson(responseTypesSupported) + ",\n" @@ -124,10 +126,22 @@ public static AuthorizationRequest.Builder getTestAuthRequestBuilder() { .setCodeVerifier(TEST_CODE_VERIFIER); } + public static EndSessionRequest.Builder getTestEndSessionRequestBuilder() { + return new EndSessionRequest.Builder( + getTestServiceConfig(), + TEST_ID_TOKEN, + TEST_APP_REDIRECT_URI); + } + public static AuthorizationRequest getTestAuthRequest() { - return getTestAuthRequestBuilder(). - setNonce(null). - build(); + return getTestAuthRequestBuilder() + .setNonce(null) + .build(); + } + + public static EndSessionRequest getTestEndSessionRequest() { + return getTestEndSessionRequestBuilder() + .build(); } public static AuthorizationResponse.Builder getTestAuthResponseBuilder() { From 95fcd92702f0e6209c53145225d60974f9a4b705 Mon Sep 17 00:00:00 2001 From: Wassim Salib Date: Mon, 21 Sep 2020 18:49:54 -0600 Subject: [PATCH 2/7] Create gradle.yml --- .github/workflows/gradle.yml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 .github/workflows/gradle.yml diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml new file mode 100644 index 00000000..0c115b53 --- /dev/null +++ b/.github/workflows/gradle.yml @@ -0,0 +1,30 @@ +# This workflow will build a Java project with Gradle +# For more information see: https://help.github.com/actions/language-and-framework-guides/building-and-testing-java-with-gradle + +name: Java CI with Gradle + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up JDK 1.8 + uses: actions/setup-java@v1 + with: + java-version: 1.8 + - name: Grant execute permission for gradlew + run: chmod +x gradlew + - name: Build with Gradle + run: ./gradlew build + - uses: actions/upload-artifact@v2 + with: + name: Package + path: build/libs From b3b1f004937ce6861b974282cbd441bade37a389 Mon Sep 17 00:00:00 2001 From: Wassim Salib Date: Mon, 21 Sep 2020 19:05:27 -0600 Subject: [PATCH 3/7] only run on tags --- .github/workflows/gradle.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 0c115b53..7b954677 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -5,10 +5,9 @@ name: Java CI with Gradle on: push: - branches: [ master ] - pull_request: - branches: [ master ] - + tags: + - '[0-9]+.[0-9]+.[0-9]+' + jobs: build: From 5acc02e2aad23c93109a9e6b5a096a581519aedf Mon Sep 17 00:00:00 2001 From: Wassim Salib Date: Mon, 21 Sep 2020 19:35:15 -0600 Subject: [PATCH 4/7] Migrate to AndroidX --- .../openid/appauth/AuthorizationManagementResponse.java | 2 +- .../net/openid/appauth/AuthorizationManagementUtil.java | 7 ++++--- library/java/net/openid/appauth/EndSessionRequest.java | 6 +++--- library/java/net/openid/appauth/EndSessionResponse.java | 8 +++++--- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/library/java/net/openid/appauth/AuthorizationManagementResponse.java b/library/java/net/openid/appauth/AuthorizationManagementResponse.java index 750a7d88..428bff92 100644 --- a/library/java/net/openid/appauth/AuthorizationManagementResponse.java +++ b/library/java/net/openid/appauth/AuthorizationManagementResponse.java @@ -15,7 +15,7 @@ package net.openid.appauth; import android.content.Intent; -import android.support.annotation.NonNull; +import androidx.annotation.NonNull; import org.json.JSONObject; diff --git a/library/java/net/openid/appauth/AuthorizationManagementUtil.java b/library/java/net/openid/appauth/AuthorizationManagementUtil.java index bf7c78ad..f830360c 100644 --- a/library/java/net/openid/appauth/AuthorizationManagementUtil.java +++ b/library/java/net/openid/appauth/AuthorizationManagementUtil.java @@ -18,8 +18,9 @@ import android.content.Intent; import android.net.Uri; -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import org.json.JSONException; import org.json.JSONObject; @@ -67,7 +68,7 @@ static AuthorizationManagementResponse responseWith( } /** - * Extracts response from an intent produced by {@link #toIntent()}. This is + * Extracts response from an intent produced by . This is * used to extract the response from the intent data passed to an activity registered as the * handler for {@link AuthorizationService#performEndSessionRequest} * or {@link AuthorizationService#performAuthorizationRequest}. diff --git a/library/java/net/openid/appauth/EndSessionRequest.java b/library/java/net/openid/appauth/EndSessionRequest.java index 69d73f15..f84d8348 100644 --- a/library/java/net/openid/appauth/EndSessionRequest.java +++ b/library/java/net/openid/appauth/EndSessionRequest.java @@ -18,9 +18,9 @@ import static net.openid.appauth.Preconditions.checkNotNull; import android.net.Uri; -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; -import android.support.annotation.VisibleForTesting; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; import org.json.JSONException; import org.json.JSONObject; diff --git a/library/java/net/openid/appauth/EndSessionResponse.java b/library/java/net/openid/appauth/EndSessionResponse.java index 7aff586f..b329506b 100644 --- a/library/java/net/openid/appauth/EndSessionResponse.java +++ b/library/java/net/openid/appauth/EndSessionResponse.java @@ -19,9 +19,11 @@ import android.content.Intent; import android.net.Uri; -import android.support.annotation.NonNull; -import android.support.annotation.Nullable; -import android.support.annotation.VisibleForTesting; + +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import androidx.annotation.NonNull; import org.json.JSONException; import org.json.JSONObject; From b35e36b9ce203d37bde8700d456f6dca0174213c Mon Sep 17 00:00:00 2001 From: Wassim Salib Date: Mon, 21 Sep 2020 20:07:13 -0600 Subject: [PATCH 5/7] Update README.md --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 3617bc64..3e6f5c29 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ ![AppAuth for Android](https://rawgit.com/openid/AppAuth-Android/master/appauth_lockup.svg) - -[![Download](https://api.bintray.com/packages/openid/net.openid/appauth/images/download.svg) ](https://bintray.com/openid/net.openid/appauth/_latestVersion) -[![Javadocs](http://javadoc.io/badge/net.openid/appauth.svg)](http://javadoc.io/doc/net.openid/appauth) -[![Build Status](https://travis-ci.org/openid/AppAuth-Android.svg?branch=master)](https://travis-ci.org/openid/AppAuth-Android) +[![](https://jitpack.io/v/trufla-technology/AppAuth-Android.svg)](https://jitpack.io/#trufla-technology/AppAuth-Android) +[![Download](https://api.bintray.com/packages/trufla-technology/net.openid/appauth/images/download.svg) ](https://bintray.com/openid/net.openid/appauth/_latestVersion) +[![Javadocs](http://javadoc.io/badge/net.trufla-technology/appauth.svg)](http://javadoc.io/doc/net.trufla-technology/appauth) +[![Build Status](https://travis-ci.org/trufla-technology/AppAuth-Android.svg?branch=master)](https://travis-ci.org/openid/AppAuth-Android) [![Codacy Badge](https://api.codacy.com/project/badge/grade/321412eec811478085ec6c4c923ad8a1)](https://www.codacy.com/app/iainmcgin/AppAuth-Android) -[![codecov.io](https://codecov.io/github/openid/AppAuth-Android/coverage.svg?branch=master)](https://codecov.io/github/openid/AppAuth-Android?branch=master) +[![codecov.io](https://codecov.io/github/trufla-technology/AppAuth-Android/coverage.svg?branch=master)](https://codecov.io/github/openid/AppAuth-Android?branch=master) AppAuth for Android is a client SDK for communicating with [OAuth 2.0](https://tools.ietf.org/html/rfc6749) and From 79c5f6f0328706da2ed9339a868f8bbca0094bc0 Mon Sep 17 00:00:00 2001 From: marina Date: Tue, 22 Sep 2020 12:20:37 +0200 Subject: [PATCH 6/7] add logout feature --- .../net/openid/appauthdemo/TokenActivity.java | 82 ++++++++-- app/res/raw/auth_config.json | 15 +- app/res/values/idp_configs.xml | 26 +++ app/res/values/strings.xml | 1 + library/AndroidManifest.xml | 16 +- .../AuthorizationServiceDiscovery.java | 2 + .../net/openid/appauth/BrowserHandler.java | 123 ++++++++++++++ .../openid/appauth/BrowserPackageHelper.java | 151 ++++++++++++++++++ .../net/openid/appauth/LogoutRequest.java | 30 ++++ .../net/openid/appauth/LogoutService.java | 90 +++++++++++ .../appauth/LogoutUriReceiverActivity.java | 60 +++++++ .../appauth/PendingLogoutIntentStore.java | 42 +++++ 12 files changed, 613 insertions(+), 25 deletions(-) create mode 100644 app/res/values/idp_configs.xml create mode 100644 library/java/net/openid/appauth/BrowserHandler.java create mode 100644 library/java/net/openid/appauth/BrowserPackageHelper.java create mode 100644 library/java/net/openid/appauth/LogoutRequest.java create mode 100644 library/java/net/openid/appauth/LogoutService.java create mode 100644 library/java/net/openid/appauth/LogoutUriReceiverActivity.java create mode 100644 library/java/net/openid/appauth/PendingLogoutIntentStore.java diff --git a/app/java/net/openid/appauthdemo/TokenActivity.java b/app/java/net/openid/appauthdemo/TokenActivity.java index 292c9388..7df3bbc9 100644 --- a/app/java/net/openid/appauthdemo/TokenActivity.java +++ b/app/java/net/openid/appauthdemo/TokenActivity.java @@ -15,6 +15,7 @@ package net.openid.appauthdemo; import android.app.Activity; +import android.app.PendingIntent; import android.content.Intent; import android.net.Uri; import android.os.Bundle; @@ -38,6 +39,8 @@ import net.openid.appauth.AuthorizationServiceDiscovery; import net.openid.appauth.ClientAuthentication; import net.openid.appauth.EndSessionRequest; +import net.openid.appauth.LogoutRequest; +import net.openid.appauth.LogoutService; import net.openid.appauth.TokenRequest; import net.openid.appauth.TokenResponse; import okio.Okio; @@ -68,6 +71,7 @@ public class TokenActivity extends AppCompatActivity { private static final String KEY_USER_INFO = "userInfo"; private static final int END_SESSION_REQUEST_CODE = 911; + private static final String EXTRA_AUTH_SERVICE_DISCOVERY = "authServiceDiscovery"; private AuthorizationService mAuthService; private AuthStateManager mStateManager; @@ -325,11 +329,7 @@ private void handleCodeExchangeResponse( } } - /** - * Demonstrates the use of {@link AuthState#performActionWithFreshTokens} to retrieve - * user info from the IDP's user info endpoint. This callback will negotiate a new access - * token / id token for use in a follow-up action, or provide an error if this fails. - */ + @MainThread private void fetchUserInfo() { displayLoading("Fetching user info"); @@ -420,21 +420,69 @@ private void endSession() { startActivityForResult(endSessionEnten, END_SESSION_REQUEST_CODE); } + + static AuthorizationServiceDiscovery getDiscoveryDocFromIntent(Intent intent) { + if (!intent.hasExtra(EXTRA_AUTH_SERVICE_DISCOVERY)) { + return null; + } + String discoveryJson = intent.getStringExtra(EXTRA_AUTH_SERVICE_DISCOVERY); + try { + return new AuthorizationServiceDiscovery(new JSONObject(discoveryJson)); + } catch (JSONException | AuthorizationServiceDiscovery.MissingArgumentException ex) { + throw new IllegalStateException("Malformed JSON in discovery doc"); + } + } + @MainThread private void signOut() { - // discard the authorization and token state, but retain the configuration and - // dynamic client registration (if applicable), to save from retrieving them again. - AuthState currentState = mStateManager.getCurrent(); - AuthState clearedState = - new AuthState(currentState.getAuthorizationServiceConfiguration()); - if (currentState.getLastRegistrationResponse() != null) { - clearedState.update(currentState.getLastRegistrationResponse()); +// // discard the authorization and token state, but retain the configuration and +// // dynamic client registration (if applicable), to save from retrieving them again. +// AuthState currentState = mStateManager.getCurrent(); +// AuthState clearedState = +// new AuthState(currentState.getAuthorizationServiceConfiguration()); +// if (currentState.getLastRegistrationResponse() != null) { +// clearedState.update(currentState.getLastRegistrationResponse()); +// } +// mStateManager.replace(clearedState); +// +// Intent mainIntent = new Intent(this, LoginActivity.class); +// mainIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); +// startActivity(mainIntent); +// finish(); + + AuthState mAuthState = mStateManager.getCurrent(); + + if (mAuthState.getAuthorizationServiceConfiguration() == null) { + Log.e(TAG, "Cannot make userInfo request without service configuration"); } - mStateManager.replace(clearedState); - Intent mainIntent = new Intent(this, LoginActivity.class); - mainIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); - startActivity(mainIntent); - finish(); + mAuthState.performActionWithFreshTokens(mAuthService, new AuthState.AuthStateAction() { + @Override + public void execute(String accessToken, String idToken, AuthorizationException ex) { + if (ex != null) { + Log.e(TAG, "Token refresh failed when fetching user info"); + return; + } + + AuthorizationServiceDiscovery discoveryDoc = getDiscoveryDocFromIntent(getIntent()); + if (discoveryDoc == null) { + throw new IllegalStateException("no available discovery doc"); + } + + Uri endSessionEndpoint = Uri.parse(discoveryDoc.getEndSessionEndpoint().toString()); + + String logoutUri = getResources().getString(R.string.keycloak_auth_logout_uri); + LogoutRequest logoutRequest = new LogoutRequest(endSessionEndpoint, + Uri.parse(logoutUri)); + + LogoutService logoutService = new LogoutService(TokenActivity.this); + logoutService.performLogoutRequest( + logoutRequest, + PendingIntent.getActivity( + TokenActivity.this, logoutRequest.hashCode(), + new Intent(TokenActivity.this, LoginActivity.class), 0) + ); + } + }); } } diff --git a/app/res/raw/auth_config.json b/app/res/raw/auth_config.json index 1796efab..12cb9d6f 100644 --- a/app/res/raw/auth_config.json +++ b/app/res/raw/auth_config.json @@ -1,12 +1,13 @@ { - "client_id": "", - "redirect_uri": "net.openid.appauthdemo:/oauth2redirect", - "end_session_uri":"", + "client_id": "truMobile_Android", + "prompt": "login", + "redirect_uri": "net.openid.appauthdemo:/oauthredirect", + "end_session_uri":"https://secure.trufla.dev/auth/realms/sharp/protocol/openid-connect/logout", "authorization_scope": "openid email profile", "discovery_uri": "", - "authorization_endpoint_uri": "", - "token_endpoint_uri": "", - "registration_endpoint_uri": "", - "user_info_endpoint_uri": "", + "authorization_endpoint_uri": "https://secure.trufla.dev/auth/realms/Sharp/protocol/openid-connect/auth", + "token_endpoint_uri": "https://secure.trufla.dev/auth/realms/Sharp/protocol/openid-connect/token", + "registration_endpoint_uri": "https://secure.trufla.dev/auth/realms/Sharp/clients-registrations/openid-connect", + "user_info_endpoint_uri": "https://secure.trufla.dev/auth/realms/Sharp/protocol/openid-connect/userinfo", "https_required": true } diff --git a/app/res/values/idp_configs.xml b/app/res/values/idp_configs.xml new file mode 100644 index 00000000..54e2ae3d --- /dev/null +++ b/app/res/values/idp_configs.xml @@ -0,0 +1,26 @@ + + + + + + false + YOUR_ID.apps.googleusercontent.com + true + + demo-test + + com.googleusercontent.apps.YOUR_ID + com.googleusercontent.apps.YOUR_ID:/oauth2redirect + com.test.demo + com.test.logout + com.test.demo:/oauth2Callback + "com.test.logout:/oauth2Callback" + diff --git a/app/res/values/strings.xml b/app/res/values/strings.xml index 253b9b92..e5a54b7f 100644 --- a/app/res/values/strings.xml +++ b/app/res/values/strings.xml @@ -28,4 +28,5 @@ Access token has expired Authorization settings in use: Use PendingIntent\'s for completion + logout diff --git a/library/AndroidManifest.xml b/library/AndroidManifest.xml index ef6c6050..15991d9b 100644 --- a/library/AndroidManifest.xml +++ b/library/AndroidManifest.xml @@ -28,7 +28,21 @@ - + + + + + + + + + + + + diff --git a/library/java/net/openid/appauth/AuthorizationServiceDiscovery.java b/library/java/net/openid/appauth/AuthorizationServiceDiscovery.java index c304eee0..a9cd7974 100644 --- a/library/java/net/openid/appauth/AuthorizationServiceDiscovery.java +++ b/library/java/net/openid/appauth/AuthorizationServiceDiscovery.java @@ -617,4 +617,6 @@ private static StringListField strList(String key, List defaults) { private static BooleanField bool(String key, boolean defaultValue) { return new BooleanField(key, defaultValue); } + + } diff --git a/library/java/net/openid/appauth/BrowserHandler.java b/library/java/net/openid/appauth/BrowserHandler.java new file mode 100644 index 00000000..85b0e820 --- /dev/null +++ b/library/java/net/openid/appauth/BrowserHandler.java @@ -0,0 +1,123 @@ +package net.openid.appauth; + +import android.content.ComponentName; +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.browser.customtabs.CustomTabsClient; +import androidx.browser.customtabs.CustomTabsIntent; +import androidx.browser.customtabs.CustomTabsServiceConnection; +import androidx.browser.customtabs.CustomTabsSession; + +import net.openid.appauth.internal.Logger; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Created by Marina Wageed on 22,September,2020 + * Trufla Technology, + * Cairo, Egypt. + */ +class BrowserHandler +{ + + /** + * Wait for at most this amount of time for the browser connection to be established. + */ + private static final long CLIENT_WAIT_TIME = 1L; + + @NonNull + private final Context mContext; + + @NonNull + private final String mBrowserPackage; + + @Nullable + private final CustomTabsServiceConnection mConnection; + + @NonNull + private final AtomicReference mClient; + + @NonNull + private final CountDownLatch mClientLatch; + + BrowserHandler(@NonNull Context context) { + mContext = context; + mBrowserPackage = BrowserPackageHelper.getInstance().getPackageNameToUse(context); + mClient = new AtomicReference<>(); + mClientLatch = new CountDownLatch(1); + mConnection = bindCustomTabsService(); + } + + private CustomTabsServiceConnection bindCustomTabsService() { + CustomTabsServiceConnection connection = new CustomTabsServiceConnection() { + @Override + public void onServiceDisconnected(ComponentName componentName) { + Logger.debug("CustomTabsService is disconnected"); + setClient(null); + } + + @Override + public void onCustomTabsServiceConnected(ComponentName componentName, + CustomTabsClient customTabsClient) { + Logger.debug("CustomTabsService is connected"); + customTabsClient.warmup(0); + setClient(customTabsClient); + } + + private void setClient(@Nullable CustomTabsClient client) { + mClient.set(client); + mClientLatch.countDown(); + } + }; + + if (!CustomTabsClient.bindCustomTabsService( + mContext, + mBrowserPackage, + connection)) { + // this is expected if the browser does not support custom tabs + Logger.info("Unable to bind custom tabs service"); + mClientLatch.countDown(); + return null; + } + + return connection; + } + + public CustomTabsIntent.Builder createCustomTabsIntentBuilder() { + return new CustomTabsIntent.Builder(createSession()); + } + + public String getBrowserPackage() { + return mBrowserPackage; + } + + public void unbind() { + if (mConnection == null) { + return; + } + + mContext.unbindService(mConnection); + mClient.set(null); + Logger.debug("CustomTabsService is disconnected"); + } + + private CustomTabsSession createSession() { + try { + mClientLatch.await(CLIENT_WAIT_TIME, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Logger.info("Interrupted while waiting for browser connection"); + mClientLatch.countDown(); + } + + CustomTabsClient client = mClient.get(); + if (client != null) { + return client.newSession(null); + } + + return null; + } +} diff --git a/library/java/net/openid/appauth/BrowserPackageHelper.java b/library/java/net/openid/appauth/BrowserPackageHelper.java new file mode 100644 index 00000000..ab63b755 --- /dev/null +++ b/library/java/net/openid/appauth/BrowserPackageHelper.java @@ -0,0 +1,151 @@ +package net.openid.appauth; + +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.net.Uri; + +import androidx.annotation.VisibleForTesting; + +import java.util.Iterator; +import java.util.List; + +/** + * Created by Marina Wageed on 22,September,2020 + * Trufla Technology, + * Cairo, Egypt. + */ +class BrowserPackageHelper +{ + private static final String SCHEME_HTTP = "http"; + private static final String SCHEME_HTTPS = "https"; + + /** + * The service we expect to find on a web browser that indicates it supports custom tabs. + */ + @VisibleForTesting + static final String ACTION_CUSTOM_TABS_CONNECTION = + "android.support.customtabs.action.CustomTabsService"; + + /** + * An arbitrary (but unregistrable, per + * IANA rules) web intent used to query + * for installed web browsers on the system. + */ + @VisibleForTesting + static final Intent BROWSER_INTENT = new Intent( + Intent.ACTION_VIEW, + Uri.parse("http://www.example.com")); + + private static BrowserPackageHelper sInstance; + + public static synchronized BrowserPackageHelper getInstance() { + if (sInstance == null) { + sInstance = new BrowserPackageHelper(); + } + return sInstance; + } + + @VisibleForTesting + static synchronized void clearInstance() { + sInstance = null; + } + + private String mPackageNameToUse; + + private BrowserPackageHelper() {} + + /** + * Searches through all apps that handle VIEW intents and have a warmup service. Picks + * the one chosen by the user if this choice has been made, otherwise any browser with a warmup + * service is returned. If no browser has a warmup service, the default browser will be + * returned. If no default browser has been chosen, an arbitrary browser package is returned. + * + *

This is not threadsafe. + * + * @param context {@link Context} to use for accessing {@link PackageManager}. + * @return The package name recommended to use for connecting to custom tabs related components. + */ + public String getPackageNameToUse(Context context) { + if (mPackageNameToUse != null) { + return mPackageNameToUse; + } + + PackageManager pm = context.getPackageManager(); + + // retrieve a list of all the matching handlers for the browser intent. + // queryIntentActivities will ensure that these are priority ordered, with the default + // (if set) as the first entry. Ignoring any matches which are not "full" browsers, + // pick the first that supports custom tabs, or the first full browser otherwise. + ResolveInfo firstMatch = null; + List resolvedActivityList = + pm.queryIntentActivities(BROWSER_INTENT, PackageManager.GET_RESOLVED_FILTER); + + for (ResolveInfo info : resolvedActivityList) { + // ignore handlers which are not browers + if (!isFullBrowser(info)) { + continue; + } + + // we hold the first non-default browser as the default browser to use, if we do + // not find any that support a warmup service. + if (firstMatch == null) { + firstMatch = info; + } + + if (hasWarmupService(pm, info.activityInfo.packageName)) { + // we have found a browser with a warmup service, return it + mPackageNameToUse = info.activityInfo.packageName; + return mPackageNameToUse; + } + } + + // No handlers have a warmup service, so we return the first match (typically the + // default browser), or null if there are no identifiable browsers. + if (firstMatch != null) { + mPackageNameToUse = firstMatch.activityInfo.packageName; + } else { + mPackageNameToUse = null; + } + return mPackageNameToUse; + } + + private boolean hasWarmupService(PackageManager pm, String packageName) { + Intent serviceIntent = new Intent(); + serviceIntent.setAction(ACTION_CUSTOM_TABS_CONNECTION); + serviceIntent.setPackage(packageName); + return (pm.resolveService(serviceIntent, 0) != null); + } + + public boolean isFullBrowser(ResolveInfo resolveInfo) { + // The filter must match ACTION_VIEW, CATEGORY_BROWSEABLE, and at least one scheme, + if (!resolveInfo.filter.hasAction(Intent.ACTION_VIEW) + || !resolveInfo.filter.hasCategory(Intent.CATEGORY_BROWSABLE) + || resolveInfo.filter.schemesIterator() == null) { + return false; + } + + // The filter must not be restricted to any particular set of authorities + if (resolveInfo.filter.authoritiesIterator() != null) { + return false; + } + + // The filter must support both HTTP and HTTPS. + boolean supportsHttp = false; + boolean supportsHttps = false; + Iterator schemeIter = resolveInfo.filter.schemesIterator(); + while (schemeIter.hasNext()) { + String scheme = schemeIter.next(); + supportsHttp |= SCHEME_HTTP.equals(scheme); + supportsHttps |= SCHEME_HTTPS.equals(scheme); + + if (supportsHttp && supportsHttps) { + return true; + } + } + + // at least one of HTTP or HTTPS is not supported + return false; + } +} diff --git a/library/java/net/openid/appauth/LogoutRequest.java b/library/java/net/openid/appauth/LogoutRequest.java new file mode 100644 index 00000000..6f964d81 --- /dev/null +++ b/library/java/net/openid/appauth/LogoutRequest.java @@ -0,0 +1,30 @@ +package net.openid.appauth; + +import android.net.Uri; +import androidx.annotation.VisibleForTesting; + +/** + * Created by Marina Wageed on 22,September,2020 + * Trufla Technology, + * Cairo, Egypt. + */ + +public class LogoutRequest +{ + @VisibleForTesting + static final String PARAM_REDIRECT_URI = "redirect_uri"; + + private Uri logoutEndpoint; + private Uri redirectUri; + + public LogoutRequest(Uri logoutEndpoint, Uri redirectUri) { + this.logoutEndpoint = logoutEndpoint; + this.redirectUri = redirectUri; + } + + public Uri toUri() { + Uri.Builder uriBuilder = logoutEndpoint.buildUpon() + .appendQueryParameter(PARAM_REDIRECT_URI, redirectUri.toString()); + return uriBuilder.build(); + } +} diff --git a/library/java/net/openid/appauth/LogoutService.java b/library/java/net/openid/appauth/LogoutService.java new file mode 100644 index 00000000..c4b6b798 --- /dev/null +++ b/library/java/net/openid/appauth/LogoutService.java @@ -0,0 +1,90 @@ +package net.openid.appauth; + +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; +import androidx.browser.customtabs.CustomTabsIntent; + +import net.openid.appauth.internal.Logger; + +import static net.openid.appauth.Preconditions.checkNotNull; + +/** + * Created by Marina Wageed on 22,September,2020 + * Trufla Technology, + * Cairo, Egypt. + */ + +public class LogoutService +{ + @VisibleForTesting + Context mContext; + + @NonNull + private final BrowserHandler mBrowserHandler; + + private boolean mDisposed = false; + + public LogoutService(@NonNull Context context) { + this(context, new BrowserHandler(context)); + } + + @VisibleForTesting + LogoutService(@NonNull Context context, + @NonNull BrowserHandler browserHandler) { + mContext = checkNotNull(context); + mBrowserHandler = checkNotNull(browserHandler); + } + + public CustomTabsIntent.Builder createCustomTabsIntentBuilder() { + checkNotDisposed(); + return mBrowserHandler.createCustomTabsIntentBuilder(); + } + + public void performLogoutRequest( + @NonNull LogoutRequest request, + @NonNull PendingIntent resultHandlerIntent) { + performLogoutRequest(request, + resultHandlerIntent, + createCustomTabsIntentBuilder().build()); + } + + public void performLogoutRequest( + @NonNull LogoutRequest request, + @NonNull PendingIntent resultHandlerIntent, + @NonNull CustomTabsIntent customTabsIntent) { + checkNotDisposed(); + Uri requestUri = request.toUri(); + PendingLogoutIntentStore.getInstance().addPendingIntent(request, resultHandlerIntent); + Intent intent = customTabsIntent.intent; + intent.setData(requestUri); + if (TextUtils.isEmpty(intent.getPackage())) { + intent.setPackage(mBrowserHandler.getBrowserPackage()); + } + + Logger.debug("Using %s as browser for auth", intent.getPackage()); + intent.putExtra(CustomTabsIntent.EXTRA_TITLE_VISIBILITY_STATE, CustomTabsIntent.NO_TITLE); + intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY); + + mContext.startActivity(intent); + } + + public void dispose() { + if (mDisposed) { + return; + } + mBrowserHandler.unbind(); + mDisposed = true; + } + + private void checkNotDisposed() { + if (mDisposed) { + throw new IllegalStateException("Service has been disposed and rendered inoperable"); + } + } +} diff --git a/library/java/net/openid/appauth/LogoutUriReceiverActivity.java b/library/java/net/openid/appauth/LogoutUriReceiverActivity.java new file mode 100644 index 00000000..23e23536 --- /dev/null +++ b/library/java/net/openid/appauth/LogoutUriReceiverActivity.java @@ -0,0 +1,60 @@ +package net.openid.appauth; + +import android.app.Activity; +import android.app.PendingIntent; +import android.content.Intent; +import android.os.Bundle; + +import net.openid.appauth.internal.Logger; + +/** + * Created by Marina Wageed on 22,September,2020 + * Trufla Technology, + * Cairo, Egypt. + */ + + +class LogoutUriReceiverActivity extends Activity +{ + private static final String KEY_STATE = "state"; + private Clock mClock = SystemClock.INSTANCE; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + Intent intent = getIntent(); + // Uri data = intent.getData(); + // String state = data.getQueryParameter(KEY_STATE); + + LogoutRequest request = PendingLogoutIntentStore.getInstance().getOriginalRequest(); + PendingIntent target = PendingLogoutIntentStore.getInstance().getPendingIntent(); + + /* + Intent responseData; + if (data.getQueryParameterNames().contains(AuthorizationException.PARAM_ERROR)) { + String error = data.getQueryParameter(AuthorizationException.PARAM_ERROR); + AuthorizationException ex = AuthorizationException.fromOAuthTemplate( + AuthorizationException.AuthorizationRequestErrors.byString(error), + error, + data.getQueryParameter(AuthorizationException.PARAM_ERROR_DESCRIPTION), + UriUtil.parseUriIfAvailable( + data.getQueryParameter(AuthorizationException.PARAM_ERROR_URI))); + responseData = ex.toIntent(); + } else { + AuthorizationResponse response = new AuthorizationResponse.Builder(request) + .fromUri(data, mClock) + .build(); + responseData = response.toIntent(); + } + */ + + Logger.debug("Forwarding redirect"); + try { + target.send(this, 0, null); + } catch (PendingIntent.CanceledException e) { + Logger.errorWithStack(e, "Unable to send pending intent"); + } + + finish(); + } +} diff --git a/library/java/net/openid/appauth/PendingLogoutIntentStore.java b/library/java/net/openid/appauth/PendingLogoutIntentStore.java new file mode 100644 index 00000000..c8b3e490 --- /dev/null +++ b/library/java/net/openid/appauth/PendingLogoutIntentStore.java @@ -0,0 +1,42 @@ +package net.openid.appauth; + +import android.app.PendingIntent; + +import java.util.LinkedList; +import java.util.Queue; + +/** + * Created by Marina Wageed on 22,September,2020 + * Trufla Technology, + * Cairo, Egypt. + */ +class PendingLogoutIntentStore +{ + private Queue mRequests = new LinkedList<>(); + private Queue mPendingIntents = new LinkedList<>(); + + private static PendingLogoutIntentStore sInstance; + + private PendingLogoutIntentStore() { + } + + public static synchronized PendingLogoutIntentStore getInstance() { + if (sInstance == null) { + sInstance = new PendingLogoutIntentStore(); + } + return sInstance; + } + + public void addPendingIntent(LogoutRequest request, PendingIntent intent) { + mRequests.add(request); + mPendingIntents.add(intent); + } + + public LogoutRequest getOriginalRequest() { + return mRequests.poll(); + } + + public PendingIntent getPendingIntent() { + return mPendingIntents.poll(); + } +} From 1365cfd76ba439c0d55e44b9176caaf03412bdab Mon Sep 17 00:00:00 2001 From: marina Date: Tue, 22 Sep 2020 14:42:00 +0200 Subject: [PATCH 7/7] chnage redirect schema --- library/AndroidManifest.xml | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/library/AndroidManifest.xml b/library/AndroidManifest.xml index ef6c6050..8c1f7b76 100644 --- a/library/AndroidManifest.xml +++ b/library/AndroidManifest.xml @@ -15,22 +15,22 @@ - + - - + + - - - - - - - - - + + + + + + + + +