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 */ 016 017package org.forgerock.openig.uma; 018 019import static java.lang.String.format; 020import static org.forgerock.http.header.WarningHeader.MISCELLANEOUS_WARNING; 021import static org.forgerock.http.protocol.Response.newResponsePromise; 022import static org.forgerock.json.JsonValue.array; 023import static org.forgerock.json.JsonValue.field; 024import static org.forgerock.json.JsonValue.json; 025import static org.forgerock.json.JsonValue.object; 026import static org.forgerock.openig.http.Responses.newInternalServerError; 027import static org.forgerock.util.Utils.closeSilently; 028 029import java.io.IOException; 030import java.util.Collections; 031import java.util.List; 032import java.util.Set; 033 034import org.forgerock.http.header.WarningHeader; 035import org.forgerock.services.context.Context; 036import org.forgerock.http.Filter; 037import org.forgerock.http.Handler; 038import org.forgerock.http.protocol.Form; 039import org.forgerock.http.protocol.Request; 040import org.forgerock.http.protocol.Response; 041import org.forgerock.http.protocol.Status; 042import org.forgerock.json.JsonValue; 043import org.forgerock.openig.filter.oauth2.BearerTokenExtractor; 044import org.forgerock.openig.heap.GenericHeapObject; 045import org.forgerock.openig.heap.GenericHeaplet; 046import org.forgerock.openig.heap.HeapException; 047import org.forgerock.util.AsyncFunction; 048import org.forgerock.util.Function; 049import org.forgerock.util.promise.NeverThrowsException; 050import org.forgerock.util.promise.Promise; 051import org.forgerock.util.promise.ResultHandler; 052 053/** 054 * An {@link UmaResourceServerFilter} implements a PEP (Policy Enforcement Point) and is responsible to ensure the 055 * incoming requests (from requesting parties) all have a valid RPT (Request Party Token) with the required set of 056 * scopes. 057 * 058 * <pre> 059 * {@code { 060 * "type": "UmaFilter", 061 * "config": { 062 * "protectionApiHandler": "HttpsClient", 063 * "umaService": "UmaService" 064 * } 065 * } 066 * } 067 * </pre> 068 */ 069public class UmaResourceServerFilter extends GenericHeapObject implements Filter { 070 071 private final UmaSharingService umaService; 072 private final Handler protectionApiHandler; 073 private final String realm; 074 075 private final BearerTokenExtractor tokenExtractor = new BearerTokenExtractor(); 076 077 /** 078 * Constructs a new UmaResourceServerFilter. 079 * 080 * @param umaService 081 * core service to use 082 * @param protectionApiHandler 083 * protectionApiHandler to use when interacting with introspection and permission request endpoints 084 * @param realm 085 * UMA realm name (can be {@code null}) 086 */ 087 public UmaResourceServerFilter(final UmaSharingService umaService, 088 final Handler protectionApiHandler, 089 final String realm) { 090 this.umaService = umaService; 091 this.protectionApiHandler = protectionApiHandler; 092 this.realm = realm; 093 } 094 095 @Override 096 public Promise<Response, NeverThrowsException> filter(final Context context, 097 final Request request, 098 final Handler next) { 099 100 try { 101 // Find a Share for this request 102 final Share share = umaService.findShare(request); 103 String rpt = tokenExtractor.getAccessToken(request.getHeaders().getFirst("Authorization")); 104 105 // Is there an RPT ? 106 if (rpt != null) { 107 // Validate the token 108 return introspectToken(context, rpt, share.getPAT()) 109 .thenAsync(new VerifyScopesAsyncFunction(share, context, request, next)); 110 } 111 112 // Error case: ask for a ticket 113 return ticket(context, share, request); 114 115 } catch (UmaException e) { 116 // No share found 117 // Make sure we return a 404 118 return newResponsePromise(e.getResponse().setStatus(Status.NOT_FOUND)); 119 } 120 } 121 122 /** 123 * Call the UMA Permission Registration Endpoint to register a requested permission with the authorization server. 124 * 125 * <p>If the registration succeed, the obtained opaque ticket is returned to the client with an additional {@literal 126 * WWW-Authenticate} header: 127 * 128 * <pre> 129 * {@code HTTP/1.1 403 Forbidden 130 * WWW-Authenticate: UMA realm="example", as_uri="https://as.example.com" 131 * 132 * { 133 * "ticket": "016f84e8-f9b9-11e0-bd6f-0021cc6004de" 134 * } 135 * } 136 * </pre> 137 * 138 * @param context 139 * Context chain used to keep a relationship between requests (tracking) 140 * @param share 141 * represents protection information about the requested resource 142 * @param incoming 143 * request used to infer the set of permissions to ask 144 * @return an asynchronous {@link Response} 145 * @see <a href="https://docs.kantarainitiative.org/uma/rec-uma-core-v1_0.html#rfc.section.3.2">Request Permission 146 * Registration</a> 147 */ 148 private Promise<Response, NeverThrowsException> ticket(final Context context, 149 final Share share, 150 final Request incoming) { 151 Request request = new Request(); 152 request.setMethod("POST"); 153 request.setUri(umaService.getTicketEndpoint()); 154 request.getHeaders().put("Authorization", format("Bearer %s", share.getPAT())); 155 request.getHeaders().put("Accept", "application/json"); 156 request.setEntity(createPermissionRequest(share, incoming).asMap()); 157 158 return protectionApiHandler.handle(context, request) 159 .then(new TicketResponseFunction()); 160 } 161 162 /** 163 * Builds the resource set registration {@link Request}'s JSON content. 164 * 165 * @param share 166 * represents protection information about the requested resource 167 * @param request 168 * request used to infer the set of permissions to ask 169 * @return a JSON structure that represents a resource set registration 170 * @see <a href="https://docs.kantarainitiative.org/uma/rec-oauth-resource-reg.html#resource-set-desc">Resource Set 171 * Descriptions</a> 172 */ 173 private JsonValue createPermissionRequest(final Share share, final Request request) { 174 ShareTemplate template = share.getTemplate(); 175 Set<String> scopes = template.getScopes(request); 176 177 return json(object(field("resource_set_id", share.getResourceSetId()), 178 field("scopes", array(scopes.toArray(new Object[scopes.size()]))))); 179 } 180 181 private Promise<Response, NeverThrowsException> introspectToken(final Context context, 182 final String token, 183 final String pat) { 184 Request request = new Request(); 185 request.setUri(umaService.getIntrospectionEndpoint()); 186 // Should accept a PAT as per the spec (See OPENAM-6320 / OPENAM-5928) 187 //request.getHeaders().put("Authorization", format("Bearer %s", pat)); 188 request.getHeaders().put("Accept", "application/json"); 189 190 Form query = new Form(); 191 query.putSingle("token", token); 192 query.putSingle("client_id", umaService.getClientId()); 193 query.putSingle("client_secret", umaService.getClientSecret()); 194 query.toRequestEntity(request); 195 196 return protectionApiHandler.handle(context, request); 197 } 198 199 private class VerifyScopesAsyncFunction implements AsyncFunction<Response, Response, NeverThrowsException> { 200 private final Share share; 201 private final Context context; 202 private final Request request; 203 private final Handler next; 204 205 public VerifyScopesAsyncFunction(final Share share, 206 final Context context, 207 final Request request, 208 final Handler next) { 209 this.share = share; 210 this.context = context; 211 this.request = request; 212 this.next = next; 213 } 214 215 @Override 216 public Promise<Response, NeverThrowsException> apply(final Response token) { 217 218 if (Status.OK == token.getStatus()) { 219 JsonValue value = null; 220 try { 221 value = json(token.getEntity().getJson()); 222 } catch (IOException e) { 223 return newResponsePromise(newInternalServerError(e)); 224 } 225 if (value.get("active").asBoolean()) { 226 // Got a valid token 227 // Need to verify embed scopes against required scopes 228 ShareTemplate template = share.getTemplate(); 229 Set<String> required = template.getScopes(request); 230 if (getScopes(value, share.getResourceSetId()).containsAll(required)) { 231 // All required scopes are present, continue the request processing 232 return next.handle(context, request); 233 } 234 235 // Not all of the required scopes are in the token 236 // Error case: ask for a ticket, append an error code 237 return ticket(context, share, request) 238 .thenOnResult(new ResultHandler<Response>() { 239 @Override 240 public void handleResult(final Response response) { 241 242 // Update the Authorization header with a proper error code 243 String authorization = response.getHeaders() 244 .getFirst("WWW-Authenticate"); 245 if (authorization != null) { 246 authorization = authorization.concat(", error=\"insufficient_scope\""); 247 response.getHeaders().put("WWW-Authenticate", authorization); 248 } 249 } 250 }); 251 } 252 } 253 254 // Error case: ask for a ticket 255 return ticket(context, share, request); 256 } 257 258 private List<String> getScopes(final JsonValue value, final String resourceSetId) { 259 for (JsonValue permission : value.get("permissions")) { 260 if (resourceSetId.equals(permission.get("resource_set_id").asString())) { 261 return permission.get("scopes").asList(String.class); 262 } 263 } 264 return Collections.emptyList(); 265 } 266 } 267 268 private class TicketResponseFunction implements Function<Response, Response, NeverThrowsException> { 269 @Override 270 public Response apply(final Response response) { 271 if (Status.CREATED == response.getStatus()) { 272 // Create a new response with authenticate header and status code 273 try { 274 JsonValue value = json(response.getEntity().getJson()); 275 Response forbidden = new Response(Status.UNAUTHORIZED); 276 String ticket = value.get("ticket").asString(); 277 forbidden.getHeaders().put("WWW-Authenticate", 278 format("UMA realm=\"%s\", as_uri=\"%s\", ticket=\"%s\"", 279 realm, 280 umaService.getAuthorizationServer(), 281 ticket)); 282 return forbidden; 283 } catch (IOException e) { 284 // JSON parsing exception 285 // Ignored here, will be handled in the later catch-all block 286 } 287 } 288 // Close previous response object 289 closeSilently(response); 290 291 // Properly handle 400 errors and UMA error codes 292 // The PAT may need to be refreshed 293 Response forbidden = new Response(Status.FORBIDDEN); 294 forbidden.getHeaders().put(new WarningHeader(MISCELLANEOUS_WARNING, 295 "-", 296 "\"UMA Authorization Server Unreachable\"")); 297 return forbidden; 298 } 299 } 300 301 /** 302 * Creates and initializes an UMA resource server filter in a heap environment. 303 */ 304 public static class Heaplet extends GenericHeaplet { 305 306 @Override 307 public Object create() throws HeapException { 308 UmaSharingService service = heap.resolve(config.get("umaService").required(), UmaSharingService.class); 309 Handler handler = heap.resolve(config.get("protectionApiHandler").required(), Handler.class); 310 String realm = config.get("realm").defaultTo("uma").asString(); 311 return new UmaResourceServerFilter(service, handler, realm); 312 } 313 } 314}