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 org.forgerock.http.protocol.Status.OK; 020import static org.forgerock.http.util.Uris.withQuery; 021import static org.forgerock.json.JsonValue.field; 022import static org.forgerock.json.JsonValue.json; 023import static org.forgerock.json.JsonValue.object; 024import static org.forgerock.openig.filter.oauth2.client.Issuer.ISSUER_KEY; 025import static org.forgerock.openig.filter.oauth2.client.OAuth2Utils.getJsonContent; 026import static org.forgerock.openig.http.Responses.blockingCall; 027import static org.forgerock.openig.http.Responses.newInternalServerError; 028import static org.forgerock.util.promise.Promises.newResultPromise; 029 030import java.net.URI; 031import java.net.URISyntaxException; 032import java.util.List; 033import java.util.regex.Pattern; 034 035import org.forgerock.http.Filter; 036import org.forgerock.http.Handler; 037import org.forgerock.http.protocol.Form; 038import org.forgerock.http.protocol.Request; 039import org.forgerock.http.protocol.Response; 040import org.forgerock.json.JsonValue; 041import org.forgerock.json.JsonValueException; 042import org.forgerock.openig.heap.Heap; 043import org.forgerock.openig.heap.HeapException; 044import org.forgerock.services.context.AttributesContext; 045import org.forgerock.services.context.Context; 046import org.forgerock.util.promise.NeverThrowsException; 047import org.forgerock.util.promise.Promise; 048 049/** 050 * In order for an OpenID Connect Relying Party to utilize OpenID Connect 051 * services for an End-User, the RP needs to know where the OpenID Provider is. 052 * OpenID Connect uses WebFinger [RFC7033] to locate the OpenID Provider for an 053 * End-User. 054 * <p> 055 * This class performs OpenID Provider Issuer discovery : determine the location 056 * of the OpenID Provider based on a given End-User input which can be an e-mail 057 * address or a URL Syntax or even a HostName and Port Syntax. 058 * </p> 059 * <p> 060 * The user input is given 061 * from the query parameters {@code '?discovery=<userInput>'}. 062 * <br> 063 * Discovery is in two part. The first extracts the host name and a normalized 064 * user input from the given input. 065 * <br> 066 * Then, IG verifies if an existing {@link Issuer} already exists in the heap 067 * corresponding to the extracted host name. If it exists, reuse it. If not, 068 * IG verifies this host name is not part of an Issuer "supportedDomain". 069 * If the host name belongs to an {@link Issuer} supported Domain, this 070 * {@link Issuer} is used. Otherwise, discovery process continues... 071 * <br> 072 * In the second part, the WebFinger uses the extracted host name, 073 * to get the corresponding OpenID Issuer location which match the selected 074 * type of service ("http://openid.net/specs/connect/1.0/issuer") if it exists. 075 * <br> 076 * Based on the returned OpenID Issuer's location, the OpenID well-known 077 * end-point is extracted and the filter builds a {@link Issuer} which is 078 * placed in the context and in the heap to be reused if needed. 079 * </p> 080 * 081 * @see <a href="https://openid.net/specs/openid-connect-discovery-1_0.html"> 082 * OpenID Connect Dynamic Client Registration 1.0</a> 083 * @see <a href="https://tools.ietf.org/html/rfc7033">WebFinger</a> 084 */ 085public class DiscoveryFilter implements Filter { 086 static final String OPENID_SERVICE = "http://openid.net/specs/connect/1.0/issuer"; 087 private static final String WELLKNOWN_WEBFINGER = ".well-known/webfinger"; 088 private static final String WELLKNOWN_OPENID_CONFIGURATION = ".well-known/openid-configuration"; 089 090 private final Handler discoveryHandler; 091 private final Heap heap; 092 093 /** 094 * Creates a discovery filter. 095 * 096 * @param handler 097 * The handler to perform the queries. 098 * @param heap 099 * A reference to the current heap. 100 */ 101 DiscoveryFilter(final Handler handler, final Heap heap) { 102 this.discoveryHandler = handler; 103 this.heap = heap; 104 } 105 106 @Override 107 public Promise<Response, NeverThrowsException> filter(Context context, 108 Request request, 109 Handler next) { 110 try { 111 final AccountIdentifier account = extractFromInput(request.getForm().getFirst("discovery")); 112 final String hostString = account.getHostBase().toASCIIString(); 113 /* Auto-created Issuer heap objects are named according to the discovered host base. */ 114 Issuer issuer = heap.get(hostString, Issuer.class); 115 /* Checks if this domain name should be supported by an existing issuer. */ 116 if (issuer == null) { 117 issuer = fromSupportedDomainNames(hostString); 118 } 119 /* Performs discovery otherwise. */ 120 if (issuer == null) { 121 final URI wellKnowIssuerUri = performOpenIdIssuerDiscovery(context, account); 122 final JsonValue issuerDeclaration = createIssuerDeclaration(hostString, wellKnowIssuerUri); 123 issuer = heap.resolve(issuerDeclaration, Issuer.class); 124 } 125 AttributesContext attributesContext = context.asContext(AttributesContext.class); 126 attributesContext.getAttributes().put(ISSUER_KEY, issuer); 127 } catch (URISyntaxException | DiscoveryException e) { 128 return newResultPromise(newInternalServerError("Discovery cannot be performed", e)); 129 } catch (HeapException e) { 130 return newResultPromise(newInternalServerError("Cannot inject inlined Issuer declaration to heap", e)); 131 } 132 133 return next.handle(context, request); 134 } 135 136 /** 137 * The given domain name can match one or none domain names supported by 138 * Issuers declared in this route. If the given domain name matches the 139 * patterns given by an Issuer 'supportedDomains' attributes, then the 140 * corresponding Issuer is returned to be used. 141 */ 142 private Issuer fromSupportedDomainNames(final String givenDomainName) throws HeapException { 143 for (final Issuer definedIssuer : heap.getAll(Issuer.class)) { 144 final List<Pattern> domainNames = definedIssuer.getSupportedDomains(); 145 for (final Pattern domainName : domainNames) { 146 if (domainName.matcher(givenDomainName).matches()) { 147 return definedIssuer; 148 } 149 } 150 } 151 return null; 152 } 153 154 private JsonValue createIssuerDeclaration(final String issuerName, final URI wellKnowIssuerUri) { 155 return json( 156 object(field("name", issuerName), 157 field("type", "Issuer"), 158 field("config", object(field("wellKnownEndpoint", wellKnowIssuerUri.toString()))))); 159 } 160 161 /** 162 * Performs the OpenID issuer discovery on the given URI. 163 * 164 * @param context 165 * Current context. 166 * @param account 167 * The account identifier links to this input. 168 * @return The '.well-known' URI if succeed. 169 * @throws DiscoveryException 170 * If an error occurs during retrieving the WebFinger URI. 171 * @throws URISyntaxException 172 * If an error occurs during building the WebFinger URI. 173 */ 174 URI performOpenIdIssuerDiscovery(final Context context, 175 final AccountIdentifier account) throws DiscoveryException, 176 URISyntaxException { 177 final Request request = buildWebFingerRequest(account); 178 String webFingerHref = null; 179 180 final Response response; 181 try { 182 response = blockingCall(discoveryHandler, context, request); 183 } catch (InterruptedException e) { 184 throw new DiscoveryException(format("Interrupted while waiting for '%s' response", request.getUri()), e); 185 } 186 187 try { 188 final JsonValue config = getJsonContent(response); 189 final JsonValue links = config.get("links").expect(List.class); 190 for (final JsonValue link : links) { 191 if (OPENID_SERVICE.equals(link.get("rel").asString())) { 192 webFingerHref = link.get("href").asString(); 193 break; 194 } 195 } 196 } catch (JsonValueException e) { 197 throw new DiscoveryException("Invalid Json response", e); 198 } catch (OAuth2ErrorException e) { 199 throw new DiscoveryException("Cannot read JSON response in webfinger process", e); 200 } 201 202 if (!OK.equals(response.getStatus()) || webFingerHref == null) { 203 throw new DiscoveryException("Invalid WebFinger URI : this can be caused by the distant server or " 204 + "a malformed WebFinger file"); 205 } 206 final URI resourceLocation = new URI(webFingerHref.endsWith("/") ? webFingerHref : webFingerHref + "/"); 207 return resourceLocation.resolve(WELLKNOWN_OPENID_CONFIGURATION); 208 } 209 210 Request buildWebFingerRequest(final AccountIdentifier account) { 211 final Request request = new Request(); 212 request.setMethod("GET"); 213 214 final Form query = new Form(); 215 query.add("resource", account.getNormalizedIdentifier().toString()); 216 query.add("rel", OPENID_SERVICE); 217 final URI wellKnownWebFinger = withQuery(account.getHostBase().resolve(WELLKNOWN_WEBFINGER), query); 218 request.setUri(wellKnownWebFinger); 219 return request; 220 } 221 222 /** 223 * Extracts from the given input the corresponding account identifier. 224 * 225 * @see <a 226 * href="https://openid.net/specs/openid-connect-discovery-1_0.html#NormalizationSteps"> 227 * OpenID Connect Dynamic Client Registration 1.0 - Normalization 228 * steps</a> 229 */ 230 AccountIdentifier extractFromInput(final String decodedUserInput) throws URISyntaxException { 231 if (decodedUserInput == null || decodedUserInput.isEmpty()) { 232 throw new IllegalArgumentException("Invalid input"); 233 } 234 235 final URI uri; 236 String normalizedIdentifier = decodedUserInput; 237 if (decodedUserInput.startsWith("acct:") || decodedUserInput.contains("@")) { 238 /* email case */ 239 if (!decodedUserInput.startsWith("acct:")) { 240 normalizedIdentifier = "acct:" + decodedUserInput; 241 } 242 if (decodedUserInput.lastIndexOf("@") > decodedUserInput.indexOf("@") + 1) { 243 /* Extracting the host only when the input like 'joe@example.com@example.org' */ 244 /* the https scheme is assumed */ 245 uri = new URI("https://".concat(decodedUserInput.substring(decodedUserInput.lastIndexOf("@") + 1, 246 decodedUserInput.length()))); 247 } else { 248 uri = new URI(normalizedIdentifier.replace("acct:", "acct://")); 249 } 250 } else { 251 uri = new URI(decodedUserInput); 252 // Removing fragments 253 if (decodedUserInput.contains("#")) { 254 normalizedIdentifier = decodedUserInput.substring(0, decodedUserInput.indexOf("#")); 255 } 256 } 257 258 final int port = uri.getPort(); 259 final String host = uri.getHost(); 260 return new AccountIdentifier(new URI(normalizedIdentifier), 261 new URI("http".equals(uri.getScheme()) ? "http" : "https", 262 null, host, port, "/", null, null)); 263 } 264 265 final class AccountIdentifier { 266 private final URI normalizedIdentifier; 267 private final URI hostBase; 268 269 AccountIdentifier(final URI normalizedIdentifier, final URI hostBase) { 270 this.normalizedIdentifier = normalizedIdentifier; 271 this.hostBase = hostBase; 272 } 273 274 /** 275 * Returns the normalized identifier(defines the subject of the 276 * requested resource for the WebFinger service). 277 * 278 * @return The normalized identifier. 279 */ 280 URI getNormalizedIdentifier() { 281 return normalizedIdentifier; 282 } 283 284 /** 285 * Returns the host base of this account which could belong to an 286 * {@link Issuer} supported domains or should be the host location where 287 * is hosted the WebFinger service. 288 * 289 * @return The host base of this account. 290 */ 291 URI getHostBase() { 292 return hostBase; 293 } 294 } 295}