View Javadoc
1   /*
2    * The contents of this file are subject to the terms of the Common Development and
3    * Distribution License (the License). You may not use this file except in compliance with the
4    * License.
5    *
6    * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
7    * specific language governing permission and limitations under the License.
8    *
9    * When distributing Covered Software, include this CDDL Header Notice in each file and include
10   * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
11   * Header, with the fields enclosed by brackets [] replaced by your own identifying
12   * information: "Portions copyright [year] [name of copyright owner]".
13   *
14   * Copyright 2014-2016 ForgeRock AS.
15   */
16  
17  package org.forgerock.http.oauth2.resolver;
18  
19  import static java.lang.String.format;
20  import static java.util.concurrent.TimeUnit.MILLISECONDS;
21  import static java.util.concurrent.TimeUnit.SECONDS;
22  import static org.forgerock.json.JsonValueFunctions.setOf;
23  import static org.forgerock.util.Utils.closeSilently;
24  
25  import java.io.IOException;
26  import java.net.URI;
27  import java.net.URISyntaxException;
28  import java.util.Set;
29  
30  import org.forgerock.http.oauth2.AccessTokenException;
31  import org.forgerock.http.oauth2.AccessTokenInfo;
32  import org.forgerock.http.oauth2.AccessTokenResolver;
33  import org.forgerock.http.Handler;
34  import org.forgerock.http.protocol.Entity;
35  import org.forgerock.http.protocol.Form;
36  import org.forgerock.http.protocol.Request;
37  import org.forgerock.http.protocol.Response;
38  import org.forgerock.http.protocol.Responses;
39  import org.forgerock.http.protocol.Status;
40  import org.forgerock.json.JsonValue;
41  import org.forgerock.json.JsonValueException;
42  import org.forgerock.services.context.Context;
43  import org.forgerock.util.Function;
44  import org.forgerock.util.annotations.VisibleForTesting;
45  import org.forgerock.util.promise.Promise;
46  import org.forgerock.util.promise.Promises;
47  import org.forgerock.util.time.TimeService;
48  
49  /**
50   * An {@link OpenAmAccessTokenResolver} knows how to resolve a given token identifier against an OpenAm instance.
51   * <p>
52   * Models an {@link AccessTokenInfo} as returned by the OpenAM {@literal tokeninfo} endpoint.
53   * <pre>
54   *     {@code
55   *     curl https://openam.example.com:8443/openam/oauth2/tokeninfo?access_token=70e5776c-b0fa-4c70-9962-defb0e9c3cd6
56   *     }
57   * </pre>
58   *
59   * Example of OpenAM returned Json value (for the previous request):
60   * <pre>
61   *     {@code
62   *     {
63   *         "scope": [
64   *             "email",
65   *             "profile"
66   *         ],
67   *         "grant_type": "password",
68   *         "realm": "/",
69   *         "token_type": "Bearer",
70   *         "expires_in": 471,
71   *         "access_token": "70e5776c-b0fa-4c70-9962-defb0e9c3cd6",
72   *         "email": "",
73   *         "profile": ""
74   *     }
75   *     }
76   * </pre>
77   */
78  public class OpenAmAccessTokenResolver implements AccessTokenResolver {
79  
80      private final Handler client;
81      private final String tokenInfoEndpoint;
82      private final Function<JsonValue, AccessTokenInfo, AccessTokenException> accessToken;
83  
84      /**
85       * Creates a new {@link OpenAmAccessTokenResolver} configured to access the given {@literal /oauth2/tokeninfo}
86       * OpenAm endpoint.
87       *
88       * @param client
89       *         Http client handler used to perform the request
90       * @param time
91       *         Time service used to compute the token expiration time
92       * @param tokenInfoEndpoint
93       *         full URL of the {@literal /oauth2/tokeninfo} endpoint
94       */
95      public OpenAmAccessTokenResolver(final Handler client,
96                                       final TimeService time,
97                                       final String tokenInfoEndpoint) {
98          this(client, new TokenInfoParser(time), tokenInfoEndpoint);
99      }
100 
101     private OpenAmAccessTokenResolver(final Handler client,
102                                       final Function<JsonValue, AccessTokenInfo, AccessTokenException> accessToken,
103                                       final String tokenInfoEndpoint) {
104         this.client = client;
105         this.accessToken = accessToken;
106         this.tokenInfoEndpoint = tokenInfoEndpoint;
107     }
108 
109     @Override
110     public Promise<AccessTokenInfo, AccessTokenException> resolve(Context context, final String token) {
111         try {
112             Request request = new Request();
113             request.setMethod("GET");
114             request.setUri(new URI(tokenInfoEndpoint));
115 
116             // Append the access_token as a query parameter (automatically performs encoding)
117             Form form = new Form();
118             form.add("access_token", token);
119             form.toRequestQuery(request);
120 
121             // Call the client handler
122             return client.handle(context, request)
123                          .then(onResult(), Responses.<AccessTokenInfo, AccessTokenException>noopExceptionFunction());
124         } catch (URISyntaxException e) {
125             return Promises.newExceptionPromise(new AccessTokenException(
126                     format("The token_info endpoint %s could not be accessed because it is a malformed URI",
127                            tokenInfoEndpoint),
128                     e));
129         }
130     }
131 
132     private Function<Response, AccessTokenInfo, AccessTokenException> onResult() {
133         return new Function<Response, AccessTokenInfo, AccessTokenException>() {
134             @Override
135             public AccessTokenInfo apply(Response response) throws AccessTokenException {
136                 if (isResponseEmpty(response)) {
137                     throw new AccessTokenException("Authorization Server did not return any AccessToken");
138                 }
139                 JsonValue content = asJson(response.getEntity());
140                 if (isOk(response)) {
141                     return content.as(accessToken);
142                 }
143 
144                 if (content.isDefined("error")) {
145                     String error = content.get("error").asString();
146                     String description = content.get("error_description").asString();
147                     throw new AccessTokenException(format("Authorization Server returned an error "
148                                                                   + "(error: %s, description: %s)",
149                                                           error,
150                                                           description));
151                 }
152 
153                 throw new AccessTokenException("AccessToken returned by the AuthorizationServer has a problem");
154             }
155         };
156     }
157 
158     private boolean isResponseEmpty(final Response response) {
159         // response.entity is NEVER null !!!
160         return response == null || response.getEntity() == null;
161     }
162 
163     private boolean isOk(final Response response) {
164         return Status.OK.equals(response.getStatus());
165     }
166 
167     /**
168      * Parse the response's content as a JSON structure.
169      * @param entity stream response's content
170      * @return {@link JsonValue} representing the JSON content
171      * @throws AccessTokenException if there was some errors during parsing
172      */
173     private JsonValue asJson(final Entity entity) throws AccessTokenException {
174         try {
175             return new JsonValue(entity.getJson());
176         } catch (IOException e) {
177             // Do not use Entity.toString(), we probably don't want to fully output the content here
178             throw new AccessTokenException("Cannot read response content as JSON", e);
179         } finally {
180             closeSilently(entity);
181         }
182     }
183 
184     @VisibleForTesting
185     static class TokenInfoParser implements Function<JsonValue, AccessTokenInfo, AccessTokenException> {
186 
187         private final TimeService time;
188 
189         /**
190          * Creates a new parser with the given {@link TimeService}.
191          *
192          * @param time
193          *         time service used to compute the expiration date
194          */
195         TokenInfoParser(final TimeService time) {
196             this.time = time;
197         }
198 
199         /**
200          * Creates a new {@link AccessTokenInfo} from a raw JSON response returned by the {@literal /oauth2/tokeninfo}
201          * endpoint.
202          *
203          * @param raw
204          *         JSON response
205          * @return a new {@link AccessTokenInfo}
206          * @throws AccessTokenException
207          *         if the JSON response is not formatted correctly.
208          */
209         @Override
210         public AccessTokenInfo apply(final JsonValue raw) throws AccessTokenException {
211             try {
212                 final long expiresIn = raw.get("expires_in").required().asLong();
213                 final Set<String> scopes = raw.get("scope").required().as(setOf(String.class));
214                 final String token = raw.get("access_token").required().asString();
215 
216                 return new AccessTokenInfo(raw, token, scopes, getExpirationTime(expiresIn));
217             } catch (JsonValueException | NullPointerException e) {
218                 throw new AccessTokenException("Cannot build AccessToken from the given JSON: invalid format", e);
219             }
220         }
221 
222         private long getExpirationTime(final long delayInSeconds) {
223             return time.now() + MILLISECONDS.convert(delayInSeconds, SECONDS);
224         }
225     }
226 }