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 — 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}