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.server.mcp.v20260728;
018
019
020import java.security.*;
021import java.util.*;
022
023import org.apache.juneau.rest.server.auth.ClaimsPrincipal;
024import static org.apache.juneau.commons.utils.Shorts.*;
025
026/**
027 * <a class="doclink" href="https://datatracker.ietf.org/doc/html/rfc8707">RFC 8707</a> audience enforcement &mdash; the
028 * confused-deputy defense pairing with the MCP client's resource indicator.
029 *
030 * <p>
031 * Confirms that a validated access token was actually minted for <i>this</i> resource by checking that the token's
032 * {@code aud} or {@code resource} claim contains this server's resource identifier, rejecting a token whose audience
033 * targets a different resource.
034 *
035 * <h5 class='section'>Claims requirement:</h5>
036 * <p>
037 * Gate-level enforcement applies to a {@link ClaimsPrincipal} (as produced by {@code JwtTokenValidator} and OAuth
038 * introspection).  A validator returning a bare {@link Principal} that exposes no claims cannot be checked at the gate;
039 * the {@code requireAudienceClaim} flag decides the disposition:
040 * <ul>
041 *    <li><jk>true</jk> (the default) &mdash; <b>fail-closed</b>: a claims-less principal is rejected, so the
042 *       confused-deputy defense is never silently a no-op.
043 *    <li><jk>false</jk> &mdash; <b>opt-out</b>: a claims-less principal passes, delegating audience enforcement to the
044 *       validator itself (e.g. {@code JwtTokenValidator} enforcing {@code aud} internally, or an introspection AS that
045 *       legitimately omits {@code aud}).
046 * </ul>
047 *
048 * @since 10.0.0
049 */
050public final class McpAudienceValidator {
051
052   private McpAudienceValidator() {}
053
054   /**
055    * Returns whether the principal's token audience matches the expected resource identifier.
056    *
057    * @param principal The authenticated principal.  May be <jk>null</jk> (returns <jk>false</jk>).
058    * @param expectedAudience The expected audience (this server's resource identifier).  Must not be <jk>null</jk> or
059    *    blank.
060    * @param requireAudienceClaim Whether a principal exposing no claims is rejected (fail-closed) rather than passed
061    *    (see {@link McpResourceServerConfig#isRequireAudienceClaim()}).
062    * @return <jk>true</jk> if the audience matches, or the principal exposes no claims and {@code requireAudienceClaim}
063    *    is <jk>false</jk>; <jk>false</jk> if the principal is <jk>null</jk>, exposes no claims while
064    *    {@code requireAudienceClaim} is <jk>true</jk>, or carries claims whose {@code aud}/{@code resource} do not include
065    *    the expected audience.
066    */
067   public static boolean matches(Principal principal, String expectedAudience, boolean requireAudienceClaim) {
068      reqnb("expectedAudience", expectedAudience);
069      if (principal == null)
070         return false;
071      if (!(principal instanceof ClaimsPrincipal cp))
072         return !requireAudienceClaim;
073      return audienceValues(cp).contains(expectedAudience);
074   }
075
076   private static Set<String> audienceValues(ClaimsPrincipal cp) {
077      var out = new HashSet<String>();
078      addClaim(out, cp.getClaims().get("aud"));
079      addClaim(out, cp.getClaims().get("resource"));
080      return out;
081   }
082
083   private static void addClaim(Set<String> out, Object v) {
084      if (v == null)
085         return;
086      if (v instanceof Collection<?> c) {
087         for (var o : c)
088            if (o != null)
089               out.add(o.toString());
090      } else {
091         out.add(v.toString());
092      }
093   }
094}