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.io.*;
022import java.net.*;
023import java.util.*;
024import java.util.stream.*;
025
026/**
027 * A parsed {@code WWW-Authenticate: Bearer ...} challenge as returned by an OAuth 2.1 / MCP resource server on a
028 * {@code 401}/{@code 403} response (RFC 6750 §3, RFC 9728 §5.1).
029 *
030 * <p>
031 * The MCP client uses this to recover the {@code resource_metadata} pointer to the RFC 9728 PRM document, the
032 * per-operation {@code scope} on a step-up challenge, and the {@code error} code.  Auth-param values are matched
033 * case-insensitively on the key per RFC 7235.
034 *
035 * <p>
036 * {@link Serializable} (it is just a scheme string plus an immutable string map) so it can ride along a serialized
037 * {@link McpInsufficientScopeException} without being dropped.
038 *
039 * @since 10.0.0
040 */
041public class WwwAuthenticateChallenge implements Serializable {
042
043   private static final long serialVersionUID = 1L;
044
045   private final String scheme;
046   private final Map<String,String> parameters;
047
048   /**
049    * Constructor.
050    *
051    * @param scheme The auth scheme (e.g. {@code "Bearer"}).  Must not be <jk>null</jk> or blank.
052    * @param parameters The auth-params (lower-cased keys).  Must not be <jk>null</jk>.
053    */
054   protected WwwAuthenticateChallenge(String scheme, Map<String,String> parameters) {
055      this.scheme = reqnb("scheme", scheme);
056      this.parameters = u(cp(reqnn("parameters", parameters)));
057   }
058
059   /**
060    * Parses a single {@code WWW-Authenticate} header value.
061    *
062    * <p>
063    * Recognizes a single challenge of the form {@code <scheme> <key>=<value>, <key>="<value>", ...}.  Both quoted
064    * and bare token values are accepted; commas inside quoted values are preserved.
065    *
066    * @param headerValue The raw header value.  May be <jk>null</jk> or blank.
067    * @return The parsed challenge, or {@link Optional#empty()} if the value is null/blank or has no scheme token.
068    */
069   public static Optional<WwwAuthenticateChallenge> parse(String headerValue) {
070      if (headerValue == null)
071         return oe();
072      var s = headerValue.strip();
073      if (s.isEmpty())
074         return oe();
075      var sp = s.indexOf(' ');
076      if (sp < 0)
077         return o(new WwwAuthenticateChallenge(s, Map.of()));
078      var scheme = s.substring(0, sp);
079      var rest = s.substring(sp + 1).strip();
080      return o(new WwwAuthenticateChallenge(scheme, parseParams(rest)));
081   }
082
083   private static Map<String,String> parseParams(String s) {
084      var out = new LinkedHashMap<String,String>();
085      var n = s.length();
086      var i = 0;
087      while ((i = skipSeparators(s, i, n)) < n) {
088         var param = parseParam(s, i, n);
089         i = param.nextIndex();
090         if (param.key() != null && !param.key().isEmpty())
091            out.putIfAbsent(param.key(), param.value());
092      }
093      return out;
094   }
095
096   /** Advances past whitespace and separator commas, returning the index of the next non-separator char (or {@code n}). */
097   private static int skipSeparators(String s, int i, int n) {
098      while (i < n && (Character.isWhitespace(s.charAt(i)) || s.charAt(i) == ','))
099         i++;
100      return i;
101   }
102
103   /**
104    * Parses a single {@code key=value} auth-param starting at {@code i} (which must not point at a separator).
105    *
106    * <p>
107    * Returns a {@code null} key when {@code i} points at a bare token with no {@code =value} (e.g. {@code token68});
108    * the caller skips such a param.  {@code nextIndex()} always advances past the scanned key (and value, if any),
109    * so the outer loop makes forward progress without a {@code break}/{@code continue}.
110    */
111   private static ParsedParam parseParam(String s, int i, int n) {
112      var keyStart = i;
113      while (i < n && s.charAt(i) != '=' && s.charAt(i) != ',')
114         i++;
115      if (i >= n || s.charAt(i) == ',')
116         return new ParsedParam(null, null, i);
117      var key = s.substring(keyStart, i).strip().toLowerCase(Locale.ROOT);
118      i++; // consume '='
119      var value = parseValue(s, i, n);
120      return new ParsedParam(key, value.value(), value.nextIndex());
121   }
122
123   /** Parses a quoted-string or bare-token auth-param value starting at {@code i}. */
124   private static ParsedValue parseValue(String s, int i, int n) {
125      if (i < n && s.charAt(i) == '"')
126         return parseQuotedValue(s, i, n);
127      var valStart = i;
128      while (i < n && s.charAt(i) != ',')
129         i++;
130      return new ParsedValue(s.substring(valStart, i).strip(), i);
131   }
132
133   /** Parses a quoted-string value starting at the opening-quote index {@code i}, honoring {@code \}-escaped chars. */
134   private static ParsedValue parseQuotedValue(String s, int i, int n) {
135      i++; // consume opening quote
136      var sb = new StringBuilder();
137      while (i < n && s.charAt(i) != '"') {
138         if (s.charAt(i) == '\\' && i + 1 < n) // handle escaped chars inside quoted-string
139            i++;
140         sb.append(s.charAt(i));
141         i++;
142      }
143      if (i < n)
144         i++; // consume closing quote
145      return new ParsedValue(sb.toString(), i);
146   }
147
148   /** A parsed auth-param; {@code key} is {@code null} for a skipped bare token with no value. */
149   private record ParsedParam(String key, String value, int nextIndex) {}
150
151   /** A parsed auth-param value and the index immediately following it. */
152   private record ParsedValue(String value, int nextIndex) {}
153
154   /**
155    * Returns the auth scheme.
156    *
157    * @return The scheme (e.g. {@code "Bearer"}).  Never {@code null}.
158    */
159   public String scheme() {
160      return scheme;
161   }
162
163   /**
164    * Returns whether the scheme is {@code Bearer} (case-insensitive).
165    *
166    * @return <jk>true</jk> if this is a Bearer challenge.
167    */
168   public boolean isBearer() {
169      return eqic("bearer", scheme);
170   }
171
172   /**
173    * Returns the parsed auth-params (lower-cased keys).
174    *
175    * @return An unmodifiable map.  Never {@code null}.
176    */
177   public Map<String,String> parameters() {
178      return parameters;
179   }
180
181   /**
182    * Returns the raw value of an auth-param.
183    *
184    * @param name The param name (matched case-insensitively).  Must not be <jk>null</jk>.
185    * @return The value, or {@link Optional#empty()} if absent.
186    */
187   public Optional<String> parameter(String name) {
188      reqnn("name", name);
189      return o(parameters.get(name.toLowerCase(Locale.ROOT)));
190   }
191
192   /**
193    * Returns the {@code resource_metadata} pointer (RFC 9728 &sect;5.1) to the PRM document.
194    *
195    * @return The PRM document URI, or {@link Optional#empty()} if absent or unparseable as a URI.
196    */
197   public Optional<URI> resourceMetadata() {
198      var v = parameters.get("resource_metadata");
199      if (ib(v))
200         return oe();
201      try {
202         return o(URI.create(v));
203      } catch (IllegalArgumentException e) {
204         return oe();
205      }
206   }
207
208   /**
209    * Returns the {@code error} code (e.g. {@code "insufficient_scope"}), if present.
210    *
211    * @return The error code, or {@link Optional#empty()}.
212    */
213   public Optional<String> error() {
214      return o(parameters.get("error"));
215   }
216
217   /**
218    * Returns the space-delimited {@code scope} param as an order-preserving set.
219    *
220    * @return The challenged scopes (empty if the param is absent or blank).  Never {@code null}.
221    */
222   public Set<String> scopes() {
223      var v = parameters.get("scope");
224      if (ib(v))
225         return Set.of();
226      var out = Arrays.stream(v.strip().split("\\s+"))
227         .collect(Collectors.toCollection(LinkedHashSet<String>::new));
228      return u(out);
229   }
230}