/* * Copyright 2015 The AppAuth for Android Authors. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except * in compliance with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under the * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either * express or implied. See the License for the specific language governing permissions and * limitations under the License. */ package net.openid.appauth; import static net.openid.appauth.AdditionalParamsProcessor.checkAdditionalParams; import static net.openid.appauth.AdditionalParamsProcessor.extractAdditionalParams; import static net.openid.appauth.Preconditions.checkNotNull; import static net.openid.appauth.Preconditions.checkNullOrNotEmpty; import android.content.Intent; import android.net.Uri; import android.text.TextUtils; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import net.openid.appauth.internal.UriUtil; import org.json.JSONException; import org.json.JSONObject; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; import java.util.concurrent.TimeUnit; /** * A response to an authorization request. * * @see AuthorizationRequest * @see "The OAuth 2.0 Authorization Framework (RFC 6749), Section 4.1.2 * " */ public class AuthorizationResponse extends AuthorizationManagementResponse { /** * The extra string used to store an {@link AuthorizationResponse} in an intent by * {@link #toIntent()}. */ public static final String EXTRA_RESPONSE = "net.openid.appauth.AuthorizationResponse"; /** * Indicates that a provided access token is a bearer token. * @see "The OAuth 2.0 Authorization Framework (RFC 6749), Section 7.1 " */ public static final String TOKEN_TYPE_BEARER = "bearer"; @VisibleForTesting static final String KEY_REQUEST = "request"; @VisibleForTesting static final String KEY_ADDITIONAL_PARAMETERS = "additional_parameters"; @VisibleForTesting static final String KEY_EXPIRES_AT = "expires_at"; // TODO: rename all KEY_* below to PARAM_* - they are standard OAuth2 parameters @VisibleForTesting static final String KEY_STATE = "state"; @VisibleForTesting static final String KEY_TOKEN_TYPE = "token_type"; @VisibleForTesting static final String KEY_AUTHORIZATION_CODE = "code"; @VisibleForTesting static final String KEY_ACCESS_TOKEN = "access_token"; @VisibleForTesting static final String KEY_EXPIRES_IN = "expires_in"; @VisibleForTesting static final String KEY_ID_TOKEN = "id_token"; @VisibleForTesting static final String KEY_SCOPE = "scope"; private static final Set BUILT_IN_PARAMS = Collections.unmodifiableSet( new HashSet<>(Arrays.asList( KEY_TOKEN_TYPE, KEY_STATE, KEY_AUTHORIZATION_CODE, KEY_ACCESS_TOKEN, KEY_EXPIRES_IN, KEY_ID_TOKEN, KEY_SCOPE))); /** * The authorization request associated with this response. */ @NonNull public final AuthorizationRequest request; /** * The returned state parameter, which must match the value specified in the request. * AppAuth for Android ensures that this is the case. */ @Nullable public final String state; /** * The type of the retrieved token. Typically this is "Bearer" when present. Otherwise, * another token_type value that the Client has negotiated with the Authorization Server. * * @see "OpenID Connect Core 1.0, Section 3.2.2.5 * " */ @Nullable public final String tokenType; /** * The authorization code generated by the authorization server. * Set when the response_type requested includes 'code'. */ @Nullable public final String authorizationCode; /** * The access token retrieved as part of the authorization flow. * This is available when the {@link AuthorizationRequest#responseType response_type} * of the request included 'token'. * * @see "OpenID Connect Core 1.0, Section 3.2.2.5 * " */ @Nullable public final String accessToken; /** * The approximate expiration time of the access token, as milliseconds from the UNIX epoch. * Set when the requested {@link AuthorizationRequest#responseType response_type} * included 'token'. * * @see "OpenID Connect Core 1.0, Section 3.2.2.5 * " */ @Nullable public final Long accessTokenExpirationTime; /** * The id token retrieved as part of the authorization flow. * This is available when the {@link AuthorizationRequest#responseType response_type} * of the request included 'id_token'. * * @see "OpenID Connect Core 1.0, Section 2 * " * @see "OpenID Connect Core 1.0, Section 3.2.2.5 * " */ @Nullable public final String idToken; /** * The scope of the returned access token. If this is not specified, the scope is assumed * to be the same as what was originally requested. */ @Nullable public final String scope; /** * The additional, non-standard parameters in the response. */ @NonNull public final Map additionalParameters; /** * Creates instances of {@link AuthorizationResponse}. */ public static final class Builder { @NonNull private AuthorizationRequest mRequest; @Nullable private String mState; @Nullable private String mTokenType; @Nullable private String mAuthorizationCode; @Nullable private String mAccessToken; @Nullable private Long mAccessTokenExpirationTime; @Nullable private String mIdToken; @Nullable private String mScope; @NonNull private Map mAdditionalParameters; /** * Creates an authorization builder with the specified mandatory properties. */ public Builder(@NonNull AuthorizationRequest request) { mRequest = checkNotNull(request, "authorization request cannot be null"); mAdditionalParameters = new LinkedHashMap<>(); } /** * Extracts authorization response parameters from the query portion of a redirect URI. */ @NonNull public Builder fromUri(@NonNull Uri uri) { return fromUri(uri, SystemClock.INSTANCE); } @NonNull @VisibleForTesting Builder fromUri(@NonNull Uri uri, @NonNull Clock clock) { setState(uri.getQueryParameter(KEY_STATE)); setTokenType(uri.getQueryParameter(KEY_TOKEN_TYPE)); setAuthorizationCode(uri.getQueryParameter(KEY_AUTHORIZATION_CODE)); setAccessToken(uri.getQueryParameter(KEY_ACCESS_TOKEN)); setAccessTokenExpiresIn(UriUtil.getLongQueryParameter(uri, KEY_EXPIRES_IN), clock); setIdToken(uri.getQueryParameter(KEY_ID_TOKEN)); setScope(uri.getQueryParameter(KEY_SCOPE)); setAdditionalParameters(extractAdditionalParams(uri, BUILT_IN_PARAMS)); return this; } /** * Specifies the OAuth 2 state. */ @NonNull public Builder setState(@Nullable String state) { checkNullOrNotEmpty(state, "state must not be empty"); mState = state; return this; } /** * Specifies the OAuth 2 token type. */ @NonNull public Builder setTokenType(@Nullable String tokenType) { checkNullOrNotEmpty(tokenType, "tokenType must not be empty"); mTokenType = tokenType; return this; } /** * Specifies the OAuth 2 authorization code. */ @NonNull public Builder setAuthorizationCode(@Nullable String authorizationCode) { checkNullOrNotEmpty(authorizationCode, "authorizationCode must not be empty"); mAuthorizationCode = authorizationCode; return this; } /** * Specifies the OAuth 2 access token. */ @NonNull public Builder setAccessToken(@Nullable String accessToken) { checkNullOrNotEmpty(accessToken, "accessToken must not be empty"); mAccessToken = accessToken; return this; } /** * Specifies the expiration period of the OAuth 2 access token. */ @NonNull public Builder setAccessTokenExpiresIn(@Nullable Long expiresIn) { return setAccessTokenExpiresIn(expiresIn, SystemClock.INSTANCE); } /** * Specifies the relative expiration time of the access token, in seconds, using the * provided clock as the source of the current time. */ @NonNull @VisibleForTesting public Builder setAccessTokenExpiresIn(@Nullable Long expiresIn, @NonNull Clock clock) { if (expiresIn == null) { mAccessTokenExpirationTime = null; } else { mAccessTokenExpirationTime = clock.getCurrentTimeMillis() + TimeUnit.SECONDS.toMillis(expiresIn); } return this; } /** * Specifies the expiration time of the OAuth 2 access token. */ @NonNull public Builder setAccessTokenExpirationTime(@Nullable Long expirationTime) { mAccessTokenExpirationTime = expirationTime; return this; } /** * Specifies the OAuth 2 Id token. */ @NonNull public Builder setIdToken(@Nullable String idToken) { checkNullOrNotEmpty(idToken, "idToken cannot be empty"); mIdToken = idToken; 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. 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) { mScope = null; } else { setScopes(Arrays.asList(scopes)); } return this; } /** * Specifies the set of case-sensitive scopes. Replaces any previously specified set of * scopes. 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 the additional set of parameters received as part of the response. */ @NonNull public Builder setAdditionalParameters(@Nullable Map additionalParameters) { mAdditionalParameters = checkAdditionalParams(additionalParameters, BUILT_IN_PARAMS); return this; } /** * Builds the Authorization object. */ @NonNull public AuthorizationResponse build() { return new AuthorizationResponse( mRequest, mState, mTokenType, mAuthorizationCode, mAccessToken, mAccessTokenExpirationTime, mIdToken, mScope, Collections.unmodifiableMap(mAdditionalParameters)); } } private AuthorizationResponse( @NonNull AuthorizationRequest request, @Nullable String state, @Nullable String tokenType, @Nullable String authorizationCode, @Nullable String accessToken, @Nullable Long accessTokenExpirationTime, @Nullable String idToken, @Nullable String scope, @NonNull Map additionalParameters) { this.request = request; this.state = state; this.tokenType = tokenType; this.authorizationCode = authorizationCode; this.accessToken = accessToken; this.accessTokenExpirationTime = accessTokenExpirationTime; this.idToken = idToken; this.scope = scope; this.additionalParameters = additionalParameters; } /** * Determines whether the returned access token has expired. */ public boolean hasAccessTokenExpired() { return hasAccessTokenExpired(SystemClock.INSTANCE); } @VisibleForTesting boolean hasAccessTokenExpired(@NonNull Clock clock) { return accessTokenExpirationTime != null && checkNotNull(clock).getCurrentTimeMillis() > accessTokenExpirationTime; } /** * Derives the set of scopes from the consolidated, space-delimited scopes in the * {@link #scope} field. If no scopes were specified on this response, the method will * return `null`. */ @Nullable public Set getScopeSet() { return AsciiStringListUtil.stringToSet(scope); } /** * 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 (authorizationCode == null) { throw new IllegalStateException("authorizationCode not available for exchange request"); } return new TokenRequest.Builder( request.configuration, request.clientId) .setGrantType(GrantTypeValues.AUTHORIZATION_CODE) .setRedirectUri(request.redirectUri) .setCodeVerifier(request.codeVerifier) .setAuthorizationCode(authorizationCode) .setAdditionalParameters(additionalExchangeParameters) .setNonce(request.nonce) .build(); } @Override @Nullable public String getState() { return state; } /** * Produces a JSON representation of the authorization response for persistent storage or local * transmission (e.g. between activities). */ @Override @NonNull public JSONObject jsonSerialize() { JSONObject json = new JSONObject(); JsonUtil.put(json, KEY_REQUEST, request.jsonSerialize()); JsonUtil.putIfNotNull(json, KEY_STATE, state); JsonUtil.putIfNotNull(json, KEY_TOKEN_TYPE, tokenType); JsonUtil.putIfNotNull(json, KEY_AUTHORIZATION_CODE, authorizationCode); JsonUtil.putIfNotNull(json, KEY_ACCESS_TOKEN, accessToken); JsonUtil.putIfNotNull(json, KEY_EXPIRES_AT, accessTokenExpirationTime); JsonUtil.putIfNotNull(json, KEY_ID_TOKEN, idToken); JsonUtil.putIfNotNull(json, KEY_SCOPE, scope); JsonUtil.put(json, KEY_ADDITIONAL_PARAMETERS, JsonUtil.mapToJsonObject(additionalParameters)); return json; } /** * Reads an authorization response from a JSON string representation produced by * {@link #jsonSerialize()}. * * @throws JSONException if the provided JSON does not match the expected structure. */ @NonNull public static AuthorizationResponse jsonDeserialize(@NonNull JSONObject json) throws JSONException { if (!json.has(KEY_REQUEST)) { throw new IllegalArgumentException( "authorization request not provided and not found in JSON"); } return new AuthorizationResponse( AuthorizationRequest.jsonDeserialize(json.getJSONObject(KEY_REQUEST)), JsonUtil.getStringIfDefined(json, KEY_STATE), JsonUtil.getStringIfDefined(json, KEY_TOKEN_TYPE), JsonUtil.getStringIfDefined(json, KEY_AUTHORIZATION_CODE), JsonUtil.getStringIfDefined(json, KEY_ACCESS_TOKEN), JsonUtil.getLongIfDefined(json, KEY_EXPIRES_AT), JsonUtil.getStringIfDefined(json, KEY_ID_TOKEN), JsonUtil.getStringIfDefined(json, KEY_SCOPE), JsonUtil.getStringMap(json, KEY_ADDITIONAL_PARAMETERS)); } /** * Reads an authorization request from a JSON string representation produced by * {@link #jsonSerializeString()}. This method is just a convenience wrapper for * {@link #jsonDeserialize(JSONObject)}, converting the JSON string to its JSON object form. * * @throws JSONException if the provided JSON does not match the expected structure. */ @NonNull public static AuthorizationResponse jsonDeserialize(@NonNull String jsonStr) throws JSONException { return jsonDeserialize(new JSONObject(jsonStr)); } /** * Produces an intent containing this authorization response. This is used to deliver the * authorization response to the registered handler after a call to * {@link AuthorizationService#performAuthorizationRequest}. */ @Override @NonNull public Intent toIntent() { Intent data = new Intent(); data.putExtra(EXTRA_RESPONSE, this.jsonSerializeString()); return data; } /** * Extracts an authorization response from an intent produced by {@link #toIntent()}. This is * used to extract the response from the intent data passed to an activity registered as the * handler for {@link AuthorizationService#performAuthorizationRequest}. */ @Nullable public static AuthorizationResponse fromIntent(@NonNull Intent dataIntent) { checkNotNull(dataIntent, "dataIntent must not be null"); if (!dataIntent.hasExtra(EXTRA_RESPONSE)) { return null; } try { return AuthorizationResponse.jsonDeserialize(dataIntent.getStringExtra(EXTRA_RESPONSE)); } catch (JSONException ex) { throw new IllegalArgumentException("Intent contains malformed auth response", ex); } } static boolean containsAuthorizationResponse(@NonNull Intent intent) { return intent.hasExtra(EXTRA_RESPONSE); } }