001/*
002 * The contents of this file are subject to the terms of the Common Development and
003 * Distribution License (the License). You may not use this file except in compliance with the
004 * License.
005 *
006 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
007 * specific language governing permission and limitations under the License.
008 *
009 * When distributing Covered Software, include this CDDL Header Notice in each file and include
010 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
011 * Header, with the fields enclosed by brackets [] replaced by your own identifying
012 * information: "Portions Copyright [year] [name of copyright owner]".
013 *
014 * Copyright 2015 ForgeRock AS.
015 */
016package org.forgerock.openig.filter.oauth2.client;
017
018import static java.lang.String.format;
019import static java.util.Collections.emptyList;
020import static org.forgerock.http.protocol.Status.BAD_REQUEST;
021import static org.forgerock.http.protocol.Status.OK;
022import static org.forgerock.http.protocol.Status.UNAUTHORIZED;
023import static org.forgerock.openig.filter.oauth2.client.OAuth2Error.E_SERVER_ERROR;
024import static org.forgerock.openig.filter.oauth2.client.OAuth2Utils.getJsonContent;
025import static org.forgerock.openig.heap.Keys.CLIENT_HANDLER_HEAP_KEY;
026import static org.forgerock.openig.http.Responses.blockingCall;
027import static org.forgerock.openig.util.JsonValues.evaluate;
028import static org.forgerock.openig.util.JsonValues.firstOf;
029
030import java.nio.charset.Charset;
031import java.util.List;
032
033import org.forgerock.http.Handler;
034import org.forgerock.http.protocol.Form;
035import org.forgerock.http.protocol.Request;
036import org.forgerock.http.protocol.Response;
037import org.forgerock.http.protocol.ResponseException;
038import org.forgerock.http.protocol.Status;
039import org.forgerock.json.JsonValue;
040import org.forgerock.openig.heap.GenericHeaplet;
041import org.forgerock.openig.heap.HeapException;
042import org.forgerock.services.context.Context;
043import org.forgerock.util.encode.Base64;
044
045/**
046 * A configuration for an OpenID Connect Provider. Options:
047 *
048 * <pre>
049 * {@code
050 * {
051 *   "clientId"                     : expression,       [REQUIRED]
052 *   "clientSecret"                 : expression,       [REQUIRED]
053 *   "issuer"                       : String / Issuer   [REQUIRED - the issuer name, or its inlined declaration,
054 *   "scopes"                       : [ expressions ],  [OPTIONAL - specific scopes to use for this client
055 *                                                                  registration. ]
056 *   "registrationHandler"          : handler           [OPTIONAL - by default it uses the 'ClientHandler'
057 *                                                                  provided in heap.]
058 *   "tokenEndpointUseBasicAuth"    : boolean           [OPTIONAL - default is true, use Basic Authentication.]
059 * }
060 * }
061 * </pre>
062 *
063 * Example of use:
064 *
065 * <pre>
066 * {@code
067 * {
068 *     "name": "MyClientRegistration",
069 *     "type": "ClientRegistration",
070 *     "config": {
071 *         "clientId": "OpenIG",
072 *         "clientSecret": "password",
073 *         "scopes": [
074 *             "openid",
075 *             "profile"
076 *         ],
077 *         "issuer": "OpenAM"
078 *     }
079 * }
080 * }
081 * </pre>
082 *
083 * or, with inlined Issuer declaration:
084 *
085 * <pre>
086 * {@code
087 * {
088 *     "name": "MyClientRegistration",
089 *     "type": "ClientRegistration",
090 *     "config": {
091 *         "clientId": "OpenIG",
092 *         "clientSecret": "password",
093 *         "scopes": [
094 *             "openid",
095 *             "profile"
096 *         ],
097           "tokenEndpointUseBasicAuth": true,
098 *         "issuer": {
099 *             "name": "myIssuer",
100 *             "type": "Issuer",
101 *             "config": {
102 *                 "wellKnownEndpoint": "http://server.com:8090/openam/oauth2/.well-known/openid-configuration"
103 *             }
104 *         }
105 *     }
106 * }
107 * }
108 * </pre>
109 *
110 * @see <a
111 *      href="https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata">
112 *      OpenID Connect Dynamic Client Registration 1.0 </a>
113 */
114public final class ClientRegistration {
115    /** The key used to store this client registration in the context. */
116    static final String CLIENT_REG_KEY = "registration";
117
118    private final String name;
119    private final String clientId;
120    private final String clientSecret;
121    private final Issuer issuer;
122    private final List<String> scopes;
123    private boolean tokenEndpointUseBasicAuth;
124    private final Handler registrationHandler;
125
126    /**
127     * Creates a Client Registration.
128     *
129     * @param name
130     *            The name of this client registration. Can be {@code null}. If
131     *            it is {@code null} the name is extracted from the
132     *            configuration.
133     * @param config
134     *            The configuration of the client registration.
135     * @param issuer
136     *            The {@link Issuer} of this Client.
137     * @param registrationHandler
138     *            The handler used to send request to the AS.
139     */
140    public ClientRegistration(final String name,
141                              final JsonValue config,
142                              final Issuer issuer,
143                              final Handler registrationHandler) {
144        this.name = name != null
145                    ? name
146                    : firstOf(config, "client_name", "client_id").asString();
147        this.clientId = firstOf(config, "clientId", "client_id").required().asString();
148        this.clientSecret = firstOf(config, "clientSecret", "client_secret").required().asString();
149        this.scopes = config.get("scopes").defaultTo(emptyList()).required().asList(String.class);
150        if (config.isDefined("token_endpoint_auth_method")
151                && config.get("token_endpoint_auth_method").asString().equals("client_secret_post")) {
152            this.tokenEndpointUseBasicAuth = false;
153        } else {
154            this.tokenEndpointUseBasicAuth = config.get("tokenEndpointUseBasicAuth").defaultTo(true).asBoolean();
155        }
156        this.issuer = issuer;
157        this.registrationHandler = registrationHandler;
158    }
159
160    /**
161     * Returns the name of this client registration.
162     *
163     * @return the name of this client registration.
164     */
165    public String getName() {
166        return name;
167    }
168
169    /**
170     * Exchanges the authorization code for an access token and optional ID
171     * token, and then update the session state.
172     *
173     * @param context
174     *            The current context.
175     * @param code
176     *            The authorization code.
177     * @param callbackUri
178     *            The callback URI.
179     * @return The json content of the response if status return code of the
180     *         response is 200 OK. Otherwise, throw an OAuth2ErrorException.
181     * @throws OAuth2ErrorException
182     *             If an error occurs when contacting the authorization server
183     *             or if the returned response status code is different than 200
184     *             OK.
185     */
186    public JsonValue getAccessToken(final Context context,
187                                    final String code,
188                                    final String callbackUri) throws OAuth2ErrorException {
189        final Request request = createRequestForAccessToken(code, callbackUri);
190        final Response response = httpRequestToAuthorizationServer(context, request);
191        checkResponseStatus(response, false);
192        return getJsonContent(response);
193    }
194
195    /**
196     * Returns the client ID of this client registration.
197     *
198     * @return the client ID.
199     */
200    public String getClientId() {
201        return clientId;
202    }
203
204    /**
205     * Returns the {@link Issuer} for this client registration.
206     *
207     * @return the {@link Issuer} for this client registration.
208     */
209    public Issuer getIssuer() {
210        return issuer;
211    }
212
213    /**
214     * Refreshes the actual access token, making a refresh request to the token
215     * end-point.
216     *
217     * @param context
218     *            The current context.
219     * @param session
220     *            The current session.
221     * @return The JSON content of the response if status return code of the
222     *         response is 200 OK. Otherwise, throw an OAuth2ErrorException.
223     * @throws ResponseException
224     *             If an exception occurs that prevents handling of the request
225     *             or if the creation of the request for a refresh token fails.
226     * @throws OAuth2ErrorException
227     *             If an error occurs when contacting the authorization server
228     *             or if the returned response status code is different than 200
229     *             OK.
230     */
231    public JsonValue refreshAccessToken(final Context context,
232                                        final OAuth2Session session) throws ResponseException, OAuth2ErrorException {
233
234        final Request request = createRequestForTokenRefresh(session);
235        final Response response = httpRequestToAuthorizationServer(context, request);
236        checkResponseStatus(response, true);
237        return getJsonContent(response);
238    }
239
240    /**
241     * Returns the list of scopes of this client registration.
242     *
243     * @return the the list of scopes of this client registration.
244     */
245    public List<String> getScopes() {
246        return scopes;
247    }
248
249    /**
250     * Returns the json value of the user info obtained from the authorization
251     * server if the response from the authorization server has a status code of
252     * 200. Otherwise, it throws an exception, meaning the access token may have
253     * expired.
254     *
255     * @param context
256     *            The current context.
257     * @param session
258     *            The current session to use.
259     * @return A JsonValue containing the requested user info.
260     * @throws ResponseException
261     *             If an exception occurs that prevents handling of the request
262     *             or if the creation of the request for getting user info
263     *             fails.
264     * @throws OAuth2ErrorException
265     *             If an error occurs when contacting the authorization server
266     *             or if the returned response status code is different than 200
267     *             OK. May signify that the access token has expired.
268     */
269    public JsonValue getUserInfo(final Context context,
270                                 final OAuth2Session session) throws ResponseException, OAuth2ErrorException  {
271        final Request request = createRequestForUserInfo(session.getAccessToken());
272        final Response response = httpRequestToAuthorizationServer(context, request);
273        if (!Status.OK.equals(response.getStatus())) {
274            /*
275             * The access token may have expired. Trigger an exception,
276             * catch it and react later.
277             */
278            final OAuth2BearerWWWAuthenticateHeader header = OAuth2BearerWWWAuthenticateHeader.valueOf(response);
279            final OAuth2Error error = header.getOAuth2Error();
280            final OAuth2Error bestEffort = OAuth2Error.bestEffortResourceServerError(response.getStatus(), error);
281            throw new OAuth2ErrorException(bestEffort);
282        }
283        return getJsonContent(response);
284    }
285
286    /**
287     * Sets the authentication method the token end-point should use.
288     * {@code true} for 'client_secret_basic', {@code false} for
289     * 'client_secret_post' (not recommended).
290     *
291     * @param useBasicAuth
292     *            {@code true} if the token end-point should use Basic
293     *            authentication, {@code false} if it should use client secret
294     *            POST.
295     * @return This client registration.
296     * @see <a href="https://tools.ietf.org/html/rfc6749#section-2.3.1">RFC 6749, Section 2.3.1</a>
297     */
298    public ClientRegistration setTokenEndpointUseBasicAuth(final boolean useBasicAuth) {
299        this.tokenEndpointUseBasicAuth = useBasicAuth;
300        return this;
301    }
302
303    private Request createRequestForAccessToken(final String code,
304                                                final String callbackUri) {
305        final Request request = new Request();
306        request.setMethod("POST");
307        request.setUri(issuer.getTokenEndpoint());
308        final Form form = new Form();
309        form.add("grant_type", "authorization_code");
310        form.add("redirect_uri", callbackUri);
311        form.add("code", code);
312        addClientIdAndSecret(request, form);
313        form.toRequestEntity(request);
314        return request;
315    }
316
317    private Request createRequestForTokenRefresh(final OAuth2Session session) throws ResponseException {
318        final Request request = new Request();
319        request.setMethod("POST");
320        request.setUri(issuer.getTokenEndpoint());
321        final Form form = new Form();
322        form.add("grant_type", "refresh_token");
323        form.add("refresh_token", session.getRefreshToken());
324        addClientIdAndSecret(request, form);
325        form.toRequestEntity(request);
326        return request;
327    }
328
329    private Request createRequestForUserInfo(final String accessToken) throws ResponseException {
330        final Request request = new Request();
331        request.setMethod("GET");
332        request.setUri(issuer.getUserInfoEndpoint());
333        request.getHeaders().add("Authorization", "Bearer " + accessToken);
334        return request;
335    }
336
337    private void addClientIdAndSecret(final Request request,
338                                      final Form form) {
339        final String user = getClientId();
340        final String pass = getClientSecret();
341        if (!tokenEndpointUseBasicAuth) {
342            form.add("client_id", user);
343            form.add("client_secret", pass);
344        } else {
345            final String userpass = Base64.encode((user + ":" + pass).getBytes(Charset.defaultCharset()));
346            request.getHeaders().add("Authorization", "Basic " + userpass);
347        }
348    }
349
350    private String getClientSecret() {
351        return clientSecret;
352    }
353
354    private Response httpRequestToAuthorizationServer(final Context context,
355                                                      final Request request)
356            throws OAuth2ErrorException {
357        try {
358            return blockingCall(registrationHandler, context, request);
359        } catch (final InterruptedException e) {
360            // FIXME Changed IOException to InterruptedException, not very sure about that
361            throw new OAuth2ErrorException(E_SERVER_ERROR,
362                                           "Authorization failed because an error occurred while trying "
363                                                   + "to contact the authorization server");
364        }
365    }
366
367    private void checkResponseStatus(final Response response,
368                                     final boolean isRefreshToken) throws OAuth2ErrorException {
369        final Status status = response.getStatus();
370        if (!OK.equals(status)) {
371            if (BAD_REQUEST.equals(status) || UNAUTHORIZED.equals(status)) {
372                final JsonValue errorJson = getJsonContent(response);
373                throw new OAuth2ErrorException(OAuth2Error.valueOfJsonContent(errorJson.asMap()));
374            } else {
375                final String errorMessage = format("Unable to %s access token [status=%d]",
376                                                   isRefreshToken ? "refresh" : "exchange",
377                                                   status.getCode());
378                throw new OAuth2ErrorException(E_SERVER_ERROR, errorMessage);
379            }
380        }
381    }
382
383    /** Creates and initializes a Client Registration object in a heap environment. */
384    public static class Heaplet extends GenericHeaplet {
385        @Override
386        public Object create() throws HeapException {
387            final Handler registrationHandler = heap.resolve(config.get("registrationHandler")
388                                                                   .defaultTo(CLIENT_HANDLER_HEAP_KEY),
389                                                             Handler.class);
390            final Issuer issuer = heap.resolve(config.get("issuer"), Issuer.class);
391            return new ClientRegistration(this.name,
392                                          evaluate(config, logger),
393                                          issuer,
394                                          registrationHandler);
395        }
396    }
397}