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}