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.*;
020import static org.apache.juneau.commons.utils.UriUtils.*;
021
022import java.io.*;
023import java.net.*;
024import java.time.*;
025import java.util.*;
026import java.util.function.*;
027
028import org.apache.juneau.rest.auth.oauth.flow.*;
029
030import com.nimbusds.oauth2.sdk.*;
031import com.nimbusds.oauth2.sdk.http.*;
032import com.nimbusds.oauth2.sdk.id.*;
033import com.nimbusds.oauth2.sdk.pkce.*;
034
035/**
036 * Interactive authorization-code + PKCE (S256) token acquisition for headless / CLI use.
037 *
038 * <p>
039 * Builds on the relocated {@link OAuthAuthorizationCodeFlow}: opens a {@link LoopbackRedirectReceiver}, generates the
040 * {@code state} and PKCE {@code code_verifier}/{@code code_challenge}, hands the authorization URL to a caller-supplied
041 * browser launcher, waits for the loopback redirect, and exchanges the returned code for a token.
042 *
043 * <p>
044 * Security checks on the callback:
045 * <ul>
046 *    <li><b>CSRF</b> &mdash; the returned {@code state} must match the one that was issued (single-use via
047 *       {@link EphemeralStore}).
048 *    <li><b>SEP-2468 ({@code iss}, RFC 9207)</b> &mdash; the callback {@code iss} parameter must equal the
049 *       discovered/expected issuer; a mismatch is rejected.  The check gates BOTH the success and error paths (a client
050 *       MUST NOT act on an error response whose issuer doesn't match).  {@link Builder#expectedIssuer(URI)} is required
051 *       by default (opt out with {@link Builder#skipIssuerValidation(boolean)}); a missing {@code iss} is accepted
052 *       unless {@link Builder#requireIssuerResponseParameter(boolean)} is set.
053 * </ul>
054 *
055 * <p>
056 * {@link Builder#resource(URI)} (the RFC 8707 resource indicator) is required so the acquired token's audience is bound
057 * to the target MCP server.
058 *
059 * <p>
060 * The optional {@link Builder#offlineAccess(boolean)} adds the {@code offline_access} scope so the IdP issues a refresh
061 * token that can subsequently drive an {@link McpTokenProvider} refresh-token provider (SEP-2207).
062 *
063 * @since 10.0.0
064 */
065@SuppressWarnings({
066   "java:S1192" // Duplicated literals (argument/property names) read more clearly inline than as constants
067})
068public class McpAuthorizationCodeAcquirer {
069
070   /**
071    * Static creator.
072    *
073    * @return A new builder.
074    */
075   public static Builder create() {
076      return new Builder();
077   }
078
079   /**
080    * Builder.
081    */
082   public static class Builder {
083      private URI authorizationEndpoint;
084      private URI tokenEndpoint;
085      private String clientId;
086      private Supplier<String> clientSecretSupplier;
087      private final Set<String> scopes = st();
088      private boolean offlineAccess;
089      private URI resource;
090      private URI expectedIssuer;
091      private boolean skipIssuerValidation;
092      private boolean requireIssuerResponseParameter;
093      private String redirectPath = DEFAULT_REDIRECT_PATH;
094      private int redirectPort;  // 0 == ephemeral / port-agnostic (the default).
095      private Consumer<URI> browserLauncher = McpAuthorizationCodeAcquirer::printAuthorizationUrl;
096      private EphemeralStore store;
097      private Duration httpTimeout = DEFAULT_HTTP_TIMEOUT;
098      private Consumer<HTTPRequest> httpRequestConfigurator;
099
100      /** Constructor. */
101      protected Builder() {}
102
103      /**
104       * Sets the authorization endpoint URL.  Required.
105       *
106       * @param value The endpoint URL.
107       * @return This object.
108       */
109      public Builder authorizationEndpoint(URI value) {
110         authorizationEndpoint = assertSecureOrLoopback(reqnn("value", value));
111         return this;
112      }
113
114      /**
115       * Sets the token endpoint URL.  Required.
116       *
117       * @param value The endpoint URL.
118       * @return This object.
119       */
120      public Builder tokenEndpoint(URI value) {
121         tokenEndpoint = assertSecureOrLoopback(reqnn("value", value));
122         return this;
123      }
124
125      /**
126       * Sets the OAuth client ID.  Required.
127       *
128       * @param value The client ID.
129       * @return This object.
130       */
131      public Builder clientId(String value) {
132         clientId = reqnb("value", value);
133         return this;
134      }
135
136      /**
137       * Sets the client secret (omit for a public/native PKCE-only client).
138       *
139       * @param value The client secret.
140       * @return This object.
141       */
142      public Builder clientSecret(String value) {
143         reqnb("value", value);
144         clientSecretSupplier = () -> value;
145         return this;
146      }
147
148      /**
149       * Adds requested scopes.
150       *
151       * @param values The scopes.
152       * @return This object.
153       */
154      public Builder scope(String... values) {
155         reqnn("values", values);
156         for (var v : values) {
157            reqnb("scope", v);
158            scopes.add(v);
159         }
160         return this;
161      }
162
163      /**
164       * Requests the {@code offline_access} scope so the IdP issues a refresh token (SEP-2207).
165       *
166       * @param value <jk>true</jk> to add {@code offline_access}.
167       * @return This object.
168       */
169      public Builder offlineAccess(boolean value) {
170         offlineAccess = value;
171         return this;
172      }
173
174      /**
175       * Sets the RFC 8707 {@code resource} indicator (the canonical MCP server URI).
176       *
177       * @param value The canonical resource URI.  Must not be <jk>null</jk>.
178       * @return This object.
179       */
180      public Builder resource(URI value) {
181         resource = reqnn("value", value);
182         return this;
183      }
184
185      /**
186       * Sets the expected issuer the callback {@code iss} parameter is validated against (SEP-2468).
187       *
188       * <p>
189       * Typically the {@code issuer} returned by {@code OidcDiscoveryClient} for the discovered authorization
190       * server.
191       *
192       * @param value The expected issuer.  Must not be <jk>null</jk>.
193       * @return This object.
194       */
195      public Builder expectedIssuer(URI value) {
196         expectedIssuer = reqnn("value", value);
197         return this;
198      }
199
200      /**
201       * Explicitly opts out of the SEP-2468 {@code iss} validation.
202       *
203       * <p>
204       * {@code iss} validation is on by default; {@link #build()} requires {@link #expectedIssuer(URI)} unless this
205       * is set to <jk>true</jk>.  Only disable this against an authorization server known not to emit {@code iss}.
206       *
207       * @param value <jk>true</jk> to skip the {@code iss} check.
208       * @return This object.
209       */
210      public Builder skipIssuerValidation(boolean value) {
211         skipIssuerValidation = value;
212         return this;
213      }
214
215      /**
216       * Requires the authorization response to carry an {@code iss} parameter, rejecting a response that omits it.
217       *
218       * <p>
219       * Set this when the discovered authorization-server metadata advertises
220       * {@code authorization_response_iss_parameter_supported} (RFC 9207); see
221       * {@link org.apache.juneau.rest.auth.oauth.oidc.OidcMetadata#authorizationResponseIssParameterSupported()}.
222       *
223       * @param value <jk>true</jk> to reject a response with no {@code iss} parameter.
224       * @return This object.
225       */
226      public Builder requireIssuerResponseParameter(boolean value) {
227         requireIssuerResponseParameter = value;
228         return this;
229      }
230
231      /**
232       * Sets the loopback redirect path.  Default {@code /callback}.
233       *
234       * @param value The path (must start with {@code /}).  Must not be <jk>null</jk> or blank.
235       * @return This object.
236       */
237      public Builder redirectPath(String value) {
238         redirectPath = reqnb("value", value);
239         req(redirectPath.startsWith("/"), "redirectPath must start with '/' (was '%s')", redirectPath);
240         return this;
241      }
242
243      /**
244       * Sets a fixed loopback redirect port so the receiver binds exactly that port (the "bind-first" strategy for
245       * strict exact-match authorization servers).
246       *
247       * <p>
248       * Defaults to {@code 0}, meaning an ephemeral port (the RFC 8252 &sect;7.3 port-agnostic strategy) &mdash; the
249       * simplest interoperable choice.  Set a non-zero port only when the authorization server registration used a
250       * bind-first {@link LoopbackRedirectUris#forPort(int, String) forPort} redirect; {@link McpClientRegistrations#configure}
251       * wires this automatically from such a registration.
252       *
253       * @param value The fixed loopback port in {@code 1..65535}, or {@code 0} for an ephemeral port.
254       * @return This object.
255       */
256      public Builder redirectPort(int value) {
257         req(value == 0 || (value >= 1 && value <= 65535), "redirectPort must be 0 (ephemeral) or in 1..65535 (was %s)", value);
258         redirectPort = value;
259         return this;
260      }
261
262      /**
263       * Sets the callback invoked with the authorization URL so the caller can open it in a browser (or print it).
264       *
265       * @param value The launcher.  Must not be <jk>null</jk>.
266       * @return This object.
267       */
268      public Builder browserLauncher(Consumer<URI> value) {
269         browserLauncher = reqnn("value", value);
270         return this;
271      }
272
273      /**
274       * Sets the ephemeral state/verifier store.  Defaults to a fresh {@link EphemeralStore}.
275       *
276       * @param value The store.  Must not be <jk>null</jk>.
277       * @return This object.
278       */
279      public Builder store(EphemeralStore value) {
280         store = reqnn("value", value);
281         return this;
282      }
283
284      /**
285       * Sets the connect/read timeout applied to the token-exchange request.  Default 10 seconds.
286       *
287       * @param value The timeout.  Must not be <jk>null</jk> and must be positive.
288       * @return This object.
289       */
290      public Builder httpTimeout(Duration value) {
291         reqnn("value", value);
292         req(!value.isZero() && !value.isNegative(), "httpTimeout must be positive (was %s)", value);
293         httpTimeout = value;
294         return this;
295      }
296
297      /**
298       * Sets a callback that customizes the Nimbus {@link HTTPRequest} for the token exchange.
299       *
300       * @param value The callback.  Must not be <jk>null</jk>.
301       * @return This object.
302       */
303      public Builder httpRequestConfigurator(Consumer<HTTPRequest> value) {
304         httpRequestConfigurator = reqnn("value", value);
305         return this;
306      }
307
308      /**
309       * Builds the acquirer.
310       *
311       * @return A new {@link McpAuthorizationCodeAcquirer}.
312       */
313      public McpAuthorizationCodeAcquirer build() {
314         if (authorizationEndpoint == null)
315            throw isex("McpAuthorizationCodeAcquirer requires authorizationEndpoint(...)");
316         if (tokenEndpoint == null)
317            throw isex("McpAuthorizationCodeAcquirer requires tokenEndpoint(...)");
318         if (clientId == null)
319            throw isex("McpAuthorizationCodeAcquirer requires clientId(...)");
320         if (resource == null)
321            throw isex("McpAuthorizationCodeAcquirer requires resource(...) (RFC 8707 resource indicator)");
322         if (expectedIssuer == null && ! skipIssuerValidation)
323            throw isex("McpAuthorizationCodeAcquirer requires expectedIssuer(...) (SEP-2468); call skipIssuerValidation(true) to opt out");
324         if (store == null)
325            store = EphemeralStore.create();
326         return new McpAuthorizationCodeAcquirer(this);
327      }
328   }
329
330   /** Default connect/read timeout applied to the token-exchange request when the caller sets none. */
331   static final Duration DEFAULT_HTTP_TIMEOUT = Duration.ofSeconds(10);
332
333   /**
334    * Default loopback callback path.  Not RFC 8252-mandated (only the loopback address itself is spec-mandated) and
335    * already fully caller-overridable via {@link Builder#redirectPath(String)}; this is merely the conventional
336    * default.
337    */
338   @SuppressWarnings({
339      "java:S1075" // Default value for an already caller-configurable Builder#redirectPath(String); not a hardcoded endpoint.
340   })
341   static final String DEFAULT_REDIRECT_PATH = "/callback";
342
343   /** Default HTML body served on the loopback callback so the browser tab shows a friendly confirmation. */
344   private static final String DEFAULT_CALLBACK_HTML =
345      "<html><body><h3>Authorization received</h3><p>You may close this window and return to the application.</p></body></html>";
346
347   private final URI authorizationEndpoint;
348   private final URI tokenEndpoint;
349   private final String clientId;
350   private final Supplier<String> clientSecretSupplier;
351   private final Set<String> scopes;
352   private final URI resource;
353   private final URI expectedIssuer;
354   private final boolean skipIssuerValidation;
355   private final boolean requireIssuerResponseParameter;
356   private final String redirectPath;
357   private final int redirectPort;
358   private final Consumer<URI> browserLauncher;
359   private final EphemeralStore store;
360   private final Duration httpTimeout;
361   private final Consumer<HTTPRequest> httpRequestConfigurator;
362
363   /**
364    * Constructor.
365    *
366    * @param b The builder.
367    */
368   protected McpAuthorizationCodeAcquirer(Builder b) {
369      this.authorizationEndpoint = b.authorizationEndpoint;
370      this.tokenEndpoint = b.tokenEndpoint;
371      this.clientId = b.clientId;
372      this.clientSecretSupplier = b.clientSecretSupplier;
373      var s = new LinkedHashSet<>(b.scopes);
374      if (b.offlineAccess)
375         s.add("offline_access");
376      this.scopes = u(s);
377      this.resource = b.resource;
378      this.expectedIssuer = b.expectedIssuer;
379      this.skipIssuerValidation = b.skipIssuerValidation;
380      this.requireIssuerResponseParameter = b.requireIssuerResponseParameter;
381      this.redirectPath = b.redirectPath;
382      this.redirectPort = b.redirectPort;
383      this.browserLauncher = b.browserLauncher;
384      this.store = b.store;
385      this.httpTimeout = b.httpTimeout;
386      this.httpRequestConfigurator = b.httpRequestConfigurator;
387   }
388
389   /**
390    * Default {@link Builder#browserLauncher(Consumer)}: prints the authorization URL to standard output so a headless
391    * user can open it manually (rather than silently doing nothing).
392    *
393    * @param authUrl The authorization URL.
394    */
395   @SuppressWarnings({
396      "java:S106" // Intentional user-facing console prompt for headless CLI authorization; not diagnostic logging.
397   })
398   private static void printAuthorizationUrl(URI authUrl) {
399      System.out.println("Open the following URL in a browser to authorize:\n  " + authUrl);
400   }
401
402   /**
403    * Runs the full interactive acquisition: starts the loopback listener, opens the authorization URL, waits for the
404    * redirect, validates it, and exchanges the code for a token.
405    *
406    * @param timeout The maximum time to wait for the user to complete authorization at the IdP.  Must not be
407    *    <jk>null</jk>.
408    * @return The acquired token.
409    * @throws McpAuthException If the listener cannot start, the wait times out, or the callback fails validation.
410    * @throws OAuthFlowException If the token-exchange HTTP round-trip fails or the IdP returns an error.
411    */
412   public OAuthToken acquire(Duration timeout) {
413      reqnn("timeout", timeout);
414      LoopbackRedirectReceiver receiver;
415      try {
416         receiver = new LoopbackRedirectReceiver(redirectPath, redirectPort, DEFAULT_CALLBACK_HTML);
417      } catch (IOException e) {
418         throw new McpAuthException("Failed to start the loopback redirect listener", e);
419      }
420      try (receiver) {
421         var flow = buildFlow(receiver.redirectUri());
422         var verifier = new CodeVerifier();
423         var state = new State().getValue();
424         var authUrl = buildAuthorizationUrl(flow, state, verifier);
425         store.store(state, verifier.getValue(), expectedIssuer);
426         browserLauncher.accept(authUrl);
427         var callbackUri = receiver.awaitCallback(timeout);
428         return handleCallback(flow, callbackUri);
429      }
430   }
431
432   /**
433    * Builds the authorization URL for a given state and PKCE verifier (exposed for callers driving their own
434    * user-agent interaction).
435    *
436    * @param flow The configured flow.  Must not be <jk>null</jk>.
437    * @param state The opaque state.  Must not be <jk>null</jk> or blank.
438    * @param verifier The PKCE verifier.  Must not be <jk>null</jk>.
439    * @return The authorization URL.
440    */
441   public URI buildAuthorizationUrl(OAuthAuthorizationCodeFlow flow, String state, CodeVerifier verifier) {
442      reqnn("flow", flow);
443      reqnn("verifier", verifier);
444      var challenge = CodeChallenge.compute(CodeChallengeMethod.S256, verifier);
445      return flow.buildAuthorizationUrl(state, challenge);
446   }
447
448   /**
449    * Validates a loopback callback and exchanges the authorization code for a token.
450    *
451    * <p>
452    * Consumes the pending {@code state} entry (single-use), performs the SEP-2468 {@code iss} check against the issuer
453    * recorded when the request was started, then exchanges the code.
454    *
455    * @param flow The configured flow.  Must not be <jk>null</jk>.
456    * @param callbackUri The full loopback callback URI.  Must not be <jk>null</jk>.
457    * @return The acquired token.
458    * @throws McpAuthException If the callback is an error response, the state is unknown/replayed, or the {@code iss}
459    *    check fails.
460    */
461   public OAuthToken handleCallback(OAuthAuthorizationCodeFlow flow, URI callbackUri) {
462      reqnn("flow", flow);
463      reqnn("callbackUri", callbackUri);
464      var resp = parse(callbackUri);
465      var state = resp.getState() == null ? null : resp.getState().getValue();
466      if (state == null)
467         throw new McpAuthException("Authorization callback is missing the state parameter");
468      var pending = store.consume(state)
469         .orElseThrow(() -> new McpAuthException("Authorization callback state is unknown, expired, or replayed"));
470      var code = extractCode(resp, pending.expectedIssuer(), skipIssuerValidation, requireIssuerResponseParameter);
471      return flow.exchange(code, new CodeVerifier(pending.codeVerifier()));
472   }
473
474   /**
475    * Validates an authorization-code callback and returns the authorization code, without any token exchange or
476    * state-store interaction.
477    *
478    * <p>
479    * This is the pure SEP-2468 / CSRF validation step.  The {@code iss} comparison gates BOTH success and error
480    * responses (per the MCP spec a client MUST NOT act on an {@code error}/{@code error_description}/{@code error_uri}
481    * from a response whose {@code iss} does not match), so an error response with a mismatched issuer is reported as
482    * an issuer mismatch rather than a denial.  A missing/mismatched {@code state} is rejected as CSRF.  When
483    * {@code iss} is absent it is accepted (future-proofed for the eventual "reject if absent" MUST); use
484    * {@link #validateAuthorizationResponse(URI, String, URI, boolean)} to require it.
485    *
486    * @param callbackUri The full callback URI.  Must not be <jk>null</jk>.
487    * @param expectedState The state issued when the authorization request was built.  Must not be <jk>null</jk>.
488    * @param expectedIssuer The discovered issuer to validate {@code iss} against.  Must not be <jk>null</jk>.
489    * @return The authorization code.
490    * @throws McpAuthException If validation fails.
491    */
492   public static String validateAuthorizationResponse(URI callbackUri, String expectedState, URI expectedIssuer) {
493      return validateAuthorizationResponse(callbackUri, expectedState, expectedIssuer, false);
494   }
495
496   /**
497    * Validates an authorization-code callback and returns the authorization code, without any token exchange or
498    * state-store interaction.
499    *
500    * @param callbackUri The full callback URI.  Must not be <jk>null</jk>.
501    * @param expectedState The state issued when the authorization request was built.  Must not be <jk>null</jk>.
502    * @param expectedIssuer The discovered issuer to validate {@code iss} against.  Must not be <jk>null</jk>.
503    * @param requireIssuerParameter <jk>true</jk> to reject a response that omits the {@code iss} parameter (RFC 9207
504    *    {@code authorization_response_iss_parameter_supported}).
505    * @return The authorization code.
506    * @throws McpAuthException If validation fails.
507    */
508   public static String validateAuthorizationResponse(URI callbackUri, String expectedState, URI expectedIssuer, boolean requireIssuerParameter) {
509      reqnn("callbackUri", callbackUri);
510      reqnb("expectedState", expectedState);
511      reqnn("expectedIssuer", expectedIssuer);
512      var resp = parse(callbackUri);
513      var state = resp.getState() == null ? null : resp.getState().getValue();
514      if (neq(expectedState, state))
515         throw new McpAuthException("Authorization callback state mismatch (possible CSRF)");
516      return extractCode(resp, expectedIssuer, false, requireIssuerParameter);
517   }
518
519   private static AuthorizationResponse parse(URI callbackUri) {
520      try {
521         return AuthorizationResponse.parse(callbackUri);
522      } catch (ParseException e) {
523         throw new McpAuthException("Authorization callback could not be parsed", e);
524      }
525   }
526
527   private static String extractCode(AuthorizationResponse resp, URI expectedIssuer, boolean skipIssuerValidation, boolean requireIssuerParameter) {
528      // SEP-2468: the iss check gates BOTH the success and error paths — a client MUST NOT act on an error response
529      // whose issuer doesn't match, so this runs before the indicatesSuccess() branch.
530      if (! skipIssuerValidation) {
531         var iss = resp.getIssuer();
532         if (iss == null) {
533            if (requireIssuerParameter)
534               throw new McpAuthException("Authorization response is missing the required iss parameter (SEP-2468 / RFC 9207)");
535         } else if (expectedIssuer == null || ! iss.getValue().equals(expectedIssuer.toString())) {
536            throw new McpAuthException("Authorization response issuer mismatch (SEP-2468): expected '"
537               + expectedIssuer + "' but got '" + iss.getValue() + "'");
538         }
539      }
540      if (! resp.indicatesSuccess()) {
541         throw new McpAuthException("Authorization request was denied: " + resp.toErrorResponse().getErrorObject().getCode());
542      }
543      return resp.toSuccessResponse().getAuthorizationCode().getValue();
544   }
545
546   private OAuthAuthorizationCodeFlow buildFlow(URI redirectUri) {
547      var b = OAuthAuthorizationCodeFlow.create()
548         .authorizationEndpoint(authorizationEndpoint)
549         .tokenEndpoint(tokenEndpoint)
550         .clientId(clientId)
551         .redirectUri(redirectUri)
552         .httpTimeout(httpTimeout);
553      if (clientSecretSupplier != null)
554         b.clientSecretSupplier(clientSecretSupplier);
555      scopes.forEach(b::scope);
556      b.resource(resource);
557      if (httpRequestConfigurator != null)
558         b.httpRequestConfigurator(httpRequestConfigurator);
559      return b.build();
560   }
561
562   /**
563    * The configured fixed loopback redirect port ({@code 0} means ephemeral).  Package-private for test verification of
564    * the bind-first port carry-through.
565    *
566    * @return The redirect port.
567    */
568   int redirectPort() {
569      return redirectPort;
570   }
571}