From 72d46c7aab54f514591901505e639fe94b683bf3 Mon Sep 17 00:00:00 2001 From: Victor Solevic Date: Fri, 12 Nov 2021 16:26:01 +0100 Subject: [PATCH 1/3] Add Device Authorization Grant implementation --- .../java/net/openid/appauth/AuthState.java | 243 ++++++++- .../appauth/AuthorizationException.java | 97 +++- .../openid/appauth/AuthorizationRequest.java | 2 +- .../openid/appauth/AuthorizationService.java | 298 ++++++++++- .../AuthorizationServiceConfiguration.java | 41 +- .../AuthorizationServiceDiscovery.java | 10 + .../appauth/CancelAsyncTaskRunnable.java | 41 ++ .../appauth/DeviceAuthorizationRequest.java | 345 +++++++++++++ .../appauth/DeviceAuthorizationResponse.java | 461 ++++++++++++++++++ .../net/openid/appauth/GrantTypeValues.java | 8 + .../java/net/openid/appauth/TokenRequest.java | 46 ++ 11 files changed, 1579 insertions(+), 13 deletions(-) create mode 100644 library/java/net/openid/appauth/CancelAsyncTaskRunnable.java create mode 100644 library/java/net/openid/appauth/DeviceAuthorizationRequest.java create mode 100644 library/java/net/openid/appauth/DeviceAuthorizationResponse.java diff --git a/library/java/net/openid/appauth/AuthState.java b/library/java/net/openid/appauth/AuthState.java index e80830a5..35e26202 100644 --- a/library/java/net/openid/appauth/AuthState.java +++ b/library/java/net/openid/appauth/AuthState.java @@ -52,6 +52,8 @@ public class AuthState { private static final String KEY_REFRESH_TOKEN = "refreshToken"; private static final String KEY_SCOPE = "scope"; private static final String KEY_LAST_AUTHORIZATION_RESPONSE = "lastAuthorizationResponse"; + private static final String KEY_LAST_DEVICE_AUTHORIZATION_RESPONSE = + "lastDeviceAuthorizationResponse"; private static final String KEY_LAST_TOKEN_RESPONSE = "mLastTokenResponse"; private static final String KEY_AUTHORIZATION_EXCEPTION = "mAuthorizationException"; private static final String KEY_LAST_REGISTRATION_RESPONSE = "lastRegistrationResponse"; @@ -68,6 +70,9 @@ public class AuthState { @Nullable private AuthorizationResponse mLastAuthorizationResponse; + @Nullable + private DeviceAuthorizationResponse mLastDeviceAuthorizationResponse; + @Nullable private TokenResponse mLastTokenResponse; @@ -77,6 +82,9 @@ public class AuthState { @Nullable private AuthorizationException mAuthorizationException; + @Nullable + private CancelAsyncTaskRunnable mCancelTokenPollingTask; + private final Object mPendingActionsSyncObject = new Object(); private List mPendingActions; private boolean mNeedsTokenRefreshOverride; @@ -124,6 +132,15 @@ public AuthState( update(tokenResponse, authException); } + /** + * Creates an {@link AuthState} based on a device authorization exchange. + */ + public AuthState( + @NonNull DeviceAuthorizationResponse deviceAuthResponse, + @Nullable AuthorizationException authException) { + update(deviceAuthResponse, authException); + } + /** * The most recent refresh token received from the server, if available. Rather than using * this property directly as part of any request depending on authorization state, it is @@ -166,6 +183,22 @@ public AuthorizationResponse getLastAuthorizationResponse() { return mLastAuthorizationResponse; } + /** + * The most recent device authorization response used to update the authorization state. For + * the device authorization flow, this will contain the device code, the complete and simple + * verification uris. It is rarely necessary to directly use the response; instead convenience + * methods are provided to retrieve the + * {@link #getVerificationUri() verification uri}, + * {@link #getVerificationUriComplete() complete verification uri}, + * {@link #getUserCode() user code}, + * {@link #getCodeExpirationTime() code expiration time} + * and {@link #getScopeSet() scope} regardless of the flow used to retrieve them. + */ + @Nullable + public DeviceAuthorizationResponse getLastDeviceAuthorizationResponse() { + return mLastDeviceAuthorizationResponse; + } + /** * The most recent token response used to update this authorization state. For the * authorization code flow, this will contain the latest access token. It is rarely necessary @@ -203,6 +236,10 @@ public AuthorizationServiceConfiguration getAuthorizationServiceConfiguration() return mLastAuthorizationResponse.request.configuration; } + if (mLastDeviceAuthorizationResponse != null) { + return mLastDeviceAuthorizationResponse.request.configuration; + } + return mConfig; } @@ -295,6 +332,103 @@ public Long getClientSecretExpirationTime() { return null; } + /** + * The current end-user device verification URI, if available. + */ + @Nullable + public String getVerificationUri() { + if (mLastDeviceAuthorizationResponse != null) { + return mLastDeviceAuthorizationResponse.verificationUri; + } + + return null; + } + + /** + * The current end-user complete device verification URI with the user code, if available. + */ + @Nullable + public String getVerificationUriComplete() { + if (mLastDeviceAuthorizationResponse != null) { + return mLastDeviceAuthorizationResponse.verificationUriComplete; + } + + return null; + } + + /** + * The current end-user verification code, if available. + */ + @Nullable + public String getUserCode() { + if (mLastDeviceAuthorizationResponse != null) { + return mLastDeviceAuthorizationResponse.userCode; + } + + return null; + } + + /** + * The expiration time of the current device code and user code (if available), as milliseconds + * from the UNIX epoch (consistent with {@link System#currentTimeMillis()}). + */ + @Nullable + public Long getCodeExpirationTime() { + if (mLastDeviceAuthorizationResponse != null) { + return mLastDeviceAuthorizationResponse.codeExpirationTime; + } + + return null; + } + + /** + * The current {@link AuthorizationServiceConfiguration}, if available. + */ + @Nullable + public AuthorizationServiceConfiguration getConfiguration() { + if (mAuthorizationException != null) { + return null; + } + + if (mLastTokenResponse != null) { + return mLastTokenResponse.request.configuration; + } + + if (mLastDeviceAuthorizationResponse != null) { + return mLastDeviceAuthorizationResponse.request.configuration; + } + + if (mLastAuthorizationResponse != null) { + return mLastAuthorizationResponse.request.configuration; + } + + return null; + } + + /** + * The current client id, if available. + */ + @Nullable + public String getClientId() { + if (mAuthorizationException != null) { + return null; + } + + if (mLastTokenResponse != null) { + return mLastTokenResponse.request.clientId; + } + + if (mLastDeviceAuthorizationResponse != null) { + return mLastDeviceAuthorizationResponse.request.clientId; + } + + if (mLastAuthorizationResponse != null) { + return mLastAuthorizationResponse.request.clientId; + } + + return null; + } + /** * Determines whether the current state represents a successful authorization, * from which at least either an access token or an ID token have been retrieved. @@ -383,6 +517,7 @@ public void update( // the last token response and refresh token are now stale, as they are associated with // any previous authorization response mLastAuthorizationResponse = authResponse; + mLastDeviceAuthorizationResponse = null; mConfig = null; mLastTokenResponse = null; mRefreshToken = null; @@ -393,6 +528,32 @@ public void update( mScope = (authResponse.scope != null) ? authResponse.scope : authResponse.request.scope; } + /** + * Updates the authorization state based on a new device authorization response. + */ + public void update( + @Nullable DeviceAuthorizationResponse deviceAuthResponse, + @Nullable AuthorizationException authException) { + checkArgument(deviceAuthResponse != null ^ authException != null, + "exactly one of deviceAuthResponse or authException should be non-null"); + if (authException != null) { + if (authException.type == AuthorizationException.TYPE_OAUTH_TOKEN_DEVICE_CODE_ERROR) { + mAuthorizationException = authException; + } + return; + } + + // the last token response and refresh token are now stale, as they are associated with + // any previous authorization response + mLastDeviceAuthorizationResponse = deviceAuthResponse; + mLastAuthorizationResponse = null; + mConfig = null; + mLastTokenResponse = null; + mRefreshToken = null; + mAuthorizationException = null; + mScope = deviceAuthResponse.request.scope; + } + /** * Updates the authorization state based on a new token response. */ @@ -443,6 +604,7 @@ public void update(@Nullable RegistrationResponse regResponse) { mRefreshToken = null; mScope = null; mLastAuthorizationResponse = null; + mLastDeviceAuthorizationResponse = null; mLastTokenResponse = null; mAuthorizationException = null; } @@ -610,14 +772,19 @@ public TokenRequest createTokenRefreshRequest( if (mRefreshToken == null) { throw new IllegalStateException("No refresh token available for refresh request"); } - if (mLastAuthorizationResponse == null) { + + AuthorizationServiceConfiguration configuration = getConfiguration(); + if (configuration == null) { throw new IllegalStateException( "No authorization configuration available for refresh request"); } - return new TokenRequest.Builder( - mLastAuthorizationResponse.request.configuration, - mLastAuthorizationResponse.request.clientId) + String clientId = getClientId(); + if (clientId == null) { + throw new IllegalStateException("No client id available for refresh request"); + } + + return new TokenRequest.Builder(configuration, clientId) .setGrantType(GrantTypeValues.REFRESH_TOKEN) .setScope(null) .setRefreshToken(mRefreshToken) @@ -625,6 +792,62 @@ public TokenRequest createTokenRefreshRequest( .build(); } + /** + * Performs a token request through a polling sequence. + */ + public void performTokenPollRequest( + @NonNull final AuthorizationService service, + @NonNull final AuthorizationService.TokenResponseCallback callback) { + try { + performTokenPollRequest(service, getClientAuthentication(), callback); + } catch (ClientAuthentication.UnsupportedAuthenticationMethod ex) { + callback.onTokenRequestCompleted(null, AuthorizationException.fromTemplate( + AuthorizationException.DeviceCodeRequestErrors.CLIENT_ERROR, ex)); + } + } + + /** + * Performs a token request through a polling sequence. + */ + public void performTokenPollRequest( + @NonNull final AuthorizationService service, + @NonNull final ClientAuthentication clientAuth, + @NonNull final AuthorizationService.TokenResponseCallback callback) { + performTokenPollRequest(service, clientAuth, Collections.emptyMap(), callback); + } + + /** + * Performs a token request through a polling sequence. + */ + public void performTokenPollRequest( + @NonNull final AuthorizationService service, + @NonNull final ClientAuthentication clientAuth, + @NonNull final Map additionalParameters, + @NonNull final AuthorizationService.TokenResponseCallback callback) { + if (mLastDeviceAuthorizationResponse == null) { + AuthorizationException ex = AuthorizationException.fromTemplate( + AuthorizationException.DeviceCodeRequestErrors.CLIENT_ERROR, + new IllegalStateException("No device authorization available")); + callback.onTokenRequestCompleted(null, ex); + return; + } + + mCancelTokenPollingTask = service.performTokenPollRequest( + mLastDeviceAuthorizationResponse.createTokenExchangeRequest(additionalParameters), + clientAuth, + mLastDeviceAuthorizationResponse.tokenPollingIntervalTime, + mLastDeviceAuthorizationResponse.codeExpirationTime, + callback); + } + + public void cancelTokenPoll() { + if (mCancelTokenPollingTask == null) { + return; + } + + mCancelTokenPollingTask.run(); + } + /** * Produces a JSON representation of the authorization state for persistent storage or local * transmission (e.g. between activities). @@ -649,6 +872,13 @@ public JSONObject jsonSerialize() { mLastAuthorizationResponse.jsonSerialize()); } + if (mLastDeviceAuthorizationResponse != null) { + JsonUtil.put( + json, + KEY_LAST_DEVICE_AUTHORIZATION_RESPONSE, + mLastDeviceAuthorizationResponse.jsonSerialize()); + } + if (mLastTokenResponse != null) { JsonUtil.put( json, @@ -702,6 +932,11 @@ public static AuthState jsonDeserialize(@NonNull JSONObject json) throws JSONExc json.getJSONObject(KEY_LAST_AUTHORIZATION_RESPONSE)); } + if (json.has(KEY_LAST_DEVICE_AUTHORIZATION_RESPONSE)) { + state.mLastDeviceAuthorizationResponse = DeviceAuthorizationResponse.jsonDeserialize( + json.getJSONObject(KEY_LAST_DEVICE_AUTHORIZATION_RESPONSE)); + } + if (json.has(KEY_LAST_TOKEN_RESPONSE)) { state.mLastTokenResponse = TokenResponse.jsonDeserialize( json.getJSONObject(KEY_LAST_TOKEN_RESPONSE)); diff --git a/library/java/net/openid/appauth/AuthorizationException.java b/library/java/net/openid/appauth/AuthorizationException.java index e2057ce7..664b2171 100644 --- a/library/java/net/openid/appauth/AuthorizationException.java +++ b/library/java/net/openid/appauth/AuthorizationException.java @@ -125,6 +125,15 @@ public final class AuthorizationException extends Exception { */ public static final int TYPE_OAUTH_REGISTRATION_ERROR = 4; + /** + * The error type for OAuth specific errors on the token endpoint while using the + * {@link GrantTypeValues#DEVICE_CODE} grant type. + * + * @see "OAuth 2.0 Device Grant" (RFC 8628), Section 3.5 + * " + */ + public static final int TYPE_OAUTH_TOKEN_DEVICE_CODE_ERROR = 5; + @VisibleForTesting static final String KEY_TYPE = "type"; @@ -462,6 +471,78 @@ public static AuthorizationException byString(String error) { } } + /** + * Error codes related to failed token requests with the {@link GrantTypeValues#DEVICE_CODE} + * grant type. + * + * @see "OAuth 2.0 Device Grant" (RFC 8628), Section 3.5 + * " + */ + public static final class DeviceCodeRequestErrors { + // codes in this group should be between 5000-5999 + + /** + * An `authorization_pending` OAuth2 error response. + */ + public static final AuthorizationException AUTHORIZATION_PENDING = + tokenDeviceCodeEx(5000, "authorization_pending"); + + /** + * A `slow_down` OAuth2 error response. + */ + public static final AuthorizationException SLOW_DOWN = + tokenDeviceCodeEx(5001, "slow_down"); + + /** + * An `access_denied` OAuth2 error response. + */ + public static final AuthorizationException ACCESS_DENIED = + tokenDeviceCodeEx(5002, "access_denied"); + + /** + * An `expired_token` OAuth2 error response. + */ + public static final AuthorizationException EXPIRED_TOKEN = + tokenDeviceCodeEx(5003, "expired_token"); + + /** + * An authorization error occurring on the client rather than the server. For example, + * due to client misconfiguration. This error should be treated as unrecoverable. + */ + public static final AuthorizationException CLIENT_ERROR = + tokenDeviceCodeEx(5004, null); + + /** + * Indicates an OAuth error as per RFC 6749, but the error code is not known to the + * AppAuth for Android library. It could be a custom error or code, or one from an + * OAuth extension. The {@link #error} field provides the exact error string returned by + * the server. + */ + public static final AuthorizationException OTHER = + tokenDeviceCodeEx(5005, null); + + private static final Map STRING_TO_EXCEPTION = + exceptionMapByString( + AUTHORIZATION_PENDING, + SLOW_DOWN, + ACCESS_DENIED, + EXPIRED_TOKEN, + CLIENT_ERROR, + OTHER); + + /** + * Returns the matching exception type for the provided OAuth2 error string, or + * {@link #OTHER} if unknown. + */ + public static AuthorizationException byString(String error) { + AuthorizationException ex = STRING_TO_EXCEPTION.get(error); + if (ex != null) { + return ex; + } + return OTHER; + } + } + private static AuthorizationException generalEx(int code, @Nullable String errorDescription) { return new AuthorizationException( TYPE_GENERAL_ERROR, code, null, errorDescription, null, null); @@ -482,10 +563,15 @@ private static AuthorizationException registrationEx(int code, @Nullable String TYPE_OAUTH_REGISTRATION_ERROR, code, error, null, null, null); } + private static AuthorizationException tokenDeviceCodeEx(int code, @Nullable String error) { + return new AuthorizationException( + TYPE_OAUTH_TOKEN_DEVICE_CODE_ERROR, code, error, null, null, null); + } + /** - * Creates an exception based on one of the existing values defined in - * {@link GeneralErrors}, {@link AuthorizationRequestErrors} or {@link TokenRequestErrors}, - * providing a root cause. + * Creates an exception based on one of the existing values defined in {@link GeneralErrors}, + * {@link AuthorizationRequestErrors}, {@link TokenRequestErrors} or + * {@link DeviceCodeRequestErrors} providing a root cause. */ public static AuthorizationException fromTemplate( @NonNull AuthorizationException ex, @@ -501,8 +587,8 @@ public static AuthorizationException fromTemplate( /** * Creates an exception based on one of the existing values defined in - * {@link AuthorizationRequestErrors} or {@link TokenRequestErrors}, adding information - * retrieved from OAuth error response. + * {@link AuthorizationRequestErrors}, {@link TokenRequestErrors} or + * {@link DeviceCodeRequestErrors}, adding information retrieved from OAuth error response. */ public static AuthorizationException fromOAuthTemplate( @NonNull AuthorizationException ex, @@ -604,6 +690,7 @@ private static Map exceptionMapByString( * @see #TYPE_OAUTH_AUTHORIZATION_ERROR * @see #TYPE_OAUTH_TOKEN_ERROR * @see #TYPE_RESOURCE_SERVER_AUTHORIZATION_ERROR + * @see #TYPE_OAUTH_TOKEN_DEVICE_CODE_ERROR */ public final int type; diff --git a/library/java/net/openid/appauth/AuthorizationRequest.java b/library/java/net/openid/appauth/AuthorizationRequest.java index 74829294..05502667 100644 --- a/library/java/net/openid/appauth/AuthorizationRequest.java +++ b/library/java/net/openid/appauth/AuthorizationRequest.java @@ -351,7 +351,7 @@ public static final class ResponseMode { * This configuration specifies how to connect to a particular OAuth provider. * Configurations may be * {@link - * AuthorizationServiceConfiguration#AuthorizationServiceConfiguration(Uri, Uri, Uri, Uri)} + * AuthorizationServiceConfiguration#AuthorizationServiceConfiguration(Uri, 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/AuthorizationService.java b/library/java/net/openid/appauth/AuthorizationService.java index 14e3438c..1347c95c 100644 --- a/library/java/net/openid/appauth/AuthorizationService.java +++ b/library/java/net/openid/appauth/AuthorizationService.java @@ -14,6 +14,8 @@ package net.openid.appauth; +import static net.openid.appauth.AuthorizationException.DeviceCodeRequestErrors.AUTHORIZATION_PENDING; +import static net.openid.appauth.AuthorizationException.DeviceCodeRequestErrors.SLOW_DOWN; import static net.openid.appauth.Preconditions.checkNotNull; import android.annotation.TargetApi; @@ -50,6 +52,7 @@ import java.net.HttpURLConnection; import java.net.URLConnection; import java.util.Map; +import java.util.concurrent.TimeUnit; /** @@ -403,6 +406,23 @@ public Intent getAuthorizationRequestIntent( return getAuthorizationRequestIntent(request, createCustomTabsIntentBuilder().build()); } + /** + * Sends a request to the authorization service as part of a device authorization request. + * The result of this request will be sent to the provided callback handler. + */ + public void performDeviceAuthorizationRequest( + @NonNull DeviceAuthorizationRequest request, + @NonNull DeviceAuthorizationResponseCallback callback) { + checkNotDisposed(); + Logger.debug("Initiating device authorization request to %s", + request.configuration.deviceAuthorizationEndpoint); + new DeviceAuthorizationRequestTask( + request, + mClientConfiguration.getConnectionBuilder(), + callback) + .execute(); + } + /** * Constructs an intent that encapsulates the provided request and custom tabs intent, * and is intended to be launched via {@link Activity#startActivityForResult}. @@ -494,6 +514,57 @@ public void performTokenRequest( .execute(); } + /** + * Performs a polling sequence that sends requests to the authorization service to exchange a + * device code granted as part of a device authorization request for a token. The result of + * this polling sequence will be sent to the provided callback handler. + * + * @return The runnable used to cancel the ongoing polling request + */ + public CancelAsyncTaskRunnable performTokenPollRequest( + @NonNull TokenRequest request, + @Nullable Long pollingInterval, + @NonNull Long expirationTime, + @NonNull TokenResponseCallback callback) { + return performTokenPollRequest( + request, + NoClientAuthentication.INSTANCE, + pollingInterval, + expirationTime, + callback); + } + + /** + * Performs a polling sequence that sends requests to the authorization service to exchange a + * device code granted as part of a device authorization request for a token. The result of + * this polling sequence will be sent to the provided callback handler. + * + * @return The runnable used to cancel the ongoing polling request + */ + public CancelAsyncTaskRunnable performTokenPollRequest( + @NonNull TokenRequest request, + @NonNull ClientAuthentication clientAuthentication, + @Nullable Long pollingInterval, + @Nullable Long expirationTime, + @NonNull TokenResponseCallback callback) { + checkNotDisposed(); + Logger.debug("Initiating device code exchange polling requests to %s", + request.configuration.tokenEndpoint); + TokenRequestPollingTask pollingTask = new TokenRequestPollingTask( + request, + clientAuthentication, + pollingInterval, + expirationTime, + mClientConfiguration.getConnectionBuilder(), + SystemClock.INSTANCE, + callback, + mClientConfiguration.getSkipIssuerHttpsCheck()); + CancelAsyncTaskRunnable cancelPollingTask = new CancelAsyncTaskRunnable( + pollingTask); + pollingTask.execute(); + return cancelPollingTask; + } + /** * Sends a request to the authorization service to dynamically register a client. * The result of this request will be sent to the provided callback handler. @@ -560,6 +631,93 @@ private Intent prepareAuthorizationRequestIntent( return intent; } + static class TokenRequestPollingTask extends TokenRequestTask { + + /** + * As defined in RFC 8628, default interval for polling should be of 5 seconds. + * + * @see "OAuth 2.0 Device Grant" (RFC 8628), Section 3.5 + * " + */ + @VisibleForTesting + public static final Long DEFAULT_INTERVAL = 5L; + + private Clock mClock; + private Long mPollingInterval; + private Long mExpirationTime; + private TokenResponseCallback mCallback; + + TokenRequestPollingTask(TokenRequest request, + @NonNull ClientAuthentication clientAuthentication, + @Nullable Long pollingInterval, + @Nullable Long expirationTime, + @NonNull ConnectionBuilder connectionBuilder, + Clock clock, + TokenResponseCallback callback, + Boolean skipIssuerHttpsCheck) { + super(request, clientAuthentication, connectionBuilder, clock, callback, + skipIssuerHttpsCheck); + mPollingInterval = TimeUnit.SECONDS.toMillis( + pollingInterval != null ? pollingInterval : DEFAULT_INTERVAL); + mExpirationTime = expirationTime; + mClock = clock; + mCallback = callback; + } + + @Override + protected JSONObject doInBackground(Void... voids) { + do { + try { + Thread.sleep(mPollingInterval); + } catch (InterruptedException ex) { + mException = AuthorizationException.fromTemplate( + AuthorizationException.DeviceCodeRequestErrors.CLIENT_ERROR, ex); + return null; + } + + JSONObject json = super.doInBackground(voids); + if (json != null && json.has(AuthorizationException.PARAM_ERROR)) { + AuthorizationException ex; + try { + String error = json.getString(AuthorizationException.PARAM_ERROR); + ex = AuthorizationException.DeviceCodeRequestErrors.byString(error); + if (ex == AUTHORIZATION_PENDING) { + continue; + } else if (ex == SLOW_DOWN) { + mPollingInterval += TimeUnit.SECONDS.toMillis(DEFAULT_INTERVAL); + continue; + } + mException = ex; + } catch (JSONException jsonEx) { + mException = AuthorizationException.fromTemplate( + GeneralErrors.JSON_DESERIALIZATION_ERROR, + jsonEx); + } + } + return json; + } while (mClock.getCurrentTimeMillis() < mExpirationTime && !isCancelled()); + if (mClock.getCurrentTimeMillis() < mExpirationTime) { + mException = AuthorizationException.fromTemplate( + AuthorizationException.DeviceCodeRequestErrors.CLIENT_ERROR, + new InterruptedException()); + } else { + mException = AuthorizationException.fromTemplate( + AuthorizationException.DeviceCodeRequestErrors.EXPIRED_TOKEN, null); + } + return null; + } + + @Override + protected void onCancelled(JSONObject json) { + if (mException == null) { + mException = AuthorizationException.fromTemplate( + AuthorizationException.DeviceCodeRequestErrors.CLIENT_ERROR, + new InterruptedException()); + } + mCallback.onTokenRequestCompleted(null, mException); + } + } + private static class TokenRequestTask extends AsyncTask { @@ -570,7 +728,7 @@ private static class TokenRequestTask private Clock mClock; private boolean mSkipIssuerHttpsCheck; - private AuthorizationException mException; + protected AuthorizationException mException; TokenRequestTask(TokenRequest request, @NonNull ClientAuthentication clientAuthentication, @@ -596,7 +754,6 @@ protected JSONObject doInBackground(Void... voids) { conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); addJsonToAcceptHeader(conn); conn.setDoOutput(true); - Map headers = mClientAuthentication .getRequestHeaders(mRequest.clientId); if (headers != null) { @@ -767,6 +924,7 @@ protected JSONObject doInBackground(Void... voids) { conn.setRequestMethod("POST"); conn.setRequestProperty("Content-Type", "application/json"); conn.setDoOutput(true); + conn.setRequestProperty("Content-Length", String.valueOf(postData.length())); OutputStreamWriter wr = new OutputStreamWriter(conn.getOutputStream()); wr.write(postData); @@ -859,4 +1017,140 @@ public interface RegistrationResponseCallback { void onRegistrationRequestCompleted(@Nullable RegistrationResponse response, @Nullable AuthorizationException ex); } + + private static class DeviceAuthorizationRequestTask + extends AsyncTask { + private DeviceAuthorizationRequest mRequest; + private final ConnectionBuilder mConnectionBuilder; + private DeviceAuthorizationResponseCallback mCallback; + + private AuthorizationException mException; + + DeviceAuthorizationRequestTask(DeviceAuthorizationRequest request, + ConnectionBuilder connectionBuilder, + DeviceAuthorizationResponseCallback callback) { + mRequest = request; + mConnectionBuilder = connectionBuilder; + mCallback = callback; + } + + @Override + protected JSONObject doInBackground(Void... voids) { + InputStream is = null; + try { + HttpURLConnection conn = mConnectionBuilder.openConnection( + mRequest.configuration.deviceAuthorizationEndpoint); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); + addJsonToAcceptHeader(conn); + conn.setDoOutput(true); + + Map parameters = mRequest.getRequestParameters(); + + String queryData = UriUtil.formUrlEncode(parameters); + conn.setRequestProperty("Content-Length", String.valueOf(queryData.length())); + OutputStreamWriter wr = new OutputStreamWriter(conn.getOutputStream()); + + wr.write(queryData); + wr.flush(); + + if (conn.getResponseCode() >= HttpURLConnection.HTTP_OK + && conn.getResponseCode() < HttpURLConnection.HTTP_MULT_CHOICE) { + is = conn.getInputStream(); + } else { + is = conn.getErrorStream(); + } + String response = Utils.readInputStream(is); + return new JSONObject(response); + } catch (IOException ex) { + Logger.debugWithStack(ex, "Failed to complete device authorization request"); + mException = AuthorizationException.fromTemplate( + GeneralErrors.NETWORK_ERROR, ex); + } catch (JSONException ex) { + Logger.debugWithStack(ex, "Failed to complete device authorization request"); + mException = AuthorizationException.fromTemplate( + GeneralErrors.JSON_DESERIALIZATION_ERROR, ex); + } finally { + Utils.closeQuietly(is); + } + return null; + } + + @Override + protected void onPostExecute(JSONObject json) { + if (mException != null) { + mCallback.onDeviceAuthorizationRequestCompleted(null, mException); + return; + } + + if (json.has(AuthorizationException.PARAM_ERROR)) { + AuthorizationException ex; + try { + String error = json.getString(AuthorizationException.PARAM_ERROR); + ex = AuthorizationException.fromOAuthTemplate( + AuthorizationException.AuthorizationRequestErrors.byString(error), + error, + json.getString(AuthorizationException.PARAM_ERROR_DESCRIPTION), + UriUtil.parseUriIfAvailable( + json.getString(AuthorizationException.PARAM_ERROR_URI))); + } catch (JSONException jsonEx) { + ex = AuthorizationException.fromTemplate( + GeneralErrors.JSON_DESERIALIZATION_ERROR, + jsonEx); + } + mCallback.onDeviceAuthorizationRequestCompleted(null, ex); + return; + } + + DeviceAuthorizationResponse response; + try { + response = new DeviceAuthorizationResponse.Builder(mRequest) + .fromResponseJson(json).build(); + } catch (JSONException jsonEx) { + mCallback.onDeviceAuthorizationRequestCompleted(null, + AuthorizationException.fromTemplate( + GeneralErrors.JSON_DESERIALIZATION_ERROR, + jsonEx)); + return; + } + Logger.debug("Device authorization request with %s completed", + mRequest.configuration.deviceAuthorizationEndpoint); + mCallback.onDeviceAuthorizationRequestCompleted(response, null); + } + + /** + * GitHub will only return a spec-compliant response if JSON is explicitly defined + * as an acceptable response type. As this is essentially harmless for all other + * spec-compliant IDPs, we add this header if no existing Accept header has been set + * by the connection builder. + */ + private void addJsonToAcceptHeader(URLConnection conn) { + if (TextUtils.isEmpty(conn.getRequestProperty("Accept"))) { + conn.setRequestProperty("Accept", "application/json"); + } + } + } + + /** + * Callback interface for device authorization requests. + * + * @see AuthorizationService#performDeviceAuthorizationRequest + */ + public interface DeviceAuthorizationResponseCallback { + /** + * Invoked when the request completes successfully or fails. + * + * Exactly one of `response` or `ex` will be non-null. If `response` is `null`, a failure + * occurred during the request. This can happen if an invalid URI was provided, no + * connection to the server could be established, or the response JSON was incomplete or + * incorrectly formatted. + * + * @param response the retrieved device authorization response, if successful; `null` + * otherwise. + * @param ex a description of the failure, if one occurred: `null` otherwise. + * @see AuthorizationException.DeviceCodeRequestErrors + */ + void onDeviceAuthorizationRequestCompleted(@Nullable DeviceAuthorizationResponse response, + @Nullable AuthorizationException ex); + } } diff --git a/library/java/net/openid/appauth/AuthorizationServiceConfiguration.java b/library/java/net/openid/appauth/AuthorizationServiceConfiguration.java index b962e68e..37f9f066 100644 --- a/library/java/net/openid/appauth/AuthorizationServiceConfiguration.java +++ b/library/java/net/openid/appauth/AuthorizationServiceConfiguration.java @@ -58,6 +58,7 @@ public class AuthorizationServiceConfiguration { "openid-configuration"; private static final String KEY_AUTHORIZATION_ENDPOINT = "authorizationEndpoint"; + private static final String KEY_DEVICE_AUTHORIZATION_ENDPOINT = "deviceAuthorizationEndpoint"; private static final String KEY_TOKEN_ENDPOINT = "tokenEndpoint"; private static final String KEY_REGISTRATION_ENDPOINT = "registrationEndpoint"; private static final String KEY_DISCOVERY_DOC = "discoveryDoc"; @@ -87,6 +88,11 @@ public class AuthorizationServiceConfiguration { @Nullable public final Uri registrationEndpoint; + /** + * The device authorization service's endpoint. + */ + @Nullable + public final Uri deviceAuthorizationEndpoint; /** * The discovery document describing the service, if it is an OpenID Connect provider. @@ -146,10 +152,37 @@ public AuthorizationServiceConfiguration( @NonNull Uri tokenEndpoint, @Nullable Uri registrationEndpoint, @Nullable Uri endSessionEndpoint) { + this(authorizationEndpoint, tokenEndpoint, registrationEndpoint, endSessionEndpoint, null); + } + + /** + * Creates a service configuration for a basic OAuth2 provider. + * @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 registrationEndpoint The optional + * [client registration endpoint URI](https://tools.ietf.org/html/rfc7591#section-3) + * @param endSessionEndpoint The optional + * [end session endpoint URI](https://tools.ietf.org/html/rfc6749#section-2.2) + * for the service. + * @param deviceAuthorizationEndpoint The optional + * [authorization endpoint URI](https://tools.ietf.org/html/rfc8628#section-4) + * for the service. + */ + public AuthorizationServiceConfiguration( + @NonNull Uri authorizationEndpoint, + @NonNull Uri tokenEndpoint, + @Nullable Uri registrationEndpoint, + @Nullable Uri endSessionEndpoint, + @Nullable Uri deviceAuthorizationEndpoint) { this.authorizationEndpoint = checkNotNull(authorizationEndpoint); this.tokenEndpoint = checkNotNull(tokenEndpoint); this.registrationEndpoint = registrationEndpoint; this.endSessionEndpoint = endSessionEndpoint; + this.deviceAuthorizationEndpoint = deviceAuthorizationEndpoint; this.discoveryDoc = null; } @@ -167,6 +200,7 @@ public AuthorizationServiceConfiguration( this.tokenEndpoint = discoveryDoc.getTokenEndpoint(); this.registrationEndpoint = discoveryDoc.getRegistrationEndpoint(); this.endSessionEndpoint = discoveryDoc.getEndSessionEndpoint(); + this.deviceAuthorizationEndpoint = discoveryDoc.getDeviceAuthorizationEndpoint(); } /** @@ -183,6 +217,10 @@ public JSONObject toJson() { if (endSessionEndpoint != null) { JsonUtil.put(json, KEY_END_SESSION_ENPOINT, endSessionEndpoint.toString()); } + if (deviceAuthorizationEndpoint != null) { + JsonUtil.put(json, KEY_DEVICE_AUTHORIZATION_ENDPOINT, + deviceAuthorizationEndpoint.toString()); + } if (discoveryDoc != null) { JsonUtil.put(json, KEY_DISCOVERY_DOC, discoveryDoc.docJson); } @@ -224,7 +262,8 @@ public static AuthorizationServiceConfiguration fromJson(@NonNull JSONObject jso 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_END_SESSION_ENPOINT), + JsonUtil.getUriIfDefined(json, KEY_DEVICE_AUTHORIZATION_ENDPOINT)); } } diff --git a/library/java/net/openid/appauth/AuthorizationServiceDiscovery.java b/library/java/net/openid/appauth/AuthorizationServiceDiscovery.java index f0d02263..f0a42bf2 100644 --- a/library/java/net/openid/appauth/AuthorizationServiceDiscovery.java +++ b/library/java/net/openid/appauth/AuthorizationServiceDiscovery.java @@ -47,6 +47,9 @@ public class AuthorizationServiceDiscovery { @VisibleForTesting static final UriField AUTHORIZATION_ENDPOINT = uri("authorization_endpoint"); + @VisibleForTesting + static final UriField DEVICE_AUTHORIZATION_ENDPOINT = uri("device_authorization_endpoint"); + @VisibleForTesting static final UriField TOKEN_ENDPOINT = uri("token_endpoint"); @@ -268,6 +271,13 @@ public Uri getEndSessionEndpoint() { return get(END_SESSION_ENDPOINT); } + /** + * The OAuth 2 device authorization endpoint URI. + */ + public Uri getDeviceAuthorizationEndpoint() { + return get(DEVICE_AUTHORIZATION_ENDPOINT); + } + /** * The OpenID Connect UserInfo endpoint URI. */ diff --git a/library/java/net/openid/appauth/CancelAsyncTaskRunnable.java b/library/java/net/openid/appauth/CancelAsyncTaskRunnable.java new file mode 100644 index 00000000..83d5b066 --- /dev/null +++ b/library/java/net/openid/appauth/CancelAsyncTaskRunnable.java @@ -0,0 +1,41 @@ +/* + * Copyright 2021 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.os.AsyncTask; + +import java.lang.ref.WeakReference; + +/** + * Runnable returned when the cancellation of the underlying AsyncTask could be delegated. + * Has no effect if called twice or after the task has concluded. + */ +public class CancelAsyncTaskRunnable implements Runnable { + + private WeakReference> mAsyncTask; + + CancelAsyncTaskRunnable(AsyncTask pollingTask) { + mAsyncTask = new WeakReference<>(pollingTask); + } + + @Override + public void run() { + AsyncTask asyncTask = mAsyncTask.get(); + if (asyncTask == null) { + return; + } + asyncTask.cancel(true); + } +} diff --git a/library/java/net/openid/appauth/DeviceAuthorizationRequest.java b/library/java/net/openid/appauth/DeviceAuthorizationRequest.java new file mode 100644 index 00000000..7fb6d074 --- /dev/null +++ b/library/java/net/openid/appauth/DeviceAuthorizationRequest.java @@ -0,0 +1,345 @@ +/* + * Copyright 2021 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.AdditionalParamsProcessor.builtInParams; +import static net.openid.appauth.AdditionalParamsProcessor.checkAdditionalParams; +import static net.openid.appauth.Preconditions.checkNotEmpty; +import static net.openid.appauth.Preconditions.checkNotNull; + +import android.net.Uri; +import android.text.TextUtils; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; + +/** + * An OAuth2 device authorization request. + * + * @see "OAuth 2.0 Device Grant (RFC 8628), Section 3 + * " + * @see "OAuth 2.0 Device Grant (RFC 8628), Section 3.1 + * " + */ +public class DeviceAuthorizationRequest { + + @VisibleForTesting + static final String PARAM_CLIENT_ID = "client_id"; + + @VisibleForTesting + static final String PARAM_SCOPE = "scope"; + + private static final Set BUILT_IN_PARAMS = builtInParams( + PARAM_CLIENT_ID, + PARAM_SCOPE); + + private static final String KEY_CONFIGURATION = "configuration"; + private static final String KEY_CLIENT_ID = "clientId"; + private static final String KEY_SCOPE = "scope"; + private static final String KEY_ADDITIONAL_PARAMETERS = "additionalParameters"; + + /** + * 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, Uri)} + * created manually}, or {@link AuthorizationServiceConfiguration#fetchFromUrl(Uri, + * AuthorizationServiceConfiguration.RetrieveConfigurationCallback)} via an OpenID Connect + * Discovery Document}. + */ + @NonNull + public final AuthorizationServiceConfiguration configuration; + + /** + * The client identifier. + * + * @see "The OAuth 2.0 Authorization Framework (RFC 6749), Section 4 + * " + * @see "The OAuth 2.0 Authorization Framework (RFC 6749), Section 4.1.1 + * " + */ + @NonNull + public final String clientId; + + /** + * The optional set of scopes expressed as a space-delimited, case-sensitive string. + * + * @see "The OAuth 2.0 Authorization Framework (RFC 6749), Section 3.1.2 + * " + * @see "The OAuth 2.0 Authorization Framework (RFC 6749), Section 3.3 + * " + */ + @Nullable + public final String scope; + + /** + * Additional parameters to be passed as part of the request. + * + * @see "The OAuth 2.0 Authorization Framework (RFC 6749), Section 3.1 + * " + */ + @NonNull + public final Map additionalParameters; + + /** + * Creates instances of {@link DeviceAuthorizationRequest}. + */ + public static final class Builder { + + // SuppressWarnings justification: static analysis incorrectly determines that this field + // is not initialized, as it is indirectly initialized by setConfiguration + @NonNull + @SuppressWarnings("NullableProblems") + private AuthorizationServiceConfiguration mConfiguration; + + // SuppressWarnings justification: static analysis incorrectly determines that this field + // is not initialized, as it is indirectly initialized by setClientId + @NonNull + @SuppressWarnings("NullableProblems") + private String mClientId; + + @Nullable + private String mScope; + + @NonNull + private Map mAdditionalParameters = new HashMap<>(); + + /** + * Creates an authorization request builder with the specified mandatory properties. + */ + public Builder( + @NonNull AuthorizationServiceConfiguration configuration, + @NonNull String clientId) { + setAuthorizationServiceConfiguration(configuration); + setClientId(clientId); + } + + /** + * 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; + } + + /** + * Specifies the client ID. Cannot be null or empty. + * + * @see "The OAuth 2.0 Authorization Framework (RFC 6749), Section 4 + * " + * @see "The OAuth 2.0 Authorization Framework (RFC 6749), Section 4.1.1 + * " + */ + @NonNull + public Builder setClientId(@NonNull String clientId) { + mClientId = checkNotEmpty(clientId, "client ID cannot be null or empty"); + return this; + } + + /** + * Specifies the encoded scope string, which is a space-delimited set of + * case-sensitive scope identifiers. Replaces any previously specified scope. + * + * @see "The OAuth 2.0 Authorization Framework (RFC 6749), Section 3.3 + * " + */ + @NonNull + public Builder setScope(@Nullable String scope) { + if (TextUtils.isEmpty(scope)) { + mScope = null; + } else { + setScopes(scope.split(" +")); + } + return this; + } + + /** + * Specifies the set of case-sensitive scopes. Replaces any previously specified set of + * scopes. If no arguments are provided, the scope string will be set to `null`. + * Individual scope strings cannot be null or empty. + * + * @see "The OAuth 2.0 Authorization Framework (RFC 6749), Section 3.3 + * " + */ + @NonNull + public Builder setScopes(String... scopes) { + if (scopes == null) { + scopes = new String[0]; + } + setScopes(Arrays.asList(scopes)); + return this; + } + + /** + * Specifies the set of case-sensitive scopes. Replaces any previously specified set of + * scopes. If the iterable is empty, the scope string will be set to `null`. + * Individual scope strings cannot be null or empty. + * + * @see "The OAuth 2.0 Authorization Framework (RFC 6749), Section 3.3 + * " + */ + @NonNull + public Builder setScopes(@Nullable Iterable scopes) { + mScope = AsciiStringListUtil.iterableToString(scopes); + return this; + } + + /** + * Specifies additional parameters. Replaces any previously provided set of parameters. + * Parameter keys and values cannot be null or empty. + * + * @see "The OAuth 2.0 Authorization Framework (RFC 6749), Section 3.1 + * " + */ + @NonNull + public Builder setAdditionalParameters(@Nullable Map additionalParameters) { + mAdditionalParameters = checkAdditionalParams(additionalParameters, BUILT_IN_PARAMS); + return this; + } + + /** + * Constructs the device authorization request. At a minimum the following fields must have + * been set: + * + * - The client ID + * + * Failure to specify any of these parameters will result in a runtime exception. + */ + @NonNull + public DeviceAuthorizationRequest build() { + return new DeviceAuthorizationRequest( + mConfiguration, + mClientId, + mScope, + Collections.unmodifiableMap(new HashMap<>(mAdditionalParameters))); + } + } + + private DeviceAuthorizationRequest( + @NonNull AuthorizationServiceConfiguration configuration, + @NonNull String clientId, + @Nullable String scope, + @NonNull Map additionalParameters) { + // mandatory fields + this.configuration = configuration; + this.clientId = clientId; + this.additionalParameters = additionalParameters; + + // optional fields + this.scope = scope; + } + + /** + * Derives the set of scopes from the consolidated, space-delimited scopes in the + * {@link #scope} field. If no scopes were specified for this request, the method will + * return `null`. + */ + @Nullable + public Set getScopeSet() { + return AsciiStringListUtil.stringToSet(scope); + } + + /** + * Produces the set of request parameters for this query, which can be further + * processed into a request body. + */ + @NonNull + public Map getRequestParameters() { + Map params = new HashMap<>(); + params.put(PARAM_CLIENT_ID, clientId); + putIfNotNull(params, PARAM_SCOPE, scope); + + for (Entry param : additionalParameters.entrySet()) { + params.put(param.getKey(), param.getValue()); + } + + return params; + } + + private void putIfNotNull(Map map, String key, Object value) { + if (value != null) { + map.put(key, value.toString()); + } + } + + /** + * Produces a JSON representation of the device authorization request for persistent storage or + * local transmission (e.g. between activities). + */ + @NonNull + public JSONObject jsonSerialize() { + JSONObject json = new JSONObject(); + JsonUtil.put(json, KEY_CONFIGURATION, configuration.toJson()); + JsonUtil.put(json, KEY_CLIENT_ID, clientId); + JsonUtil.putIfNotNull(json, KEY_SCOPE, scope); + JsonUtil.put(json, KEY_ADDITIONAL_PARAMETERS, + JsonUtil.mapToJsonObject(additionalParameters)); + return json; + } + + /** + * Produces a JSON string representation of the device 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. + */ + @NonNull + public String jsonSerializeString() { + return jsonSerialize().toString(); + } + + /** + * Reads a device authorization request from a JSON string representation produced by + * {@link #jsonSerialize()}. + * @throws JSONException if the provided JSON does not match the expected structure. + */ + @NonNull + public static DeviceAuthorizationRequest jsonDeserialize(@NonNull JSONObject json) + throws JSONException { + checkNotNull(json, "json cannot be null"); + + return new DeviceAuthorizationRequest( + AuthorizationServiceConfiguration.fromJson(json.getJSONObject(KEY_CONFIGURATION)), + JsonUtil.getString(json, KEY_CLIENT_ID), + JsonUtil.getStringIfDefined(json, KEY_SCOPE), + JsonUtil.getStringMap(json, KEY_ADDITIONAL_PARAMETERS)); + } + + /** + * Reads a device 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 DeviceAuthorizationRequest jsonDeserialize(@NonNull String jsonStr) + throws JSONException { + checkNotNull(jsonStr, "json string cannot be null"); + return jsonDeserialize(new JSONObject(jsonStr)); + } + +} diff --git a/library/java/net/openid/appauth/DeviceAuthorizationResponse.java b/library/java/net/openid/appauth/DeviceAuthorizationResponse.java new file mode 100644 index 00000000..359c7c12 --- /dev/null +++ b/library/java/net/openid/appauth/DeviceAuthorizationResponse.java @@ -0,0 +1,461 @@ +/* + * Copyright 2021 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.AdditionalParamsProcessor.checkAdditionalParams; +import static net.openid.appauth.AdditionalParamsProcessor.extractAdditionalParams; +import static net.openid.appauth.Preconditions.checkNotEmpty; +import static net.openid.appauth.Preconditions.checkNotNull; +import static net.openid.appauth.Preconditions.checkNullOrNotEmpty; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +/** + * A response to a device authorization request. + * + * @see DeviceAuthorizationRequest + * @see "OAuth 2.0 Device Grant (RFC 8628), Section 3.2 + * " + */ +public class DeviceAuthorizationResponse { + + @VisibleForTesting + static final String KEY_REQUEST = "request"; + + @VisibleForTesting + static final String KEY_DEVICE_CODE = "device_code"; + + @VisibleForTesting + static final String KEY_USER_CODE = "user_code"; + + @VisibleForTesting + static final String KEY_VERIFICATION_URI = "verification_uri"; + + @VisibleForTesting + static final String KEY_VERIFICATION_URI_COMPLETE = "verification_uri_complete"; + + @VisibleForTesting + static final String KEY_EXPIRES_IN = "expires_in"; + + @VisibleForTesting + static final String KEY_INTERVAL = "interval"; + + @VisibleForTesting + static final String KEY_ADDITIONAL_PARAMETERS = "additionalParameters"; + + private static final Set BUILT_IN_PARAMS = new HashSet<>(Arrays.asList( + KEY_DEVICE_CODE, + KEY_USER_CODE, + KEY_VERIFICATION_URI, + KEY_VERIFICATION_URI_COMPLETE, + KEY_EXPIRES_IN, + KEY_INTERVAL + )); + + /** + * The device authorization request associated with this response. + */ + @NonNull + public final DeviceAuthorizationRequest request; + + /** + * The device verification code. + * + * @see "OAuth 2.0 Device Grant (RFC 8628), Section 3.2 + * " + */ + @Nullable + public final String deviceCode; + + /** + * The end-user verification code. + * + * @see "OAuth 2.0 Device Grant (RFC 8628), Section 3.2 + * " + */ + @Nullable + public final String userCode; + + /** + * The end-user verification URI on the authorization server. + * + * @see "OAuth 2.0 Device Grant (RFC 8628), Section 3.2 + * " + */ + @Nullable + public final String verificationUri; + + /** + * A verification URI that includes the "user_code" (or other information with the same + * function as the "user_code"), which is designed for non-textual transmission. + * + * @see "OAuth 2.0 Device Grant (RFC 8628), Section 3.2 + * " + */ + @Nullable + public final String verificationUriComplete; + + /** + * The expiration time of the {@link #deviceCode} and {@link #userCode}. + * + * @see "OAuth 2.0 Device Grant (RFC 8628), Section 3.2 + * " + */ + @Nullable + public final Long codeExpirationTime; + + /** + * The minimum amount of time in seconds that the client SHOULD wait between polling requests + * to the token endpoint. If no value is provided, clients MUST use 5 as the default. + * + * @see "OAuth 2.0 Device Grant (RFC 8628), Section 3.2 + * " + */ + @Nullable + public final Long tokenPollingIntervalTime; + + /** + * Additional, non-standard parameters in the response. + */ + @NonNull + public final Map additionalParameters; + + /** + * Creates instances of {@link DeviceAuthorizationResponse}. + */ + public static final class Builder { + @NonNull + private DeviceAuthorizationRequest mRequest; + + @Nullable + private String mDeviceCode; + + @Nullable + private String mUserCode; + + @Nullable + private String mVerificationUri; + + @Nullable + private String mVerificationUriComplete; + + @Nullable + private Long mCodeExpirationTime; + + @Nullable + private Long mTokenPollingIntervalTime; + + @NonNull + private Map mAdditionalParameters; + + /** + * Creates a device authorization response associated with the specified request. + */ + public Builder(@NonNull DeviceAuthorizationRequest request) { + setRequest(request); + mAdditionalParameters = Collections.emptyMap(); + } + + /** + * Extracts device authorization response fields from a JSON string. + * + * @throws JSONException if the JSON is malformed or has incorrect value types for fields. + */ + @NonNull + public Builder fromResponseJsonString(@NonNull String jsonStr) throws JSONException { + checkNotEmpty(jsonStr, "json cannot be null or empty"); + return fromResponseJson(new JSONObject(jsonStr)); + } + + /** + * Extracts device authorization response fields from a JSON object. + * + * @throws JSONException if the JSON is malformed or has incorrect value types for fields. + */ + @NonNull + public Builder fromResponseJson(@NonNull JSONObject json) throws JSONException { + setDeviceCode(JsonUtil.getString(json, KEY_DEVICE_CODE)); + setUserCode(JsonUtil.getString(json, KEY_USER_CODE)); + setVerificationUri(JsonUtil.getString(json, KEY_VERIFICATION_URI)); + setVerificationUriComplete( + JsonUtil.getStringIfDefined(json, KEY_VERIFICATION_URI_COMPLETE)); + setCodeExpiresIn(json.getLong(KEY_EXPIRES_IN)); + if (json.has(KEY_INTERVAL)) { + setTokenPollingIntervalTime(json.getLong(KEY_INTERVAL)); + } + setAdditionalParameters(extractAdditionalParams(json, BUILT_IN_PARAMS)); + + return this; + } + + /** + * Specifies the request associated with this response. Must not be null. + */ + @NonNull + public Builder setRequest(@NonNull DeviceAuthorizationRequest request) { + mRequest = checkNotNull(request, "request cannot be null"); + return this; + } + + /** + * Specifies the device code associated with this response. If not null, the value must + * be non-empty + */ + @NonNull + public Builder setDeviceCode(@Nullable String deviceCode) { + mDeviceCode = checkNullOrNotEmpty(deviceCode, + "device code must not be empty if defined"); + return this; + } + + /** + * Specifies the user code associated with this response. If not null, the value must + * be non-empty + */ + @NonNull + public Builder setUserCode(@Nullable String userCode) { + mUserCode = checkNullOrNotEmpty(userCode, "user code must not be empty if defined"); + return this; + } + + /** + * Specifies the verification uri associated with this response. If not null, the value + * must be non-empty + */ + @NonNull + public Builder setVerificationUri(@Nullable String verificationUri) { + mVerificationUri = checkNullOrNotEmpty(verificationUri, + "verification uri must not be empty if defined"); + return this; + } + + /** + * Specifies the complete verification uri associated with this response. If not null, the + * value must be non-empty + */ + @NonNull + public Builder setVerificationUriComplete(@Nullable String verificationUriComplete) { + mVerificationUriComplete = checkNullOrNotEmpty(verificationUriComplete, + "complete verification uri must not be empty if defined"); + return this; + } + + /** + * Specifies the relative expiration time of the user code, in seconds, using the default + * system clock as the source of the current time. + */ + @NonNull + public Builder setCodeExpiresIn(@Nullable Long expiresIn) { + return setCodeExpiresIn(expiresIn, SystemClock.INSTANCE); + } + + /** + * Specifies the relative expiration time of the user code, in seconds, using the + * provided clock as the source of the current time. + */ + @NonNull + @VisibleForTesting + public Builder setCodeExpiresIn(@Nullable Long expiresIn, @NonNull Clock clock) { + if (expiresIn == null) { + mCodeExpirationTime = null; + } else { + mCodeExpirationTime = clock.getCurrentTimeMillis() + + TimeUnit.SECONDS.toMillis(expiresIn); + } + return this; + } + + /** + * Specifies the expiration time of the user code. + */ + @NonNull + public Builder setCodeExpirationTime(@Nullable Long expirationTime) { + mCodeExpirationTime = expirationTime; + return this; + } + + /** + * Sets the token polling interval time, in seconds. + */ + @NonNull + public Builder setTokenPollingIntervalTime(@Nullable Long interval) { + mTokenPollingIntervalTime = interval; + return this; + } + + /** + * Specifies the additional, non-standard parameters received as part of the response. + */ + @NonNull + public Builder setAdditionalParameters(@Nullable Map additionalParameters) { + mAdditionalParameters = checkAdditionalParams(additionalParameters, BUILT_IN_PARAMS); + return this; + } + + /** + * Creates the device authorization response. + */ + public DeviceAuthorizationResponse build() { + return new DeviceAuthorizationResponse( + mRequest, + mDeviceCode, + mUserCode, + mVerificationUri, + mVerificationUriComplete, + mCodeExpirationTime, + mTokenPollingIntervalTime, + mAdditionalParameters); + } + } + + DeviceAuthorizationResponse( + @NonNull DeviceAuthorizationRequest request, + @Nullable String deviceCode, + @Nullable String userCode, + @Nullable String verificationUri, + @Nullable String verificationUriComplete, + @Nullable Long codeExpirationTime, + @Nullable Long tokenPollingIntervalTime, + @NonNull Map additionalParameters) { + this.request = request; + this.deviceCode = deviceCode; + this.userCode = userCode; + this.verificationUri = verificationUri; + this.verificationUriComplete = verificationUriComplete; + this.codeExpirationTime = codeExpirationTime; + this.tokenPollingIntervalTime = tokenPollingIntervalTime; + this.additionalParameters = additionalParameters; + } + + /** + * Determines whether the returned user code has expired. + */ + public boolean hasCodeExpired() { + return hasCodeExpired(SystemClock.INSTANCE); + } + + @VisibleForTesting + boolean hasCodeExpired(@NonNull Clock clock) { + return codeExpirationTime != null + && checkNotNull(clock).getCurrentTimeMillis() > codeExpirationTime; + } + + /** + * Creates a follow-up request to exchange a received authorization code for tokens. + */ + @NonNull + public TokenRequest createTokenExchangeRequest() { + return createTokenExchangeRequest(Collections.emptyMap()); + } + + /** + * Creates a follow-up request to exchange a received authorization code for tokens, including + * the provided additional parameters. + */ + @NonNull + public TokenRequest createTokenExchangeRequest( + @NonNull Map additionalExchangeParameters) { + checkNotNull(additionalExchangeParameters, + "additionalExchangeParameters cannot be null"); + + if (deviceCode == null) { + throw new IllegalStateException("deviceCode not available for exchange request"); + } + + return new TokenRequest.Builder( + request.configuration, + request.clientId) + .setGrantType(GrantTypeValues.DEVICE_CODE) + .setDeviceCode(deviceCode) + .setAdditionalParameters(additionalExchangeParameters) + .build(); + } + + /** + * Produces a JSON string representation of the device authorization response for + * persistent storage or local transmission (e.g. between activities). + */ + public JSONObject jsonSerialize() { + JSONObject json = new JSONObject(); + JsonUtil.put(json, KEY_REQUEST, request.jsonSerialize()); + JsonUtil.putIfNotNull(json, KEY_DEVICE_CODE, deviceCode); + JsonUtil.putIfNotNull(json, KEY_USER_CODE, userCode); + JsonUtil.putIfNotNull(json, KEY_VERIFICATION_URI, verificationUri); + JsonUtil.putIfNotNull(json, KEY_VERIFICATION_URI_COMPLETE, verificationUriComplete); + JsonUtil.putIfNotNull(json, KEY_EXPIRES_IN, codeExpirationTime); + JsonUtil.putIfNotNull(json, KEY_INTERVAL, tokenPollingIntervalTime); + JsonUtil.put(json, KEY_ADDITIONAL_PARAMETERS, + JsonUtil.mapToJsonObject(additionalParameters)); + return json; + } + + /** + * Produces a JSON string representation of the device 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. + */ + public String jsonSerializeString() { + return jsonSerialize().toString(); + } + + /** + * Reads a device authorization response from a JSON string, and associates it with the + * provided request. If a request is not provided, its serialized form is expected to be found + * in the JSON (as if produced by a prior call to {@link #jsonSerialize()}. + * @throws JSONException if the JSON is malformed or missing required fields. + */ + @NonNull + public static DeviceAuthorizationResponse jsonDeserialize( + @NonNull JSONObject json) throws JSONException { + if (!json.has(KEY_REQUEST)) { + throw new IllegalArgumentException( + "device authorization request not provided and not found in JSON"); + } + return new DeviceAuthorizationResponse( + DeviceAuthorizationRequest.jsonDeserialize(json.getJSONObject(KEY_REQUEST)), + JsonUtil.getStringIfDefined(json, KEY_DEVICE_CODE), + JsonUtil.getStringIfDefined(json, KEY_USER_CODE), + JsonUtil.getStringIfDefined(json, KEY_VERIFICATION_URI), + JsonUtil.getStringIfDefined(json, KEY_VERIFICATION_URI_COMPLETE), + JsonUtil.getLongIfDefined(json, KEY_EXPIRES_IN), + JsonUtil.getLongIfDefined(json, KEY_INTERVAL), + JsonUtil.getStringMap(json, KEY_ADDITIONAL_PARAMETERS)); + } + + /** + * Reads a device authorization response from a JSON string, and associates it with the + * provided request. If a request is not provided, its serialized form is expected to be found + * in the JSON (as if produced by a prior call to {@link #jsonSerialize()}. + * @throws JSONException if the JSON is malformed or missing required fields. + */ + @NonNull + public static DeviceAuthorizationResponse jsonDeserialize( + @NonNull String jsonStr) throws JSONException { + checkNotEmpty(jsonStr, "jsonStr cannot be null or empty"); + return jsonDeserialize(new JSONObject(jsonStr)); + } +} diff --git a/library/java/net/openid/appauth/GrantTypeValues.java b/library/java/net/openid/appauth/GrantTypeValues.java index e2f3d7a2..fd55092f 100644 --- a/library/java/net/openid/appauth/GrantTypeValues.java +++ b/library/java/net/openid/appauth/GrantTypeValues.java @@ -28,6 +28,14 @@ public final class GrantTypeValues { */ public static final String AUTHORIZATION_CODE = "authorization_code"; + /** + * The grant type used when obtaining a device access token. + * + * @see " OAuth 2.0 Device Grant (RFC 8628), Section 3.4 + * " + */ + public static final String DEVICE_CODE = "urn:ietf:params:oauth:grant-type:device_code"; + /** * The grant type used when obtaining an access token. * diff --git a/library/java/net/openid/appauth/TokenRequest.java b/library/java/net/openid/appauth/TokenRequest.java index 8076a918..8ea49de9 100644 --- a/library/java/net/openid/appauth/TokenRequest.java +++ b/library/java/net/openid/appauth/TokenRequest.java @@ -61,6 +61,8 @@ public class TokenRequest { @VisibleForTesting static final String KEY_AUTHORIZATION_CODE = "authorizationCode"; @VisibleForTesting + static final String KEY_DEVICE_CODE = "deviceCode"; + @VisibleForTesting static final String KEY_REFRESH_TOKEN = "refreshToken"; @VisibleForTesting static final String KEY_CODE_VERIFIER = "codeVerifier"; @@ -72,6 +74,9 @@ public class TokenRequest { @VisibleForTesting static final String PARAM_CODE = "code"; + @VisibleForTesting + static final String PARAM_DEVICE_CODE = "device_code"; + @VisibleForTesting static final String PARAM_CODE_VERIFIER = "code_verifier"; @@ -178,6 +183,15 @@ public class TokenRequest { @Nullable public final String authorizationCode; + /** + * A device code to be exchanged for one or more tokens. + * + * @see "OAuth 2.0 Device Grant (RFC 8628), Section 3.4 + * " + */ + @Nullable + public final String deviceCode; + /** * A space-delimited set of scopes used to determine the scope of any returned tokens. * @@ -240,6 +254,9 @@ public static final class Builder { @Nullable private String mAuthorizationCode; + @Nullable + private String mDeviceCode; + @Nullable private String mRefreshToken; @@ -387,6 +404,21 @@ public Builder setAuthorizationCode(@Nullable String authorizationCode) { return this; } + /** + * Specifies the device code for the request. If provided, the device code must not be + * empty. + * + * Specifying a device code normally implies that this is a request to exchange this + * device code for one or more tokens. If this is not intended, the grant type should + * be explicitly set. + */ + @NonNull + public Builder setDeviceCode(@Nullable String deviceCode) { + checkNullOrNotEmpty(deviceCode, "device code must not be empty"); + mDeviceCode = deviceCode; + return this; + } + /** * Specifies the refresh token for the request. If a non-null value is provided, it must * not be empty. @@ -440,6 +472,12 @@ public TokenRequest build() { + GrantTypeValues.AUTHORIZATION_CODE); } + if (GrantTypeValues.DEVICE_CODE.equals(grantType)) { + checkNotNull(mDeviceCode, + "device code must be specified for grant_type = " + + GrantTypeValues.DEVICE_CODE); + } + if (GrantTypeValues.REFRESH_TOKEN.equals(grantType)) { checkNotNull(mRefreshToken, "refresh token must be specified for grant_type = " @@ -460,6 +498,7 @@ public TokenRequest build() { mRedirectUri, mScope, mAuthorizationCode, + mDeviceCode, mRefreshToken, mCodeVerifier, Collections.unmodifiableMap(mAdditionalParameters)); @@ -470,6 +509,8 @@ private String inferGrantType() { return mGrantType; } else if (mAuthorizationCode != null) { return GrantTypeValues.AUTHORIZATION_CODE; + } else if (mDeviceCode != null) { + return GrantTypeValues.DEVICE_CODE; } else if (mRefreshToken != null) { return GrantTypeValues.REFRESH_TOKEN; } else { @@ -486,6 +527,7 @@ private TokenRequest( @Nullable Uri redirectUri, @Nullable String scope, @Nullable String authorizationCode, + @Nullable String deviceCode, @Nullable String refreshToken, @Nullable String codeVerifier, @NonNull Map additionalParameters) { @@ -496,6 +538,7 @@ private TokenRequest( this.redirectUri = redirectUri; this.scope = scope; this.authorizationCode = authorizationCode; + this.deviceCode = deviceCode; this.refreshToken = refreshToken; this.codeVerifier = codeVerifier; this.additionalParameters = additionalParameters; @@ -521,6 +564,7 @@ public Map getRequestParameters() { params.put(PARAM_GRANT_TYPE, grantType); putIfNotNull(params, PARAM_REDIRECT_URI, redirectUri); putIfNotNull(params, PARAM_CODE, authorizationCode); + putIfNotNull(params, PARAM_DEVICE_CODE, deviceCode); putIfNotNull(params, PARAM_REFRESH_TOKEN, refreshToken); putIfNotNull(params, PARAM_CODE_VERIFIER, codeVerifier); putIfNotNull(params, PARAM_SCOPE, scope); @@ -552,6 +596,7 @@ public JSONObject jsonSerialize() { JsonUtil.putIfNotNull(json, KEY_REDIRECT_URI, redirectUri); JsonUtil.putIfNotNull(json, KEY_SCOPE, scope); JsonUtil.putIfNotNull(json, KEY_AUTHORIZATION_CODE, authorizationCode); + JsonUtil.putIfNotNull(json, KEY_DEVICE_CODE, deviceCode); JsonUtil.putIfNotNull(json, KEY_REFRESH_TOKEN, refreshToken); JsonUtil.putIfNotNull(json, KEY_CODE_VERIFIER, codeVerifier); JsonUtil.put(json, KEY_ADDITIONAL_PARAMETERS, @@ -586,6 +631,7 @@ public static TokenRequest jsonDeserialize(JSONObject json) throws JSONException JsonUtil.getUriIfDefined(json, KEY_REDIRECT_URI), JsonUtil.getStringIfDefined(json, KEY_SCOPE), JsonUtil.getStringIfDefined(json, KEY_AUTHORIZATION_CODE), + JsonUtil.getStringIfDefined(json, KEY_DEVICE_CODE), JsonUtil.getStringIfDefined(json, KEY_REFRESH_TOKEN), JsonUtil.getStringIfDefined(json, KEY_CODE_VERIFIER), JsonUtil.getStringMap(json, KEY_ADDITIONAL_PARAMETERS)); From 1aa56c14b8065ed303575b32bb5bbd580e0eca29 Mon Sep 17 00:00:00 2001 From: Victor Solevic Date: Fri, 12 Nov 2021 16:26:01 +0100 Subject: [PATCH 2/3] Add Device Authorization Grant tests --- .../net/openid/appauth/AuthStateTest.java | 189 ++++++++++++++- ...AuthorizationServiceConfigurationTest.java | 6 +- .../AuthorizationServiceDiscoveryTest.java | 2 + .../appauth/AuthorizationServiceTest.java | 220 ++++++++++++++++++ .../appauth/CancelAsyncTaskRunnableTest.java | 67 ++++++ .../DeviceAuthorizationRequestTest.java | 176 ++++++++++++++ .../DeviceAuthorizationResponseTest.java | 156 +++++++++++++ .../net/openid/appauth/IdTokenTest.java | 2 + .../net/openid/appauth/TestValues.java | 35 +++ .../net/openid/appauth/TokenRequestTest.java | 23 ++ 10 files changed, 873 insertions(+), 3 deletions(-) create mode 100644 library/javatests/net/openid/appauth/CancelAsyncTaskRunnableTest.java create mode 100644 library/javatests/net/openid/appauth/DeviceAuthorizationRequestTest.java create mode 100644 library/javatests/net/openid/appauth/DeviceAuthorizationResponseTest.java diff --git a/library/javatests/net/openid/appauth/AuthStateTest.java b/library/javatests/net/openid/appauth/AuthStateTest.java index c4d0bf22..46a8a442 100644 --- a/library/javatests/net/openid/appauth/AuthStateTest.java +++ b/library/javatests/net/openid/appauth/AuthStateTest.java @@ -20,20 +20,28 @@ import static net.openid.appauth.TestValues.TEST_ID_TOKEN; import static net.openid.appauth.TestValues.TEST_REFRESH_TOKEN; import static net.openid.appauth.TestValues.getMinimalAuthRequestBuilder; +import static net.openid.appauth.TestValues.getMinimalDeviceAuthRequestBuilder; import static net.openid.appauth.TestValues.getTestAuthCodeExchangeResponse; import static net.openid.appauth.TestValues.getTestAuthCodeExchangeResponseBuilder; import static net.openid.appauth.TestValues.getTestAuthRequest; import static net.openid.appauth.TestValues.getTestAuthResponse; import static net.openid.appauth.TestValues.getTestAuthResponseBuilder; +import static net.openid.appauth.TestValues.getTestDeviceAuthorizationResponse; import static net.openid.appauth.TestValues.getTestRegistrationResponse; import static net.openid.appauth.TestValues.getTestRegistrationResponseBuilder; import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import androidx.annotation.Nullable; import java.util.Collections; import org.junit.Before; @@ -71,6 +79,7 @@ public void testInitialState() { assertThat(state.getLastAuthorizationResponse()).isNull(); assertThat(state.getLastTokenResponse()).isNull(); assertThat(state.getLastRegistrationResponse()).isNull(); + assertThat(state.getLastDeviceAuthorizationResponse()).isNull(); assertThat(state.getScope()).isNull(); assertThat(state.getScopeSet()).isNull(); @@ -93,6 +102,7 @@ public void testInitialState_fromAuthorizationResponse() { assertThat(state.getAuthorizationException()).isNull(); assertThat(state.getLastAuthorizationResponse()).isSameAs(resp); assertThat(state.getLastTokenResponse()).isNull(); + assertThat(state.getLastDeviceAuthorizationResponse()).isNull(); assertThat(state.getScope()).isEqualTo(authCodeRequest.scope); assertThat(state.getScopeSet()).isEqualTo(authCodeRequest.getScopeSet()); @@ -103,7 +113,7 @@ public void testInitialState_fromAuthorizationResponse() { @Test public void testInitialState_fromAuthorizationException() { AuthState state = new AuthState( - null, + (AuthorizationResponse) null, AuthorizationException.AuthorizationRequestErrors.ACCESS_DENIED); assertThat(state.isAuthorized()).isFalse(); @@ -181,6 +191,28 @@ public void testInitialState_fromRegistrationResponse() { assertThat(state.getNeedsTokenRefresh(mClock)).isTrue(); } + @Test + public void testInitialState_fromDeviceAuthResponse() { + DeviceAuthorizationResponse deviceAuthResp = getTestDeviceAuthorizationResponse(); + AuthState state = new AuthState(deviceAuthResp, null); + + assertThat(state.isAuthorized()).isFalse(); + assertThat(state.getAccessToken()).isNull(); + assertThat(state.getAccessTokenExpirationTime()).isNull(); + assertThat(state.getIdToken()).isNull(); + assertThat(state.getRefreshToken()).isNull(); + + assertThat(state.getAuthorizationException()).isNull(); + assertThat(state.getLastAuthorizationResponse()).isNull(); + assertThat(state.getLastTokenResponse()).isNull(); + assertThat(state.getLastRegistrationResponse()).isNull(); + assertThat(state.getLastDeviceAuthorizationResponse()).isSameAs(deviceAuthResp); + + assertThat(state.getScope()).isNull(); + assertThat(state.getScopeSet()).isNull(); + assertThat(state.getNeedsTokenRefresh(mClock)).isTrue(); + } + @Test(expected = IllegalArgumentException.class) public void testConstructor_withAuthResponseAndException() { new AuthState(getTestAuthResponse(), @@ -225,6 +257,25 @@ public void testUpdate_tokenResponseWithException_ignoredErrorType() { assertThat(state.getAuthorizationException()).isNull(); } + @Test + public void testUpdate_deviceAuthResponseWithException_deviceAuthErrorType() { + AuthState state = new AuthState(); + state.update((DeviceAuthorizationResponse) null, + AuthorizationException.DeviceCodeRequestErrors.ACCESS_DENIED); + + assertThat(state.getAuthorizationException()) + .isSameAs(AuthorizationException.DeviceCodeRequestErrors.ACCESS_DENIED); + } + + @Test + public void testUpdate_deviceAuthResponseWithException_ignoredErrorType() { + AuthState state = new AuthState(); + state.update((DeviceAuthorizationResponse) null, + AuthorizationException.GeneralErrors.SERVER_ERROR); + + assertThat(state.getAuthorizationException()).isNull(); + } + @Test(expected = IllegalArgumentException.class) public void testUpdate_withAuthResponseAndException() { AuthState state = new AuthState(); @@ -239,6 +290,13 @@ public void testUpdate_withTokenResponseAndException() { AuthorizationException.AuthorizationRequestErrors.ACCESS_DENIED); } + @Test(expected = IllegalArgumentException.class) + public void testUpdate_withDeviceAuthResponseAndException() { + AuthState state = new AuthState(); + state.update(getTestDeviceAuthorizationResponse(), + AuthorizationException.AuthorizationRequestErrors.ACCESS_DENIED); + } + @Test public void testGetAccessToken_fromAuthResponse() { AuthorizationRequest authReq = getMinimalAuthRequestBuilder("code token") @@ -319,6 +377,60 @@ public void testGetIdToken_fromTokenResponse() { assertThat(state.getIdToken()).isEqualTo(tokenResp.idToken); } + @Test + public void testGetConfiguration() { + AuthorizationResponse authResp = getTestAuthResponse(); + AuthState state = new AuthState(authResp, null); + assertThat(state.getConfiguration()).isEqualTo(authResp.request.configuration); + + TokenResponse tokenResp = getTestAuthCodeExchangeResponse(); + state.update(tokenResp, null); + assertThat(state.getConfiguration()).isEqualTo(tokenResp.request.configuration); + + DeviceAuthorizationResponse deviceAuthResp = getTestDeviceAuthorizationResponse(); + state.update(deviceAuthResp, null); + assertThat(state.getConfiguration()).isEqualTo(deviceAuthResp.request.configuration); + } + + @Test + public void testGetClientId_fromAuthResponse() { + AuthorizationResponse authResp = getTestAuthResponse(); + AuthState state = new AuthState(authResp, null); + + assertThat(state.getClientId()).isEqualTo((Object) authResp.request.clientId); + } + + @Test + public void testGetClientId_fromTokenResponse() { + AuthorizationRequest authReq = getMinimalAuthRequestBuilder("code") + .setClientId("123456") + .build(); + AuthorizationResponse authResp = new AuthorizationResponse.Builder(authReq) + .build(); + TokenResponse tokenResp = getTestAuthCodeExchangeResponse(); + AuthState state = new AuthState(authResp, tokenResp, null); + + // in this scenario, we have a client ID on both the authorization response and the + // token response. The value on the token response takes precedence. + assertThat(state.getClientId()).isEqualTo((Object) tokenResp.request.clientId); + } + + @Test + public void testGetClientId_fromDeviceAuthResponse() { + AuthorizationRequest authReq = getMinimalAuthRequestBuilder("code") + .setClientId("123456") + .build(); + AuthorizationResponse authResp = new AuthorizationResponse.Builder(authReq) + .build(); + DeviceAuthorizationResponse deviceAuthResp = getTestDeviceAuthorizationResponse(); + AuthState state = new AuthState(authResp,null); + state.update(deviceAuthResp, null); + + // in this scenario, we have a client ID on both the authorization response and the device + // authorization response. The value on the device authorization response takes precedence. + assertThat(state.getClientId()).isEqualTo((Object) deviceAuthResp.request.clientId); + } + @Test public void testCreateTokenRefreshRequest() { AuthorizationResponse authResp = getTestAuthResponse(); @@ -580,6 +692,70 @@ public void testPerformActionWithFreshToken_afterTokenExpiration_multipleActions assertThat(state.getIdToken()).isEqualTo(freshIdToken); } + @Test + public void testPerformTokenPollRequest() { + DeviceAuthorizationResponse deviceAuthResp = getTestDeviceAuthorizationResponse(); + AuthState state = new AuthState(deviceAuthResp, null); + + AuthorizationService service = mock(AuthorizationService.class); + AuthorizationService.TokenResponseCallback callback = + mock(AuthorizationService.TokenResponseCallback.class); + + state.performTokenPollRequest(service, callback); + + verify(service, times(1)).performTokenPollRequest( + any(TokenRequest.class), + any(ClientAuthentication.class), + anyLong(), + anyLong(), + any(AuthorizationService.TokenResponseCallback.class)); + } + + @Test + public void testPerformTokenPollRequest_deviceAuthMissingError() { + AuthState state = new AuthState(); + + AuthorizationService service = mock(AuthorizationService.class); + AuthorizationService.TokenResponseCallback callback = + mock(AuthorizationService.TokenResponseCallback.class); + + state.performTokenPollRequest(service, callback); + + ArgumentCaptor exceptionCaptor = + ArgumentCaptor.forClass(AuthorizationException.class); + + verify(callback, times(1)).onTokenRequestCompleted( + ArgumentMatchers.isNull(), + exceptionCaptor.capture()); + + assertThat(exceptionCaptor.getValue()) + .isEqualTo(AuthorizationException.DeviceCodeRequestErrors.CLIENT_ERROR); + } + + @Test + public void testCancelTokenPoll() { + DeviceAuthorizationResponse deviceAuthResp = getTestDeviceAuthorizationResponse(); + AuthState state = new AuthState(deviceAuthResp, null); + + AuthorizationService service = mock(AuthorizationService.class); + AuthorizationService.TokenResponseCallback callback = + mock(AuthorizationService.TokenResponseCallback.class); + CancelAsyncTaskRunnable cancelRunnable = mock(CancelAsyncTaskRunnable.class); + + when(service.performTokenPollRequest( + any(TokenRequest.class), + any(ClientAuthentication.class), + anyLong(), + anyLong(), + any(AuthorizationService.TokenResponseCallback.class) + )).thenReturn(cancelRunnable); + + state.performTokenPollRequest(service, callback); + state.cancelTokenPoll(); + + verify(cancelRunnable, times(1)).run(); + } + @Test public void testJsonSerialization() throws Exception { AuthorizationRequest authReq = getMinimalAuthRequestBuilder("id_token token code") @@ -597,8 +773,10 @@ public void testJsonSerialization() throws Exception { TokenResponse tokenResp = getTestAuthCodeExchangeResponse(); RegistrationResponse regResp = getTestRegistrationResponse(); + DeviceAuthorizationResponse deviceAuthResp = getTestDeviceAuthorizationResponse(); AuthState state = new AuthState(authResp, tokenResp, null); state.update(regResp); + state.update(deviceAuthResp, null); String json = state.jsonSerializeString(); AuthState restoredState = AuthState.jsonDeserialize(json); @@ -610,6 +788,11 @@ public void testJsonSerialization() throws Exception { .isEqualTo(state.getAccessTokenExpirationTime()); assertThat(restoredState.getIdToken()).isEqualTo(state.getIdToken()); assertThat(restoredState.getRefreshToken()).isEqualTo(state.getRefreshToken()); + assertThat(restoredState.getUserCode()).isEqualTo(state.getUserCode()); + assertThat(restoredState.getCodeExpirationTime()).isEqualTo(state.getCodeExpirationTime()); + assertThat(restoredState.getVerificationUri()).isEqualTo(state.getVerificationUri()); + assertThat(restoredState.getVerificationUriComplete()) + .isEqualTo(state.getVerificationUriComplete()); assertThat(restoredState.getScope()).isEqualTo(state.getScope()); assertThat(restoredState.getNeedsTokenRefresh(mClock)) .isEqualTo(state.getNeedsTokenRefresh(mClock)); @@ -635,8 +818,10 @@ public void testJsonSerialization_doesNotChange() throws Exception { TokenResponse tokenResp = getTestAuthCodeExchangeResponse(); RegistrationResponse regResp = getTestRegistrationResponse(); + DeviceAuthorizationResponse deviceAuthResp = getTestDeviceAuthorizationResponse(); AuthState state = new AuthState(authResp, tokenResp, null); state.update(regResp); + state.update(deviceAuthResp, null); String firstOutput = state.jsonSerializeString(); String secondOutput = AuthState.jsonDeserialize(firstOutput).jsonSerializeString(); @@ -647,7 +832,7 @@ public void testJsonSerialization_doesNotChange() throws Exception { @Test public void testJsonSerialization_withException() throws Exception { AuthState state = new AuthState( - null, + (AuthorizationResponse) null, AuthorizationException.AuthorizationRequestErrors.INVALID_REQUEST); AuthState restored = AuthState.jsonDeserialize(state.jsonSerializeString()); diff --git a/library/javatests/net/openid/appauth/AuthorizationServiceConfigurationTest.java b/library/javatests/net/openid/appauth/AuthorizationServiceConfigurationTest.java index 00d5c8f2..56fd56a7 100644 --- a/library/javatests/net/openid/appauth/AuthorizationServiceConfigurationTest.java +++ b/library/javatests/net/openid/appauth/AuthorizationServiceConfigurationTest.java @@ -60,6 +60,7 @@ public class AuthorizationServiceConfigurationTest { 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_DEVICE_AUTH_ENDPOINT = "https://test.openid.com/o/oauth/device"; 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"; private static final List TEST_RESPONSE_TYPE_SUPPORTED = Arrays.asList("code", "token"); @@ -77,6 +78,7 @@ public class AuthorizationServiceConfigurationTest { + " \"token_endpoint\": \"" + TEST_TOKEN_ENDPOINT + "\",\n" + " \"registration_endpoint\": \"" + TEST_REGISTRATION_ENDPOINT + "\",\n" + " \"end_session_endpoint\": \"" + TEST_END_SESSION_ENDPOINT + "\",\n" + + " \"device_authorization_endpoint\": \"" + TEST_DEVICE_AUTH_ENDPOINT + "\",\n" + " \"userinfo_endpoint\": \"" + TEST_USERINFO_ENDPOINT + "\",\n" + " \"jwks_uri\": \"" + TEST_JWKS_URI + "\",\n" + " \"response_types_supported\": " + toJson(TEST_RESPONSE_TYPE_SUPPORTED) + ",\n" @@ -127,7 +129,8 @@ public void setUp() throws Exception { Uri.parse(TEST_AUTH_ENDPOINT), Uri.parse(TEST_TOKEN_ENDPOINT), Uri.parse(TEST_REGISTRATION_ENDPOINT), - Uri.parse(TEST_END_SESSION_ENDPOINT)); + Uri.parse(TEST_END_SESSION_ENDPOINT), + Uri.parse(TEST_DEVICE_AUTH_ENDPOINT)); when(mConnectionBuilder.openConnection(any(Uri.class))).thenReturn(mHttpConnection); mPausedExecutorService = new PausedExecutorService(); @@ -201,6 +204,7 @@ private void assertMembers(AuthorizationServiceConfiguration config) { assertEquals(TEST_AUTH_ENDPOINT, config.authorizationEndpoint.toString()); assertEquals(TEST_TOKEN_ENDPOINT, config.tokenEndpoint.toString()); assertEquals(TEST_REGISTRATION_ENDPOINT, config.registrationEndpoint.toString()); + assertEquals(TEST_DEVICE_AUTH_ENDPOINT, config.deviceAuthorizationEndpoint.toString()); assertEquals(TEST_END_SESSION_ENDPOINT, config.endSessionEndpoint.toString()); } diff --git a/library/javatests/net/openid/appauth/AuthorizationServiceDiscoveryTest.java b/library/javatests/net/openid/appauth/AuthorizationServiceDiscoveryTest.java index 3e2e2788..ef37cfee 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_DEVICE_AUTHORIZATION_ENDPOINT = "http://test.openid.com/o/oauth/device"; 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"); @@ -54,6 +55,7 @@ public class AuthorizationServiceDiscoveryTest { TEST_TOKEN_ENDPOINT, TEST_USERINFO_ENDPOINT, TEST_REGISTRATION_ENDPOINT, + TEST_DEVICE_AUTHORIZATION_ENDPOINT, TEST_END_SESSION_ENDPOINT, TEST_JWKS_URI, TEST_RESPONSE_TYPES_SUPPORTED, diff --git a/library/javatests/net/openid/appauth/AuthorizationServiceTest.java b/library/javatests/net/openid/appauth/AuthorizationServiceTest.java index 2010f836..57d65db0 100644 --- a/library/javatests/net/openid/appauth/AuthorizationServiceTest.java +++ b/library/javatests/net/openid/appauth/AuthorizationServiceTest.java @@ -21,6 +21,8 @@ import android.content.Intent; import android.graphics.Color; import android.net.Uri; +import android.os.Looper; + import androidx.annotation.ColorInt; import androidx.annotation.Nullable; import androidx.browser.customtabs.CustomTabsClient; @@ -56,6 +58,10 @@ import java.io.OutputStream; import java.net.HttpURLConnection; import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import static androidx.browser.customtabs.CustomTabsIntent.EXTRA_TITLE_VISIBILITY_STATE; import static androidx.browser.customtabs.CustomTabsIntent.EXTRA_TOOLBAR_COLOR; @@ -67,6 +73,11 @@ import static net.openid.appauth.TestValues.TEST_CLIENT_ID; import static net.openid.appauth.TestValues.TEST_CLIENT_SECRET; import static net.openid.appauth.TestValues.TEST_CLIENT_SECRET_EXPIRES_AT; +import static net.openid.appauth.TestValues.TEST_DEVICE_CODE; +import static net.openid.appauth.TestValues.TEST_DEVICE_CODE_EXPIRES_IN; +import static net.openid.appauth.TestValues.TEST_DEVICE_CODE_POLL_INTERVAL; +import static net.openid.appauth.TestValues.TEST_DEVICE_USER_CODE; +import static net.openid.appauth.TestValues.TEST_DEVICE_VERIFICATION_URI; import static net.openid.appauth.TestValues.TEST_ID_TOKEN; import static net.openid.appauth.TestValues.TEST_NONCE; import static net.openid.appauth.TestValues.TEST_REFRESH_TOKEN; @@ -74,11 +85,14 @@ 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.getTestDeviceAuthorizationRequest; +import static net.openid.appauth.TestValues.getTestDeviceAuthorizationRequestBuilder; 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; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; @@ -108,6 +122,14 @@ public class AuthorizationServiceTest { + " \"application_type\": " + RegistrationRequest.APPLICATION_TYPE_NATIVE + "\n" + "}"; + private static final String DEVICE_AUTH_RESPONSE_JSON = "{\n" + + " \"device_code\": \"" + TEST_DEVICE_CODE + "\",\n" + + " \"user_code\": \"" + TEST_DEVICE_USER_CODE + "\",\n" + + " \"verification_uri\": \"" + TEST_DEVICE_VERIFICATION_URI + "\",\n" + + " \"expires_in\": " + TEST_DEVICE_CODE_EXPIRES_IN + ",\n" + + " \"interval\": " + TEST_DEVICE_CODE_POLL_INTERVAL + "\n" + + "}"; + private static final String INVALID_GRANT_RESPONSE_JSON = "{\n" + " \"error\": \"invalid_grant\",\n" + " \"error_description\": \"invalid_grant description\"\n" @@ -122,6 +144,7 @@ public class AuthorizationServiceTest { private AutoCloseable mMockitoCloseable; private AuthorizationCallback mAuthCallback; private RegistrationCallback mRegistrationCallback; + private DeviceAuthorizationCallback mDeviceAuthCallback; private AuthorizationService mService; private OutputStream mOutputStream; private BrowserDescriptor mBrowserDescriptor; @@ -139,6 +162,7 @@ public void setUp() throws Exception { mMockitoCloseable = MockitoAnnotations.openMocks(this); mAuthCallback = new AuthorizationCallback(); mRegistrationCallback = new RegistrationCallback(); + mDeviceAuthCallback = new DeviceAuthorizationCallback(); mBrowserDescriptor = Browsers.Chrome.customTab("46"); mService = new AuthorizationService( mContext, @@ -445,6 +469,129 @@ public void testTokenRequest_IoException() throws Exception { assertEquals(GeneralErrors.NETWORK_ERROR, mAuthCallback.error); } + @Test + public void testTokenPollRequest() throws Exception { + InputStream is = new ByteArrayInputStream(getAuthCodeExchangeResponseJson().getBytes()); + when(mHttpConnection.getInputStream()).thenReturn(is); + when(mHttpConnection.getRequestProperty("Accept")).thenReturn(null); + when(mHttpConnection.getResponseCode()).thenReturn(HttpURLConnection.HTTP_OK); + TokenRequest request = getTestAuthCodeExchangeRequest(); + Long currentTime = SystemClock.INSTANCE.getCurrentTimeMillis(); + Long expirationTime = currentTime + TimeUnit.SECONDS.toMillis(TEST_EXPIRES_IN); + mService.performTokenPollRequest(request, + 0L, + expirationTime, + mAuthCallback); + mPausedExecutorService.runAll(); + shadowOf(getMainLooper()).idle(); + assertTokenResponse(mAuthCallback.response, request); + String postBody = mOutputStream.toString(); + + // by default, we set application/json as an acceptable response type if a value was not + // already set + verify(mHttpConnection).setRequestProperty("Accept", "application/json"); + + Map params = UriUtil.formUrlDecodeUnique(postBody); + + for (Map.Entry requestParam : request.getRequestParameters().entrySet()) { + assertThat(params).containsEntry(requestParam.getKey(), requestParam.getValue()); + } + + assertThat(params).containsEntry(TokenRequest.PARAM_CLIENT_ID, request.clientId); + } + + @Test + public void testTokenPollRequest_withSlowDown() throws Exception { + InputStream slowDownIs = new ByteArrayInputStream(getDeviceAuthSlowDownJson().getBytes()); + InputStream successIs = new ByteArrayInputStream(getAuthCodeExchangeResponseJson().getBytes()); + when(mHttpConnection.getInputStream()).thenReturn(slowDownIs, successIs); + when(mHttpConnection.getRequestProperty("Accept")).thenReturn(null); + when(mHttpConnection.getResponseCode()).thenReturn(HttpURLConnection.HTTP_OK); + TokenRequest request = getTestAuthCodeExchangeRequest(); + Long currentTime = SystemClock.INSTANCE.getCurrentTimeMillis(); + Long expirationTime = currentTime + TimeUnit.SECONDS.toMillis(TEST_EXPIRES_IN); + mService.performTokenPollRequest(request, + 0L, + expirationTime, + mAuthCallback); + mPausedExecutorService.runAll(); + shadowOf(getMainLooper()).idle(); + assertTokenResponse(mAuthCallback.response, request); + + Long interval = TimeUnit.SECONDS.toMillis(AuthorizationService.TokenRequestPollingTask.DEFAULT_INTERVAL); + assertThat(SystemClock.INSTANCE.getCurrentTimeMillis() - currentTime) + .isGreaterThanOrEqualTo(interval); + } + + @Test + public void testTokenPollRequest_withPending() throws Exception { + InputStream pendingIs = new ByteArrayInputStream(getDeviceAuthPendingJson().getBytes()); + InputStream successIs = new ByteArrayInputStream(getAuthCodeExchangeResponseJson().getBytes()); + when(mHttpConnection.getInputStream()).thenReturn(pendingIs, successIs); + when(mHttpConnection.getRequestProperty("Accept")).thenReturn(null); + when(mHttpConnection.getResponseCode()).thenReturn(HttpURLConnection.HTTP_OK); + TokenRequest request = getTestAuthCodeExchangeRequest(); + Long currentTime = SystemClock.INSTANCE.getCurrentTimeMillis(); + Long expirationTime = currentTime + TimeUnit.SECONDS.toMillis(TEST_EXPIRES_IN); + mService.performTokenPollRequest(request, + 0L, + expirationTime, + mAuthCallback); + mPausedExecutorService.runAll(); + shadowOf(getMainLooper()).idle(); + assertTokenResponse(mAuthCallback.response, request); + } + + @Test + public void testTokenPollRequest_withExpiration() throws Exception { + InputStream is = new ByteArrayInputStream(getDeviceAuthPendingJson().getBytes()); + when(mHttpConnection.getInputStream()).thenReturn(is); + when(mHttpConnection.getRequestProperty("Accept")).thenReturn(null); + when(mHttpConnection.getResponseCode()).thenReturn(HttpURLConnection.HTTP_OK); + TokenRequest request = getTestAuthCodeExchangeRequest(); + Long currentTime = SystemClock.INSTANCE.getCurrentTimeMillis(); + mService.performTokenPollRequest(request, + 0L, + currentTime, + mAuthCallback); + mPausedExecutorService.runAll(); + shadowOf(getMainLooper()).idle(); + assertNotNull(mAuthCallback.error); + assertEquals(AuthorizationException.DeviceCodeRequestErrors.EXPIRED_TOKEN, mAuthCallback.error); + } + + @Test + public void testTokenPollRequest_interruptException() throws Exception { + when(mHttpConnection.getInputStream()).thenAnswer(invocation -> + new ByteArrayInputStream(getDeviceAuthPendingJson().getBytes())); + when(mHttpConnection.getResponseCode()).thenReturn(HttpURLConnection.HTTP_OK); + Long currentTime = SystemClock.INSTANCE.getCurrentTimeMillis(); + Long expirationTime = currentTime + TimeUnit.SECONDS.toMillis(TEST_EXPIRES_IN); + + CancelAsyncTaskRunnable cancelRunnable = mService.performTokenPollRequest( + getTestAuthCodeExchangeRequest(), + TEST_DEVICE_CODE_POLL_INTERVAL, + expirationTime, + mAuthCallback); + cancelRunnable.run(); + mPausedExecutorService.runAll(); + shadowOf(getMainLooper()).idle(); + assertNotNull(mAuthCallback.error); + assertEquals(AuthorizationException.DeviceCodeRequestErrors.CLIENT_ERROR, mAuthCallback.error); + } + + @Test + public void testTokenPollRequest_ioException() throws Exception { + Exception ex = new IOException(); + when(mHttpConnection.getInputStream()).thenThrow(ex); + when(mHttpConnection.getResponseCode()).thenReturn(HttpURLConnection.HTTP_OK); + mService.performTokenPollRequest(getTestAuthCodeExchangeRequest(), 0L, 0L, mAuthCallback); + mPausedExecutorService.runAll(); + shadowOf(getMainLooper()).idle(); + assertNotNull(mAuthCallback.error); + assertEquals(GeneralErrors.NETWORK_ERROR, mAuthCallback.error); + } + @Test public void testRegistrationRequest() throws Exception { InputStream is = new ByteArrayInputStream(REGISTRATION_RESPONSE_JSON.getBytes()); @@ -469,6 +616,42 @@ public void testRegistrationRequest_IoException() throws Exception { assertEquals(GeneralErrors.NETWORK_ERROR, mRegistrationCallback.error); } + @Test + public void testDeviceAuthRequest() throws Exception { + InputStream is = new ByteArrayInputStream(DEVICE_AUTH_RESPONSE_JSON.getBytes()); + when(mHttpConnection.getInputStream()).thenReturn(is); + when(mHttpConnection.getRequestProperty("Accept")).thenReturn(null); + when(mHttpConnection.getResponseCode()).thenReturn(HttpURLConnection.HTTP_OK); + DeviceAuthorizationRequest request = getTestDeviceAuthorizationRequest(); + mService.performDeviceAuthorizationRequest(request, mDeviceAuthCallback); + mPausedExecutorService.runAll(); + shadowOf(getMainLooper()).idle(); + assertDeviceAuthResponse(mDeviceAuthCallback.response, request); + String postBody = mOutputStream.toString(); + + verify(mHttpConnection).setRequestProperty("Accept", "application/json"); + + Map params = UriUtil.formUrlDecodeUnique(postBody); + + for (Map.Entry requestParam : request.getRequestParameters().entrySet()) { + assertThat(params).containsEntry(requestParam.getKey(), requestParam.getValue()); + } + + assertThat(params).containsEntry(TokenRequest.PARAM_CLIENT_ID, request.clientId); + } + + @Test + public void testDeviceAuthRequest_IoException() throws Exception { + Exception ex = new IOException(); + when(mHttpConnection.getInputStream()).thenThrow(ex); + mService.performDeviceAuthorizationRequest(getTestDeviceAuthorizationRequest(), + mDeviceAuthCallback); + mPausedExecutorService.runAll(); + shadowOf(getMainLooper()).idle(); + assertNotNull(mDeviceAuthCallback.error); + assertEquals(GeneralErrors.NETWORK_ERROR, mDeviceAuthCallback.error); + } + @Test(expected = IllegalStateException.class) public void testTokenRequest_afterDispose() throws Exception { mService.dispose(); @@ -537,6 +720,16 @@ private void assertRegistrationResponse(RegistrationResponse response, assertThat(response.clientSecretExpiresAt).isEqualTo(TEST_CLIENT_SECRET_EXPIRES_AT); } + private void assertDeviceAuthResponse(DeviceAuthorizationResponse response, + DeviceAuthorizationRequest expectedRequest) { + assertThat(response).isNotNull(); + assertThat(response.request).isEqualTo(expectedRequest); + assertThat(response.deviceCode).isEqualTo(TEST_DEVICE_CODE); + assertThat(response.userCode).isEqualTo(TEST_DEVICE_USER_CODE); + assertThat(response.tokenPollingIntervalTime).isEqualTo(TEST_DEVICE_CODE_POLL_INTERVAL); + assertThat(response.codeExpirationTime).isNotNull(); + } + private void assertTokenRequestBody( String requestBody, Map expectedParameters) { Uri postBody = new Uri.Builder().encodedQuery(requestBody).build(); @@ -575,6 +768,21 @@ public void onRegistrationRequestCompleted( } } + private static class DeviceAuthorizationCallback implements + AuthorizationService.DeviceAuthorizationResponseCallback { + public DeviceAuthorizationResponse response; + public AuthorizationException error; + + @Override + public void onDeviceAuthorizationRequestCompleted( + @Nullable DeviceAuthorizationResponse deviceAuthResponse, + @Nullable AuthorizationException ex) { + assertTrue((deviceAuthResponse == null) ^ (ex == null)); + this.response = deviceAuthResponse; + this.error = ex; + } + } + private void assertRequestIntent(Intent intent, Integer color) { assertEquals(Intent.ACTION_VIEW, intent.getAction()); assertColorMatch(intent, color); @@ -607,6 +815,18 @@ String getAuthCodeExchangeResponseJson() { return getAuthCodeExchangeResponseJson(null); } + String getDeviceAuthPendingJson() { + return "{\n" + + "\"error\":\"authorization_pending\"\n" + + "}"; + } + + String getDeviceAuthSlowDownJson() { + return "{\n" + + "\"error\":\"slow_down\"\n" + + "}"; + } + String getAuthCodeExchangeResponseJson(@Nullable String idToken) { if (idToken == null) { idToken = TEST_ID_TOKEN; diff --git a/library/javatests/net/openid/appauth/CancelAsyncTaskRunnableTest.java b/library/javatests/net/openid/appauth/CancelAsyncTaskRunnableTest.java new file mode 100644 index 00000000..621e5112 --- /dev/null +++ b/library/javatests/net/openid/appauth/CancelAsyncTaskRunnableTest.java @@ -0,0 +1,67 @@ +/* + * Copyright 2021 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 android.os.Looper.getMainLooper; +import static org.assertj.core.api.Assertions.assertThat; +import static org.robolectric.Shadows.shadowOf; + +import android.os.AsyncTask; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.android.util.concurrent.PausedExecutorService; +import org.robolectric.annotation.Config; +import org.robolectric.shadows.ShadowPausedAsyncTask; + +@SuppressWarnings({"deprecation", "UnstableApiUsage"}) +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 16) +public class CancelAsyncTaskRunnableTest +{ + private PausedExecutorService mPausedExecutorService; + + @Before + public void setup() { + mPausedExecutorService = new PausedExecutorService(); + ShadowPausedAsyncTask.overrideExecutor(mPausedExecutorService); + } + + @Test + public void testRun() { + AsyncTask task = new AsyncTask() { + @SuppressWarnings("StatementWithEmptyBody") + @Override + protected Void doInBackground(Void... objects) { + while (!isCancelled()); + return null; + } + }; + + CancelAsyncTaskRunnable cancelRunnable = new CancelAsyncTaskRunnable(task); + assertThat(task.getStatus()).isEqualTo(AsyncTask.Status.PENDING); + + task.execute(); + assertThat(task.getStatus()).isEqualTo(AsyncTask.Status.RUNNING); + + cancelRunnable.run(); + mPausedExecutorService.runAll(); + shadowOf(getMainLooper()).idle(); + + assertThat(task.getStatus()).isEqualTo(AsyncTask.Status.FINISHED); + } +} diff --git a/library/javatests/net/openid/appauth/DeviceAuthorizationRequestTest.java b/library/javatests/net/openid/appauth/DeviceAuthorizationRequestTest.java new file mode 100644 index 00000000..5a78d7cf --- /dev/null +++ b/library/javatests/net/openid/appauth/DeviceAuthorizationRequestTest.java @@ -0,0 +1,176 @@ +/* + * Copyright 2021 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.TestValues.TEST_CLIENT_ID; +import static net.openid.appauth.TestValues.getTestServiceConfig; +import static org.assertj.core.api.Assertions.assertThat; + +import org.json.JSONException; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 16) +public class DeviceAuthorizationRequestTest { + + private static final Map TEST_ADDITIONAL_PARAMS; + + static { + TEST_ADDITIONAL_PARAMS = new HashMap<>(); + TEST_ADDITIONAL_PARAMS.put("test_key1", "test_value1"); + TEST_ADDITIONAL_PARAMS.put("test_key2", "test_value2"); + } + + private DeviceAuthorizationRequest.Builder mRequestBuilder; + + @Before + public void setUp() { + mRequestBuilder = new DeviceAuthorizationRequest.Builder( + getTestServiceConfig(), + TEST_CLIENT_ID); + } + + /* ********************************** Builder() ***********************************************/ + + @Test(expected = NullPointerException.class) + @SuppressWarnings("ConstantConditions") + public void testBuilder_nullConfiguration() { + new DeviceAuthorizationRequest.Builder( + null, + TEST_CLIENT_ID); + } + + @Test(expected = NullPointerException.class) + @SuppressWarnings("ConstantConditions") + public void testBuilder_nullClientId() { + new DeviceAuthorizationRequest.Builder( + getTestServiceConfig(), + null); + } + + @Test(expected = IllegalArgumentException.class) + public void testBuilder_emptyClientId() { + new DeviceAuthorizationRequest.Builder( + getTestServiceConfig(), + ""); + } + + /* ************************************** clientId ********************************************/ + + @Test + public void testClientId_fromConstructor() { + DeviceAuthorizationRequest request = mRequestBuilder.build(); + assertThat(request.clientId).isEqualTo(TEST_CLIENT_ID); + } + + @Test(expected = NullPointerException.class) + @SuppressWarnings("ConstantConditions") + public void testClientId_null() { + mRequestBuilder.setClientId(null).build(); + } + + @Test(expected = IllegalArgumentException.class) + public void testClientId_empty() { + mRequestBuilder.setClientId("").build(); + } + + /* *********************************** scope **************************************************/ + + @Test + public void testScope_null() { + DeviceAuthorizationRequest request = mRequestBuilder + .setScopes((Iterable)null) + .build(); + assertThat(request.scope).isNull(); + } + + @Test + public void testScope_empty() { + DeviceAuthorizationRequest request = mRequestBuilder + .setScopes() + .build(); + assertThat(request.scope).isNull(); + } + + @Test + public void testScope_emptyList() { + DeviceAuthorizationRequest request = mRequestBuilder + .setScopes(Collections.emptyList()) + .build(); + assertThat(request.scope).isNull(); + } + + /* ******************************* additionalParams *******************************************/ + + @Test(expected = IllegalArgumentException.class) + public void testBuilder_setAdditionalParams_withBuiltInParam() { + Map additionalParams = new HashMap<>(); + additionalParams.put(AuthorizationRequest.PARAM_SCOPE, AuthorizationRequest.Scope.EMAIL); + mRequestBuilder.setAdditionalParameters(additionalParams); + } + + /* ************************** jsonSerialize() / jsonDeserialize() *****************************/ + + @Test + public void testJsonSerialize_clientId() throws Exception { + DeviceAuthorizationRequest copy = serializeDeserialize( + mRequestBuilder.setClientId(TEST_CLIENT_ID).build()); + assertThat(copy.clientId).isEqualTo(TEST_CLIENT_ID); + } + + @Test + public void testJsonSerialize_scope() throws Exception { + DeviceAuthorizationRequest copy = serializeDeserialize( + mRequestBuilder.setScope(AuthorizationRequest.Scope.EMAIL).build()); + assertThat(copy.scope).isEqualTo(AuthorizationRequest.Scope.EMAIL); + } + + @Test + public void testSerialization_scopeNull() throws Exception { + DeviceAuthorizationRequest copy = serializeDeserialize( + mRequestBuilder.setScopes((Iterable)null).build()); + assertThat(copy.scope).isNull(); + } + + @Test + public void testSerialization_scopeEmpty() throws Exception { + DeviceAuthorizationRequest copy = serializeDeserialize( + mRequestBuilder + .setScopes(Collections.emptyList()) + .build()); + assertThat(copy.scope).isNull(); + } + + @Test + public void testJsonSerialize_additionalParams() throws Exception { + DeviceAuthorizationRequest copy = serializeDeserialize( + mRequestBuilder.setAdditionalParameters(TEST_ADDITIONAL_PARAMS).build()); + assertThat(copy.additionalParameters).isEqualTo(TEST_ADDITIONAL_PARAMS); + } + + private DeviceAuthorizationRequest serializeDeserialize(DeviceAuthorizationRequest request) + throws JSONException { + return DeviceAuthorizationRequest.jsonDeserialize(request.jsonSerializeString()); + } + +} diff --git a/library/javatests/net/openid/appauth/DeviceAuthorizationResponseTest.java b/library/javatests/net/openid/appauth/DeviceAuthorizationResponseTest.java new file mode 100644 index 00000000..ce0d1e37 --- /dev/null +++ b/library/javatests/net/openid/appauth/DeviceAuthorizationResponseTest.java @@ -0,0 +1,156 @@ +/* + * Copyright 2021 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.TestValues.TEST_DEVICE_CODE; +import static net.openid.appauth.TestValues.TEST_DEVICE_CODE_EXPIRES_IN; +import static net.openid.appauth.TestValues.TEST_DEVICE_CODE_POLL_INTERVAL; +import static net.openid.appauth.TestValues.TEST_DEVICE_USER_CODE; +import static net.openid.appauth.TestValues.TEST_DEVICE_VERIFICATION_COMPLETE_URI; +import static net.openid.appauth.TestValues.TEST_DEVICE_VERIFICATION_URI; +import static net.openid.appauth.TestValues.getMinimalDeviceAuthRequestBuilder; +import static net.openid.appauth.TestValues.getTestDeviceAuthorizationRequest; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatExceptionOfType; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.util.Collections; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 16) +public class DeviceAuthorizationResponseTest { + + // the test is asserted to be running at time 23 + private static final Long TEST_START_TIME = 23L; + // expiration time, in seconds + private static final Long TEST_EXPIRES_IN = 79L; + private static final Long TEST_CODE_EXPIRE_TIME = 79023L; + + private DeviceAuthorizationResponse.Builder mDeviceAuthResponseBuilder; + private DeviceAuthorizationResponse mDeviceAuthResponse; + + TestClock mClock; + + @Before + public void setUp() { + mClock = new TestClock(TEST_START_TIME); + mDeviceAuthResponseBuilder = new DeviceAuthorizationResponse.Builder( + getTestDeviceAuthorizationRequest()) + .setDeviceCode(TEST_DEVICE_CODE) + .setUserCode(TEST_DEVICE_USER_CODE) + .setVerificationUri(TEST_DEVICE_VERIFICATION_URI) + .setVerificationUriComplete(TEST_DEVICE_VERIFICATION_COMPLETE_URI) + .setCodeExpiresIn(TEST_DEVICE_CODE_EXPIRES_IN, mClock) + .setTokenPollingIntervalTime(TEST_DEVICE_CODE_POLL_INTERVAL); + + mDeviceAuthResponse = mDeviceAuthResponseBuilder.build(); + } + + @Test + public void testBuilder() { + checkExpectedFields(mDeviceAuthResponseBuilder.build()); + } + + @Test(expected = IllegalArgumentException.class) + public void testBuild_setAdditionalParams_withBuiltInParam() { + mDeviceAuthResponseBuilder.setAdditionalParameters( + Collections.singletonMap(DeviceAuthorizationResponse.KEY_DEVICE_CODE, + "deviceCode")); + } + + @Test + public void testExpiresIn() { + DeviceAuthorizationResponse deviceAuthResponse = mDeviceAuthResponseBuilder + .setCodeExpiresIn(TEST_EXPIRES_IN, mClock) + .build(); + assertEquals(TEST_CODE_EXPIRE_TIME, deviceAuthResponse.codeExpirationTime); + } + + @Test + public void testExpirationTime() { + DeviceAuthorizationResponse deviceAuthResponse = mDeviceAuthResponseBuilder + .setCodeExpirationTime(TEST_CODE_EXPIRE_TIME) + .build(); + assertEquals(TEST_CODE_EXPIRE_TIME, deviceAuthResponse.codeExpirationTime); + } + + @Test + public void testHasExpired() { + mClock.currentTime.set(TEST_START_TIME + 1); + assertFalse(mDeviceAuthResponse.hasCodeExpired(mClock)); + mClock.currentTime.set(TEST_CODE_EXPIRE_TIME - 1); + assertFalse(mDeviceAuthResponse.hasCodeExpired(mClock)); + mClock.currentTime.set(TEST_CODE_EXPIRE_TIME + 1); + assertTrue(mDeviceAuthResponse.hasCodeExpired(mClock)); + } + + @Test + public void testSerialization() throws Exception { + String json = mDeviceAuthResponse.jsonSerializeString(); + DeviceAuthorizationResponse deviceAuthResponse = DeviceAuthorizationResponse + .jsonDeserialize(json); + checkExpectedFields(deviceAuthResponse); + } + + @Test + public void testCreateTokenExchangeRequest() { + TokenRequest tokenExchangeRequest = mDeviceAuthResponse.createTokenExchangeRequest(); + assertThat(tokenExchangeRequest.grantType) + .isEqualTo(GrantTypeValues.DEVICE_CODE); + assertThat(tokenExchangeRequest.deviceCode) + .isEqualTo(TEST_DEVICE_CODE); + } + + @Test + public void testCreateTokenExchangeRequest_failsForImplicitFlowResponse() { + // simulate an implicit flow request and response + DeviceAuthorizationRequest request = getMinimalDeviceAuthRequestBuilder().build(); + DeviceAuthorizationResponse response = new DeviceAuthorizationResponse.Builder(request) + .setUserCode(TEST_DEVICE_USER_CODE) + .setVerificationUri(TEST_DEVICE_VERIFICATION_URI) + .setVerificationUriComplete(TEST_DEVICE_VERIFICATION_COMPLETE_URI) + .setCodeExpiresIn(TEST_DEVICE_CODE_EXPIRES_IN) + .setTokenPollingIntervalTime(TEST_DEVICE_CODE_POLL_INTERVAL) + .build(); + + // as there is no device code in the response, this will fail + assertThatExceptionOfType(IllegalStateException.class) + .isThrownBy(response::createTokenExchangeRequest) + .withMessage("deviceCode not available for exchange request"); + } + + private void checkExpectedFields(DeviceAuthorizationResponse deviceAuthResponse) { + assertEquals("device code does not match", + TEST_DEVICE_CODE, deviceAuthResponse.deviceCode); + assertEquals("user code does not match", + TEST_DEVICE_USER_CODE, deviceAuthResponse.userCode); + assertEquals("verification uri does not match", + TEST_DEVICE_VERIFICATION_URI, deviceAuthResponse.verificationUri); + assertEquals("verification uri complete does not match", + TEST_DEVICE_VERIFICATION_COMPLETE_URI, deviceAuthResponse.verificationUriComplete); + assertEquals("user code expiration time does not match", + TEST_CODE_EXPIRE_TIME, deviceAuthResponse.codeExpirationTime); + assertEquals("access token polling interval does not match", + TEST_DEVICE_CODE_POLL_INTERVAL, deviceAuthResponse.tokenPollingIntervalTime); + } +} diff --git a/library/javatests/net/openid/appauth/IdTokenTest.java b/library/javatests/net/openid/appauth/IdTokenTest.java index 5014650c..fc4059eb 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_DEVICE_AUTHORIZATION_ENDPOINT; 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; @@ -462,6 +463,7 @@ private String getDiscoveryDocJsonWithIssuer(String issuer) { TEST_TOKEN_ENDPOINT, TEST_USERINFO_ENDPOINT, TEST_REGISTRATION_ENDPOINT, + TEST_DEVICE_AUTHORIZATION_ENDPOINT, TEST_END_SESSION_ENDPOINT, TEST_JWKS_URI, TEST_RESPONSE_TYPES_SUPPORTED, diff --git a/library/javatests/net/openid/appauth/TestValues.java b/library/javatests/net/openid/appauth/TestValues.java index 27e57a59..79cca552 100644 --- a/library/javatests/net/openid/appauth/TestValues.java +++ b/library/javatests/net/openid/appauth/TestValues.java @@ -55,6 +55,13 @@ class TestValues { null ); public static final String TEST_REFRESH_TOKEN = "asdfghjkl"; + public static final String TEST_DEVICE_CODE = "zzbbbdddz"; + public static final String TEST_DEVICE_USER_CODE = "abcd-efgh"; + public static final String TEST_DEVICE_VERIFICATION_URI = "https://testidp.example.com/device"; + public static final String TEST_DEVICE_VERIFICATION_COMPLETE_URI = + "https://testidp.example.com/device?code=ABCDEF"; + public static final Long TEST_DEVICE_CODE_EXPIRES_IN = 79L; + public static final Long TEST_DEVICE_CODE_POLL_INTERVAL = 3L; public static final Long TEST_CLIENT_SECRET_EXPIRES_AT = 78L; public static final String TEST_CLIENT_SECRET = "test_client_secret"; @@ -71,6 +78,7 @@ static String getDiscoveryDocumentJson( String tokenEndpoint, String userInfoEndpoint, String registrationEndpoint, + String deviceAuthorizationEndpoint, String endSessionEndpoint, String jwksUri, List responseTypesSupported, @@ -87,6 +95,7 @@ static String getDiscoveryDocumentJson( + " \"userinfo_endpoint\": \"" + userInfoEndpoint + "\",\n" + " \"end_session_endpoint\": \"" + endSessionEndpoint + "\",\n" + " \"registration_endpoint\": \"" + registrationEndpoint + "\",\n" + + " \"device_authorization_endpoint\": \"" + deviceAuthorizationEndpoint + "\",\n" + " \"jwks_uri\": \"" + jwksUri + "\",\n" + " \"response_types_supported\": " + toJson(responseTypesSupported) + ",\n" + " \"subject_types_supported\": " + toJson(subjectTypesSupported) + ",\n" @@ -185,15 +194,31 @@ public static RegistrationRequest.Builder getTestRegistrationRequestBuilder() { Arrays.asList(TEST_APP_REDIRECT_URI)); } + public static DeviceAuthorizationRequest.Builder getTestDeviceAuthorizationRequestBuilder() { + return new DeviceAuthorizationRequest.Builder(getTestServiceConfig(), TEST_CLIENT_ID); + } + public static RegistrationRequest getTestRegistrationRequest() { return getTestRegistrationRequestBuilder().build(); } + public static DeviceAuthorizationRequest getTestDeviceAuthorizationRequest() { + return getTestDeviceAuthorizationRequestBuilder().build(); + } + + public static DeviceAuthorizationRequest.Builder getMinimalDeviceAuthRequestBuilder() { + return new DeviceAuthorizationRequest.Builder(getTestServiceConfig(), TEST_CLIENT_ID); + } + public static RegistrationResponse.Builder getTestRegistrationResponseBuilder() { return new RegistrationResponse.Builder(getTestRegistrationRequest()) .setClientId(TEST_CLIENT_ID); } + public static DeviceAuthorizationResponse.Builder getTestDeviceAuthorizationResponseBuilder() { + return new DeviceAuthorizationResponse.Builder(getTestDeviceAuthorizationRequest()); + } + public static RegistrationResponse getTestRegistrationResponse() { return getTestRegistrationResponseBuilder() .setClientSecret(TEST_CLIENT_SECRET) @@ -201,6 +226,16 @@ public static RegistrationResponse getTestRegistrationResponse() { .build(); } + public static DeviceAuthorizationResponse getTestDeviceAuthorizationResponse() { + return getTestDeviceAuthorizationResponseBuilder() + .setDeviceCode(TEST_DEVICE_CODE) + .setVerificationUri(TEST_DEVICE_VERIFICATION_URI) + .setVerificationUriComplete(TEST_DEVICE_VERIFICATION_COMPLETE_URI) + .setCodeExpiresIn(TEST_DEVICE_CODE_EXPIRES_IN) + .setTokenPollingIntervalTime(TEST_DEVICE_CODE_POLL_INTERVAL) + .build(); + } + public static String getTestIdTokenWithNonce(String nonce) { return IdTokenTest.getUnsignedIdToken( TEST_ISSUER, diff --git a/library/javatests/net/openid/appauth/TokenRequestTest.java b/library/javatests/net/openid/appauth/TokenRequestTest.java index baec30a5..95937220 100644 --- a/library/javatests/net/openid/appauth/TokenRequestTest.java +++ b/library/javatests/net/openid/appauth/TokenRequestTest.java @@ -35,6 +35,7 @@ public class TokenRequestTest { private static final String TEST_AUTHORIZATION_CODE = "ABCDEFGH"; private static final String TEST_REFRESH_TOKEN = "IJKLMNOP"; + private static final String TEST_DEVICE_CODE = "QRSTUVWX"; private TokenRequest.Builder mMinimalBuilder; private TokenRequest.Builder mAuthorizationCodeRequestBuilder; @@ -78,6 +79,13 @@ public void testBuild_emptyAuthorizationCode() { .build(); } + @Test(expected = IllegalArgumentException.class) + public void testBuild_emptyDeviceCode() { + mMinimalBuilder + .setDeviceCode("") + .build(); + } + @Test(expected = IllegalArgumentException.class) public void testBuild_emptyRefreshToken() { mMinimalBuilder @@ -131,6 +139,21 @@ public void testGetRequestParameters_forCodeExchange() { TEST_APP_REDIRECT_URI.toString()); } + @Test + public void testGetRequestParameters_forDeviceCode() { + TokenRequest request = mMinimalBuilder + .setDeviceCode(TEST_DEVICE_CODE) + .build(); + + Map params = request.getRequestParameters(); + assertThat(params).containsEntry( + TokenRequest.PARAM_GRANT_TYPE, + GrantTypeValues.DEVICE_CODE); + assertThat(params).containsEntry( + TokenRequest.PARAM_DEVICE_CODE, + TEST_DEVICE_CODE); + } + @Test public void testGetRequestParameters_forRefreshToken() { TokenRequest request = mMinimalBuilder From 27173fcbe8e9ebd01ee1ff739646107027dc1f31 Mon Sep 17 00:00:00 2001 From: Victor Solevic Date: Fri, 12 Nov 2021 16:26:01 +0100 Subject: [PATCH 3/3] Update readme with Device Authorization Grant documentation --- README.md | 130 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 128 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3351720c..579a019a 100644 --- a/README.md +++ b/README.md @@ -22,12 +22,17 @@ including using for authorization requests. For this reason, `WebView` is explicitly *not* supported due to usability and security reasons. -The library also supports the [PKCE](https://tools.ietf.org/html/rfc7636) +The library supports the [PKCE](https://tools.ietf.org/html/rfc7636) extension to OAuth which was created to secure authorization codes in public clients when custom URI scheme redirects are used. The library is friendly to other extensions (standard or otherwise) with the ability to handle additional parameters in all protocol requests and responses. +The library also offers device flow support as described in +[RFC 8628 - Device Authorization Grant](https://tools.ietf.org/html/rfc8628) +for authenticating devices that either lack a browser or have limited +input capabilities to fully perform the traditional authentication flow. + A talk providing an overview of using the library for enterprise single sign-on (produced by Google) can be found here: [Enterprise SSO with Chrome Custom Tabs](https://www.youtube.com/watch?v=DdQTXrk6YTk). @@ -49,7 +54,9 @@ be used with the library. In general, AppAuth can work with any Authorization Server (AS) that supports native apps as documented in [RFC 8252](https://tools.ietf.org/html/rfc8252), -either through custom URI scheme redirects, or App Links. +either through custom URI scheme redirects, or App Links. For the device flow, +the AS would need to properly support the device authorization grant as +documented in [RFC 8628](https://tools.ietf.org/html/rfc8628). AS's that assume all clients are web-based or require clients to maintain confidentiality of the client secrets may not work well. @@ -514,6 +521,125 @@ public void writeAuthState(@NonNull AuthState state) { The demo app has an [AuthStateManager](https://github.com/openid/AppAuth-Android/blob/master/app/java/net/openid/appauthdemo/AuthStateManager.java) type which demonstrates this in more detail. +## Implementing the device flow + +Native apps that run on devices that lack a suitable browser integration or +have limited input capabilities should use the +[device flow](https://datatracker.ietf.org/doc/html/rfc8628#section-1) +to authorize the limited device through another, fully capable, device. + +This flow is effectively composed of 4 stages: + +1. Discovering or specifying the endpoints to interact with the provider. +2. Exchanging the client identifier to the authorization server to obtain a + device code, an end-user code and the end-user verification URI. +3. The end-user enters the code provided by the authorization server at the + verification URI on a fully capable device, proceeds through the + authentication flow and reviews the authorization request associated to the + code. +4. While the end-user completes the authorization process on the other device, + polling on the authorization server most be performed with the device code + and client identifier to obtain a refresh token and/or ID token. + +### Authorization service configuration + +The authorization service configuration construction is the same as for the +regular authorization flow. + +Refer to the authorization code flow +[authorization service configuration](#authorization-service-configuration) +and use the according constructor to build a configuration with a device +authorization endpoint in case of manual creation, otherwise, verify that your +authorization server exposes a device authorization endpoint through the OpenID +Connect discovery document. + +### Obtaining an end-user code + +A device authorization request can be performed to obtain the device +verification URI and the end-user code by constructing a +`DeviceAuthorizationRequest`, using its Builder: + +```java +DeviceAuthorizationRequest deviceAuthRequest = + new DeviceAuthorizationRequest.Builder(authorizationServiceConfiguration, clientId) + .setScope("openid email profile") + .build(); +``` + +The request can then be executed through the `AuthorizationService`: + +```java +authService.performDeviceAuthorizationRequest( + deviceAuthRequest, + new AuthorizationService.DeviceAuthorizationResponseCallback() { + @Override public void onDeviceAuthorizationRequestCompleted( + DeviceAuthorizationResponse resp, AuthorizationException ex) { + if (resp != null) { + // device authorization succeeded + } else { + // authorization failed, check ex for more details + } + } + }); +``` + +The response can be provided to the `AuthState` instance for easy persistence +and further processing: + +```java +authState.update(resp, ex); +``` + +### Exchanging the end-user code + +Given a successful device authorization response carrying an end-user code, +a token request can be made to exchange the code for a refresh token: + +```java +authService.performTokenPollRequestRequest( + resp.createTokenExchangeRequest(), + resp.tokenPollingIntervalTime, + resp.codeExpirationTime, + new AuthorizationService.TokenResponseCallback() { + @Override public void onTokenRequestCompleted( + TokenResponse resp, AuthorizationException ex) { + if (resp != null) { + // exchange succeeded + } else { + // authorization failed, check ex for more details + } + } + }); +``` + +Alternatively, the `AuthState` exposes a helper method to perform the polling +after a successful device authorization request has been completed: + +```java +authState.performTokenPollRequest(authService, + new AuthorizationService.TokenResponseCallback() { + @Override public void onTokenRequestCompleted( + TokenResponse resp, AuthorizationException ex) { + if (resp != null) { + // exchange succeeded + } else { + // authorization failed, check ex for more details + } + } + }); +``` + +In both use cases, the token response can then be used to update an `AuthState` +instance: + +```java +authState.update(resp, ex); +``` + +### Ending current session + +Revoking ID tokens and refresh tokens is currently not supported. + ## Advanced configuration AppAuth provides some advanced configuration options via