OpenAmAccessTokenResolver.java

/*
 * The contents of this file are subject to the terms of the Common Development and
 * Distribution License (the License). You may not use this file except in compliance with the
 * License.
 *
 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
 * specific language governing permission and limitations under the License.
 *
 * When distributing Covered Software, include this CDDL Header Notice in each file and include
 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
 * Header, with the fields enclosed by brackets [] replaced by your own identifying
 * information: "Portions copyright [year] [name of copyright owner]".
 *
 * Copyright 2014-2016 ForgeRock AS.
 */

package org.forgerock.http.oauth2.resolver;

import static java.lang.String.format;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.forgerock.json.JsonValueFunctions.setOf;
import static org.forgerock.util.Utils.closeSilently;

import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Set;

import org.forgerock.http.oauth2.AccessTokenException;
import org.forgerock.http.oauth2.AccessTokenInfo;
import org.forgerock.http.oauth2.AccessTokenResolver;
import org.forgerock.http.Handler;
import org.forgerock.http.protocol.Entity;
import org.forgerock.http.protocol.Form;
import org.forgerock.http.protocol.Request;
import org.forgerock.http.protocol.Response;
import org.forgerock.http.protocol.Responses;
import org.forgerock.http.protocol.Status;
import org.forgerock.json.JsonValue;
import org.forgerock.json.JsonValueException;
import org.forgerock.services.context.Context;
import org.forgerock.util.Function;
import org.forgerock.util.annotations.VisibleForTesting;
import org.forgerock.util.promise.Promise;
import org.forgerock.util.promise.Promises;
import org.forgerock.util.time.TimeService;

/**
 * An {@link OpenAmAccessTokenResolver} knows how to resolve a given token identifier against an OpenAm instance.
 * <p>
 * Models an {@link AccessTokenInfo} as returned by the OpenAM {@literal tokeninfo} endpoint.
 * <pre>
 *     {@code
 *     curl https://openam.example.com:8443/openam/oauth2/tokeninfo?access_token=70e5776c-b0fa-4c70-9962-defb0e9c3cd6
 *     }
 * </pre>
 *
 * Example of OpenAM returned Json value (for the previous request):
 * <pre>
 *     {@code
 *     {
 *         "scope": [
 *             "email",
 *             "profile"
 *         ],
 *         "grant_type": "password",
 *         "realm": "/",
 *         "token_type": "Bearer",
 *         "expires_in": 471,
 *         "access_token": "70e5776c-b0fa-4c70-9962-defb0e9c3cd6",
 *         "email": "",
 *         "profile": ""
 *     }
 *     }
 * </pre>
 */
public class OpenAmAccessTokenResolver implements AccessTokenResolver {

    private final Handler client;
    private final String tokenInfoEndpoint;
    private final Function<JsonValue, AccessTokenInfo, AccessTokenException> accessToken;

    /**
     * Creates a new {@link OpenAmAccessTokenResolver} configured to access the given {@literal /oauth2/tokeninfo}
     * OpenAm endpoint.
     *
     * @param client
     *         Http client handler used to perform the request
     * @param time
     *         Time service used to compute the token expiration time
     * @param tokenInfoEndpoint
     *         full URL of the {@literal /oauth2/tokeninfo} endpoint
     */
    public OpenAmAccessTokenResolver(final Handler client,
                                     final TimeService time,
                                     final String tokenInfoEndpoint) {
        this(client, new TokenInfoParser(time), tokenInfoEndpoint);
    }

    private OpenAmAccessTokenResolver(final Handler client,
                                      final Function<JsonValue, AccessTokenInfo, AccessTokenException> accessToken,
                                      final String tokenInfoEndpoint) {
        this.client = client;
        this.accessToken = accessToken;
        this.tokenInfoEndpoint = tokenInfoEndpoint;
    }

    @Override
    public Promise<AccessTokenInfo, AccessTokenException> resolve(Context context, final String token) {
        try {
            Request request = new Request();
            request.setMethod("GET");
            request.setUri(new URI(tokenInfoEndpoint));

            // Append the access_token as a query parameter (automatically performs encoding)
            Form form = new Form();
            form.add("access_token", token);
            form.toRequestQuery(request);

            // Call the client handler
            return client.handle(context, request)
                         .then(onResult(), Responses.<AccessTokenInfo, AccessTokenException>noopExceptionFunction());
        } catch (URISyntaxException e) {
            return Promises.newExceptionPromise(new AccessTokenException(
                    format("The token_info endpoint %s could not be accessed because it is a malformed URI",
                           tokenInfoEndpoint),
                    e));
        }
    }

    private Function<Response, AccessTokenInfo, AccessTokenException> onResult() {
        return new Function<Response, AccessTokenInfo, AccessTokenException>() {
            @Override
            public AccessTokenInfo apply(Response response) throws AccessTokenException {
                if (isResponseEmpty(response)) {
                    throw new AccessTokenException("Authorization Server did not return any AccessToken");
                }
                JsonValue content = asJson(response.getEntity());
                if (isOk(response)) {
                    return content.as(accessToken);
                }

                if (content.isDefined("error")) {
                    String error = content.get("error").asString();
                    String description = content.get("error_description").asString();
                    throw new AccessTokenException(format("Authorization Server returned an error "
                                                                  + "(error: %s, description: %s)",
                                                          error,
                                                          description));
                }

                throw new AccessTokenException("AccessToken returned by the AuthorizationServer has a problem");
            }
        };
    }

    private boolean isResponseEmpty(final Response response) {
        // response.entity is NEVER null !!!
        return response == null || response.getEntity() == null;
    }

    private boolean isOk(final Response response) {
        return Status.OK.equals(response.getStatus());
    }

    /**
     * Parse the response's content as a JSON structure.
     * @param entity stream response's content
     * @return {@link JsonValue} representing the JSON content
     * @throws AccessTokenException if there was some errors during parsing
     */
    private JsonValue asJson(final Entity entity) throws AccessTokenException {
        try {
            return new JsonValue(entity.getJson());
        } catch (IOException e) {
            // Do not use Entity.toString(), we probably don't want to fully output the content here
            throw new AccessTokenException("Cannot read response content as JSON", e);
        } finally {
            closeSilently(entity);
        }
    }

    @VisibleForTesting
    static class TokenInfoParser implements Function<JsonValue, AccessTokenInfo, AccessTokenException> {

        private final TimeService time;

        /**
         * Creates a new parser with the given {@link TimeService}.
         *
         * @param time
         *         time service used to compute the expiration date
         */
        TokenInfoParser(final TimeService time) {
            this.time = time;
        }

        /**
         * Creates a new {@link AccessTokenInfo} from a raw JSON response returned by the {@literal /oauth2/tokeninfo}
         * endpoint.
         *
         * @param raw
         *         JSON response
         * @return a new {@link AccessTokenInfo}
         * @throws AccessTokenException
         *         if the JSON response is not formatted correctly.
         */
        @Override
        public AccessTokenInfo apply(final JsonValue raw) throws AccessTokenException {
            try {
                final long expiresIn = raw.get("expires_in").required().asLong();
                final Set<String> scopes = raw.get("scope").required().as(setOf(String.class));
                final String token = raw.get("access_token").required().asString();

                return new AccessTokenInfo(raw, token, scopes, getExpirationTime(expiresIn));
            } catch (JsonValueException | NullPointerException e) {
                throw new AccessTokenException("Cannot build AccessToken from the given JSON: invalid format", e);
            }
        }

        private long getExpirationTime(final long delayInSeconds) {
            return time.now() + MILLISECONDS.convert(delayInSeconds, SECONDS);
        }
    }
}