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.client.mcp.auth; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020 021import java.net.*; 022import java.time.*; 023import java.util.*; 024import java.util.function.*; 025 026import org.apache.juneau.rest.auth.oauth.oidc.*; 027 028import com.nimbusds.oauth2.sdk.http.*; 029 030/** 031 * Orchestrates SEP-2352 registration-mechanism selection, issuer-keying, and re-registration on authorization-server 032 * migration, returning a usable {@link McpClientRegistration} for a discovered AS. 033 * 034 * <p> 035 * Given a discovered {@link OidcMetadata} (whose {@code issuer} identifies the AS and whose {@code extras} carry the 036 * RFC 7591 {@code registration_endpoint}), {@link #resolve(OidcMetadata)} selects credentials per the SEP-2352 rules: 037 * <ul> 038 * <li><b>Pre-registered credentials</b> ({@link Builder#preRegistered(McpClientRegistration)}) are used only when 039 * their bound {@code issuer} matches the discovered AS; a mismatch (the AS migrated) surfaces an 040 * {@link McpAuthException} rather than silently reusing cross-AS credentials (SEP-2352 SHOULD). 041 * <li><b>With a store</b> ({@link Builder#store(McpClientRegistrationStore)}) the issuer-keyed entry is reused without 042 * a second DCR round-trip; on migration to a different issuer the store has no entry for the new AS, so the manager 043 * re-registers via DCR and stores under the new issuer — the old AS's entry is never reused (SEP-2352 044 * "MUST NOT reuse client credentials from a different authorization server"). 045 * <li><b>On-demand mode</b> (no store) performs a fresh DCR on every {@link #resolve(OidcMetadata)} call — fully 046 * compliant (persisting a registration is optional). 047 * <li>If the AS advertises no {@code registration_endpoint} and no matching pre-registered credentials exist, an 048 * {@link McpAuthException} is thrown (no way to obtain a {@code client_id}; CIMD is a tracked fast-follow). 049 * </ul> 050 * 051 * @since 10.0.0 052 */ 053@SuppressWarnings({ 054 "java:S1192" // Duplicated literals (argument/property names) read more clearly inline than as constants 055}) 056public class McpClientRegistrationManager { 057 058 /** 059 * Static creator. 060 * 061 * @return A new builder. 062 */ 063 public static Builder create() { 064 return new Builder(); 065 } 066 067 /** 068 * Builder. 069 */ 070 public static class Builder { 071 McpClientRegistrationStore store; 072 McpClientRegistration preRegistered; 073 McpApplicationType applicationType = McpApplicationType.NATIVE; 074 Supplier<List<URI>> redirectUrisSupplier; 075 final Set<String> scopes = st(); 076 boolean confidential; 077 String clientName; 078 Supplier<String> initialAccessTokenSupplier; 079 Duration httpTimeout = McpDynamicClientRegistrar.DEFAULT_HTTP_TIMEOUT; 080 Consumer<HTTPRequest> httpRequestConfigurator; 081 Function<OidcMetadata,McpClientRegistration> dcrOverride; 082 083 /** Constructor. */ 084 protected Builder() {} 085 086 /** 087 * Sets the optional issuer-keyed credential store. When omitted, the manager runs in on-demand mode and 088 * performs a fresh DCR on every {@code resolve(...)} call (compliant). 089 * 090 * @param value The store. Must not be <jk>null</jk>. 091 * @return This object. 092 */ 093 public Builder store(McpClientRegistrationStore value) { 094 store = reqnn("value", value); 095 return this; 096 } 097 098 /** 099 * Sets pre-registered (manually provisioned) credentials bound to a specific AS {@code issuer}. 100 * 101 * @param value The pre-registered credentials. Must not be <jk>null</jk>. 102 * @return This object. 103 */ 104 public Builder preRegistered(McpClientRegistration value) { 105 preRegistered = reqnn("value", value); 106 return this; 107 } 108 109 /** 110 * Sets the {@code application_type} declared when the manager performs DCR. Default {@link McpApplicationType#NATIVE}. 111 * 112 * @param value The application type. Must not be <jk>null</jk>. 113 * @return This object. 114 */ 115 public Builder applicationType(McpApplicationType value) { 116 applicationType = reqnn("value", value); 117 return this; 118 } 119 120 /** 121 * Sets the redirect URIs to register when the manager performs DCR. 122 * 123 * @param values The redirect URIs (e.g. {@link LoopbackRedirectUris#portAgnostic(String)}). Must not be 124 * <jk>null</jk> or empty. 125 * @return This object. 126 */ 127 public Builder redirectUris(List<URI> values) { 128 reqnn("values", values); 129 req(! values.isEmpty(), "redirectUris must not be empty"); 130 var copy = u(cp(values)); 131 redirectUrisSupplier = () -> copy; 132 return this; 133 } 134 135 /** 136 * Sets a supplier of redirect URIs to register when the manager performs DCR (for the bind-first strategy where 137 * the loopback port is learned per-connection). 138 * 139 * @param value The supplier. Must not be <jk>null</jk>. 140 * @return This object. 141 */ 142 public Builder redirectUrisSupplier(Supplier<List<URI>> value) { 143 redirectUrisSupplier = reqnn("value", value); 144 return this; 145 } 146 147 /** 148 * Adds scopes requested during DCR. 149 * 150 * @param values The scopes. 151 * @return This object. 152 */ 153 public Builder scope(String... values) { 154 reqnn("values", values); 155 for (var v : values) { 156 reqnb("scope", v); 157 scopes.add(v); 158 } 159 return this; 160 } 161 162 /** 163 * Registers a confidential client during DCR (default public/native). 164 * 165 * @param value <jk>true</jk> to register a confidential client. 166 * @return This object. 167 */ 168 public Builder confidential(boolean value) { 169 confidential = value; 170 return this; 171 } 172 173 /** 174 * Sets the human-readable client name declared during DCR. 175 * 176 * @param value The client name. 177 * @return This object. 178 */ 179 public Builder clientName(String value) { 180 clientName = reqnb("value", value); 181 return this; 182 } 183 184 /** 185 * Sets the RFC 7591 §3 initial access token for a protected registration endpoint. 186 * 187 * @param value The initial access token. Must not be <jk>null</jk> or blank. 188 * @return This object. 189 */ 190 public Builder initialAccessToken(String value) { 191 reqnb("value", value); 192 initialAccessTokenSupplier = () -> value; 193 return this; 194 } 195 196 /** 197 * Sets the connect/read timeout applied to DCR requests. Default 10 seconds. 198 * 199 * @param value The timeout. Must not be <jk>null</jk> and must be positive. 200 * @return This object. 201 */ 202 public Builder httpTimeout(Duration value) { 203 reqnn("value", value); 204 req(!value.isZero() && !value.isNegative(), "httpTimeout must be positive (was %s)", value); 205 httpTimeout = value; 206 return this; 207 } 208 209 /** 210 * Sets a callback that customizes the Nimbus {@link HTTPRequest} for each DCR request. 211 * 212 * @param value The callback. Must not be <jk>null</jk>. 213 * @return This object. 214 */ 215 public Builder httpRequestConfigurator(Consumer<HTTPRequest> value) { 216 httpRequestConfigurator = reqnn("value", value); 217 return this; 218 } 219 220 /** 221 * Overrides the registration function the manager invokes to perform DCR (advanced / test seam). 222 * 223 * <p> 224 * The default builds an {@link McpDynamicClientRegistrar} from the discovered {@link OidcMetadata} and performs 225 * a live RFC 7591 round-trip; supply this to inject an alternate registration strategy or a stub. 226 * 227 * @param value The registration function mapping a discovered AS to an issued {@link McpClientRegistration}. 228 * Must not be <jk>null</jk>. 229 * @return This object. 230 */ 231 public Builder registrarFunction(Function<OidcMetadata,McpClientRegistration> value) { 232 dcrOverride = reqnn("value", value); 233 return this; 234 } 235 236 /** 237 * Builds the manager. 238 * 239 * @return A new {@link McpClientRegistrationManager}. 240 */ 241 public McpClientRegistrationManager build() { 242 return new McpClientRegistrationManager(this); 243 } 244 } 245 246 private final McpClientRegistrationStore store; 247 private final McpClientRegistration preRegistered; 248 private final McpApplicationType applicationType; 249 private final Supplier<List<URI>> redirectUrisSupplier; 250 private final Set<String> scopes; 251 private final boolean confidential; 252 private final String clientName; 253 private final Supplier<String> initialAccessTokenSupplier; 254 private final Duration httpTimeout; 255 private final Consumer<HTTPRequest> httpRequestConfigurator; 256 private final Function<OidcMetadata,McpClientRegistration> dcrOverride; 257 258 /** 259 * Constructor. 260 * 261 * @param b The builder. 262 */ 263 protected McpClientRegistrationManager(Builder b) { 264 this.store = b.store; 265 this.preRegistered = b.preRegistered; 266 this.applicationType = b.applicationType; 267 this.redirectUrisSupplier = b.redirectUrisSupplier; 268 this.scopes = u(new LinkedHashSet<>(b.scopes)); 269 this.confidential = b.confidential; 270 this.clientName = b.clientName; 271 this.initialAccessTokenSupplier = b.initialAccessTokenSupplier; 272 this.httpTimeout = b.httpTimeout; 273 this.httpRequestConfigurator = b.httpRequestConfigurator; 274 this.dcrOverride = b.dcrOverride; 275 } 276 277 /** 278 * Resolves a usable client registration for the discovered authorization server, applying the SEP-2352 selection, 279 * issuer-keying, and migration rules (see the class javadoc). 280 * 281 * @param as The discovered authorization-server metadata. Must not be <jk>null</jk>. 282 * @return A usable registration bound to {@code as.issuer()}. Never <jk>null</jk>. 283 * @throws McpAuthException If credentials cannot be obtained (AS migrated away from pre-registered creds, or the AS 284 * advertises no {@code registration_endpoint} and no matching pre-registered creds exist). 285 */ 286 public McpClientRegistration resolve(OidcMetadata as) { 287 reqnn("as", as); 288 var issuer = as.issuer(); 289 290 // Pre-registered credentials: use only if bound to this exact AS; a mismatch means the AS migrated (SEP-2352 291 // MUST NOT reuse cross-AS creds — SHOULD surface an error rather than silently reuse). 292 if (preRegistered != null) { 293 if (! preRegistered.issuer().equals(issuer)) 294 throw new McpAuthException("Pre-registered client credentials are bound to issuer '" + preRegistered.issuer() 295 + "' but the resource now indicates a different authorization server '" + issuer 296 + "' (SEP-2352): refusing to reuse credentials across authorization servers"); 297 if (store != null) 298 store.put(issuer, preRegistered); 299 return preRegistered; 300 } 301 302 // Store-hit: reuse the issuer-keyed entry without a second DCR round-trip. On migration to a new issuer the 303 // store has no entry for it, so we fall through to DCR and store under the new issuer, never touching the old. 304 if (store != null) { 305 var hit = store.find(issuer); 306 if (hit.isPresent()) { 307 // Defensive: a buggy durable store that returns an entry keyed under the wrong issuer would otherwise 308 // cause exactly the cross-AS credential reuse SEP-2352 forbids. Never trust the returned entry's key. 309 if (! hit.get().issuer().equals(issuer)) 310 throw new McpAuthException("Client-registration store returned an entry bound to issuer '" + hit.get().issuer() 311 + "' for a lookup of issuer '" + issuer + "' (SEP-2352): refusing to reuse credentials across authorization servers"); 312 return hit.get(); 313 } 314 } 315 316 var reg = register(as); 317 if (store != null) 318 store.put(issuer, reg); 319 return reg; 320 } 321 322 private McpClientRegistration register(OidcMetadata as) { 323 if (dcrOverride != null) 324 return dcrOverride.apply(as); 325 var regEndpoint = registrationEndpoint(as) 326 .orElseThrow(() -> new McpAuthException("Authorization server '" + as.issuer() 327 + "' advertises no registration_endpoint and no matching pre-registered credentials are configured" 328 + " (Dynamic Client Registration not possible; CIMD is out of scope)")); 329 if (redirectUrisSupplier == null) 330 throw new McpAuthException("McpClientRegistrationManager requires redirectUris(...) to perform Dynamic Client Registration"); 331 var b = McpDynamicClientRegistrar.create() 332 .registrationEndpoint(regEndpoint) 333 .issuer(as.issuer()) 334 .applicationType(applicationType) 335 .addRedirectUris(redirectUrisSupplier.get()) 336 .confidential(confidential) 337 .httpTimeout(httpTimeout); 338 scopes.forEach(b::scope); 339 if (clientName != null) 340 b.clientName(clientName); 341 if (initialAccessTokenSupplier != null) 342 b.initialAccessToken(initialAccessTokenSupplier.get()); 343 if (httpRequestConfigurator != null) 344 b.httpRequestConfigurator(httpRequestConfigurator); 345 return b.build().register(); 346 } 347 348 /** 349 * Extracts the RFC 7591 {@code registration_endpoint} from the discovered AS metadata's extras, if present. 350 * 351 * <p> 352 * The value is AS-supplied, so a hostile/misconfigured AS document is defended against here: a malformed URI or a 353 * plaintext {@code http://} (non-loopback) endpoint yields the documented {@link McpAuthException} rather than a raw 354 * {@link IllegalArgumentException} (from {@code URI.create}) or a downstream {@link IllegalStateException} (from the 355 * registrar's https gate). 356 */ 357 static Optional<URI> registrationEndpoint(OidcMetadata as) { 358 var v = as.extras().get("registration_endpoint"); 359 if (v == null) 360 return oe(); 361 URI uri; 362 try { 363 uri = URI.create(v.toString()); 364 } catch (IllegalArgumentException e) { 365 throw new McpAuthException("Authorization server advertised a malformed registration_endpoint: '" + v + "'", e); 366 } 367 if (! McpDynamicClientRegistrar.isSecureOrLoopback(uri)) 368 throw new McpAuthException("Authorization server advertised a non-https registration_endpoint (loopback exempt): '" + uri + "'"); 369 return o(uri); 370 } 371}