001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.rest.auth.oauth.oidc;
018
019
020import java.io.*;
021import java.net.*;
022import java.time.*;
023import java.util.*;
024import java.util.function.*;
025
026import org.apache.juneau.commons.utils.*;
027
028import com.nimbusds.oauth2.sdk.*;
029import com.nimbusds.oauth2.sdk.as.*;
030import com.nimbusds.oauth2.sdk.http.*;
031import com.nimbusds.oauth2.sdk.id.*;
032import com.nimbusds.openid.connect.sdk.op.*;
033import static org.apache.juneau.commons.utils.Shorts.*;
034
035/**
036 * Fetches a Juneau-native {@link OidcMetadata} record describing an authorization server, supporting both the RFC 8414
037 * OAuth Authorization Server Metadata endpoint ({@code /.well-known/oauth-authorization-server}) and the OpenID Connect
038 * Discovery endpoint ({@code /.well-known/openid-configuration}).
039 *
040 * <p>
041 * {@link #discover()} tries {@code AuthorizationServerMetadata.resolve(...)} (RFC 8414) first and falls back to
042 * {@code OIDCProviderMetadata.resolve(...)} (OIDC Discovery); both perform the RFC 8414 / OIDC Discovery issuer identity
043 * check (the returned document's {@code issuer} must exactly equal the requested issuer).  The metadata is fetched on
044 * each call to {@link #discover()}; the result is not cached internally &mdash; callers cache themselves if needed.
045 *
046 * <h5 class='topic'>Usage</h5>
047 *
048 * <p class='bjava'>
049 *    <jk>var</jk> oidc = OidcDiscoveryClient.<jsm>create</jsm>()
050 *       .issuer(URI.<jsm>create</jsm>(<js>"https://login.example.com/realms/api"</js>))
051 *       .build()
052 *       .discover();
053 * </p>
054 *
055 * @since 10.0.0
056 */
057@SuppressWarnings({
058   "java:S1192" // Duplicated literals (argument/property names) read more clearly inline than as constants
059})
060public class OidcDiscoveryClient {
061
062   /**
063    * Static creator.
064    *
065    * @return A new builder.
066    */
067   public static Builder create() {
068      return new Builder();
069   }
070
071   /**
072    * Builder.
073    */
074   public static class Builder {
075      private URI issuer;
076      private Duration httpTimeout = DEFAULT_HTTP_TIMEOUT;
077      private Consumer<HTTPRequest> httpRequestConfigurator;
078
079      /** Constructor. */
080      protected Builder() {}
081
082      /**
083       * Sets the issuer URL.  Required.
084       *
085       * @param value The issuer URL.
086       * @return This object.
087       */
088      public Builder issuer(URI value) {
089         issuer = reqnn("value", value);
090         return this;
091      }
092
093      /**
094       * Sets the connect/read timeout applied to the discovery request.  Default 10 seconds.
095       *
096       * @param value The timeout.  Must not be <jk>null</jk> and must be positive.
097       * @return This object.
098       */
099      public Builder httpTimeout(Duration value) {
100         reqnn("value", value);
101         req(!value.isZero() && !value.isNegative(), "httpTimeout must be positive (was %s)", value);
102         httpTimeout = value;
103         return this;
104      }
105
106      /**
107       * Sets the HTTP request configurator.
108       *
109       * @param value The callback.
110       * @return This object.
111       */
112      public Builder httpRequestConfigurator(Consumer<HTTPRequest> value) {
113         httpRequestConfigurator = reqnn("value", value);
114         return this;
115      }
116
117      /**
118       * Builds the client.
119       *
120       * @return A new {@link OidcDiscoveryClient}.
121       * @throws IllegalArgumentException If the issuer is not <js>"https"</js> and does not target a loopback host.
122       */
123      public OidcDiscoveryClient build() {
124         if (issuer == null)
125            throw new IllegalStateException("OidcDiscoveryClient requires issuer(...)");
126         UriUtils.assertSecureOrLoopback(issuer);
127         return new OidcDiscoveryClient(this);
128      }
129   }
130
131   /** Default connect/read timeout applied to a discovery request when the caller sets none. */
132   static final Duration DEFAULT_HTTP_TIMEOUT = Duration.ofSeconds(10);
133
134   private final URI issuer;
135   private final Duration httpTimeout;
136   private final Consumer<HTTPRequest> httpRequestConfigurator;
137
138   /**
139    * Constructor.
140    *
141    * @param b The builder.
142    */
143   protected OidcDiscoveryClient(Builder b) {
144      this.issuer = b.issuer;
145      this.httpTimeout = b.httpTimeout;
146      this.httpRequestConfigurator = b.httpRequestConfigurator;
147   }
148
149   /**
150    * Returns the configured issuer URL.
151    *
152    * @return The issuer URL.
153    */
154   public URI getIssuer() {
155      return issuer;
156   }
157
158   /**
159    * Fetches the authorization server's metadata, trying RFC 8414 first then OIDC Discovery.
160    *
161    * @return The parsed metadata record.
162    * @throws IOException If both discovery HTTP fetches fail.
163    * @throws OidcDiscoveryException If the metadata cannot be parsed or the issuer identity check fails.
164    */
165   public OidcMetadata discover() throws IOException, OidcDiscoveryException {
166      var iss = new Issuer(issuer.toString());
167      HTTPRequestModifier cfg = http -> {
168         http.setConnectTimeout((int) httpTimeout.toMillis());
169         http.setReadTimeout((int) httpTimeout.toMillis());
170         if (httpRequestConfigurator != null)
171            httpRequestConfigurator.accept(http);
172         return http;
173      };
174      // RFC 8414 OAuth Authorization Server Metadata first, OIDC Discovery as fallback (spec requires both).
175      try {
176         return toMetadata(AuthorizationServerMetadata.resolve(iss, null, cfg, false));
177      } catch (IOException | GeneralException asFailure) {
178         try {
179            return toMetadata(OIDCProviderMetadata.resolve(iss, null, cfg, false));
180         } catch (GeneralException oidcFailure) {
181            throw new OidcDiscoveryException("Failed to resolve authorization-server metadata for " + issuer
182               + " via RFC 8414 or OIDC Discovery", oidcFailure);
183         }
184      }
185   }
186
187   private static OidcMetadata toMetadata(AuthorizationServerMetadata md) {
188      var extras = new LinkedHashMap<String,Object>();
189      var json = md.toJSONObject();
190      for (var e : json.entrySet()) {
191         var k = e.getKey();
192         if (!STANDARD_FIELDS.contains(k))
193            extras.put(k, e.getValue());
194      }
195      URI userInfo = null;
196      URI endSession = null;
197      if (md instanceof OIDCProviderMetadata md2) {
198         userInfo = md2.getUserInfoEndpointURI();
199         endSession = md2.getEndSessionEndpointURI();
200      }
201      return new OidcMetadata(
202         URI.create(md.getIssuer().getValue()),
203         md.getTokenEndpointURI(),
204         md.getAuthorizationEndpointURI(),
205         md.getIntrospectionEndpointURI(),
206         md.getJWKSetURI(),
207         userInfo,
208         endSession,
209         toScopeSet(md),
210         extras
211      );
212   }
213
214   private static Set<String> toScopeSet(AuthorizationServerMetadata md) {
215      var s = md.getScopes();
216      if (s == null)
217         return Collections.emptySet();
218      return new LinkedHashSet<>(s.toStringList());
219   }
220
221   private static final Set<String> STANDARD_FIELDS = Set.of(
222      "issuer", "token_endpoint", "authorization_endpoint", "introspection_endpoint",
223      "jwks_uri", "userinfo_endpoint", "end_session_endpoint", "scopes_supported"
224   );
225}