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.net.*; 021import java.util.*; 022import static org.apache.juneau.commons.utils.Shorts.*; 023 024/** 025 * Fluent, insertion-ordered builder for a {@code WWW-Authenticate: Bearer ...} challenge value (RFC 6750 §3, RFC 026 * 9728 §5.1) emitted by the {@code 2026-07-28} MCP resource server on a {@code 401}. 027 * 028 * <p> 029 * Produces {@code Bearer <k>="<v>", ...} auth-params in the order added, so the emitted header parses cleanly on the 030 * client side via {@code org.apache.juneau.rest.client.mcp.auth.WwwAuthenticateChallenge} (which lower-cases keys and 031 * accepts quoted values). Auth-param values are sanitized for the RFC 7235 quoted-string grammar: control characters 032 * ({@code < 0x20} and {@code 0x7F}, including CR/LF that would otherwise enable header injection) are stripped, and 033 * {@code "}/{@code \} are backslash-escaped ({@code \"} / {@code \\}) rather than mangled. 034 * 035 * <h5 class='section'>Extensibility:</h5> 036 * <p> 037 * The baseline emits {@link #realm(String) realm}, {@link #error(String) error}, 038 * {@link #errorDescription(String) error_description}, {@link #scope(Collection) scope}, and 039 * {@link #resourceMetadata(URI) resource_metadata}. The generic {@link #param(String, String)} seam lets a 040 * later layer (SEP-2350 scoped step-up) add further auth-params without touching this class — this class 041 * deliberately does not implement scope-accumulation/step-up. 042 * 043 * @since 10.0.0 044 */ 045public final class McpBearerChallenge { 046 047 private final Map<String,String> params = new LinkedHashMap<>(); 048 049 /** 050 * Static creator. 051 * 052 * @return A new, empty challenge builder. 053 */ 054 public static McpBearerChallenge create() { 055 return new McpBearerChallenge(); 056 } 057 058 private McpBearerChallenge() {} 059 060 /** 061 * Adds the {@code realm} auth-param. 062 * 063 * @param value The realm. A <jk>null</jk> value is ignored. 064 * @return This object (for method chaining). 065 */ 066 public McpBearerChallenge realm(String value) { 067 return param("realm", value); 068 } 069 070 /** 071 * Adds the {@code error} auth-param (e.g. {@code invalid_token}, {@code insufficient_scope}). 072 * 073 * @param value The error code. A <jk>null</jk> value is ignored. 074 * @return This object (for method chaining). 075 */ 076 public McpBearerChallenge error(String value) { 077 return param("error", value); 078 } 079 080 /** 081 * Adds the {@code error_description} auth-param. 082 * 083 * @param value The human-readable error description. A <jk>null</jk> value is ignored. 084 * @return This object (for method chaining). 085 */ 086 public McpBearerChallenge errorDescription(String value) { 087 return param("error_description", value); 088 } 089 090 /** 091 * Adds the space-delimited {@code scope} auth-param. 092 * 093 * @param scopes The scopes. A <jk>null</jk> or empty collection is ignored. 094 * @return This object (for method chaining). 095 */ 096 public McpBearerChallenge scope(Collection<String> scopes) { 097 if (ie(scopes)) 098 return this; 099 return param("scope", String.join(" ", scopes)); 100 } 101 102 /** 103 * Adds the {@code resource_metadata} auth-param (RFC 9728 §5.1) pointing at the PRM document. 104 * 105 * @param value The PRM document URI. A <jk>null</jk> value is ignored. 106 * @return This object (for method chaining). 107 */ 108 public McpBearerChallenge resourceMetadata(URI value) { 109 return param("resource_metadata", value == null ? null : value.toString()); 110 } 111 112 /** 113 * Adds an arbitrary auth-param — the extensibility seam for future challenge parameters. 114 * 115 * @param name The auth-param name. Must not be <jk>null</jk> or blank. 116 * @param value The auth-param value. A <jk>null</jk> value is ignored (the param is not added). 117 * @return This object (for method chaining). 118 */ 119 public McpBearerChallenge param(String name, String value) { 120 reqnb("name", name); 121 if (value != null) 122 params.put(name, sanitize(value)); 123 return this; 124 } 125 126 /** 127 * Builds the {@code WWW-Authenticate} challenge value. 128 * 129 * @return The header value (e.g. {@code Bearer realm="mcp", resource_metadata="https://host/.well-known/..."}), or 130 * the bare {@code Bearer} scheme when no auth-params were added. 131 */ 132 public String build() { 133 if (params.isEmpty()) 134 return "Bearer"; 135 var sb = new StringBuilder("Bearer "); 136 var first = true; 137 for (var e : params.entrySet()) { 138 if (!first) 139 sb.append(", "); 140 sb.append(e.getKey()).append("=\"").append(e.getValue()).append('"'); 141 first = false; 142 } 143 return sb.toString(); 144 } 145 146 @Override /* Overridden from Object */ 147 public String toString() { 148 return build(); 149 } 150 151 private static String sanitize(String v) { 152 var sb = new StringBuilder(v.length()); 153 for (var i = 0; i < v.length(); i++) { 154 var c = v.charAt(i); 155 if (c < 0x20 || c == 0x7F) // Strip all CTLs (incl. CR/LF) that would corrupt the response header. 156 continue; 157 if (c == '"' || c == '\\') // RFC 7235 quoted-string: escape rather than mangle. 158 sb.append('\\'); 159 sb.append(c); 160 } 161 return sb.toString(); 162 } 163}