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