001/* 002 * Copyright 2022-2026 Revetware LLC. 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package com.soklet; 018 019import org.jspecify.annotations.NonNull; 020import org.jspecify.annotations.Nullable; 021 022import javax.annotation.concurrent.NotThreadSafe; 023import javax.annotation.concurrent.ThreadSafe; 024import java.net.URI; 025import java.util.ArrayList; 026import java.util.LinkedHashSet; 027import java.util.List; 028import java.util.Objects; 029import java.util.Optional; 030 031import static java.util.Objects.requireNonNull; 032 033/** 034 * An immutable RFC 6750 Bearer challenge with an RFC 9728 protected-resource 035 * metadata URL. {@link #getHeaderValue()} renders one {@code WWW-Authenticate} 036 * field value, without the field name. Constructing a challenge validates its 037 * syntax; the application remains responsible for publishing the metadata and 038 * choosing scopes its authorization server can grant. 039 * 040 * @author <a href="https://www.revetkn.com">Mark Allen</a> 041 */ 042@ThreadSafe 043public final class BearerAuthenticationChallenge { 044 @NonNull 045 private final URI resourceMetadataUri; 046 @Nullable 047 private final BearerAuthenticationError error; 048 @NonNull 049 private final List<@NonNull String> requiredScopes; 050 @Nullable 051 private final String realm; 052 @Nullable 053 private final String errorDescription; 054 @Nullable 055 private final URI errorUri; 056 @NonNull 057 private final String headerValue; 058 059 /** @return a builder with the configured protected-resource metadata URL */ 060 @NonNull 061 public static Builder withResourceMetadataUri( 062 @NonNull URI resourceMetadataUri) { 063 return new Builder(resourceMetadataUri); 064 } 065 066 private BearerAuthenticationChallenge(@NonNull Builder builder) { 067 requireNonNull(builder); 068 this.resourceMetadataUri = requireMetadataUri(builder.resourceMetadataUri); 069 this.error = builder.error; 070 this.requiredScopes = validatedScopes(builder.requiredScopes); 071 this.realm = builder.realm == null ? null 072 : requireQuotedRealm(builder.realm); 073 this.errorDescription = builder.errorDescription == null ? null 074 : requireBearerText(builder.errorDescription, "errorDescription", true); 075 this.errorUri = builder.errorUri == null ? null 076 : requireErrorUri(builder.errorUri); 077 if (this.error == BearerAuthenticationError.INSUFFICIENT_SCOPE 078 && this.requiredScopes.isEmpty()) 079 throw new IllegalArgumentException( 080 "An insufficient-scope challenge requires at least one scope."); 081 if (this.error == null 082 && (this.errorDescription != null || this.errorUri != null)) 083 throw new IllegalArgumentException( 084 "Error details require a Bearer authentication error."); 085 this.headerValue = render(); 086 } 087 088 /** @return the configured absolute protected-resource metadata URI */ 089 @NonNull 090 public URI getResourceMetadataUri() { 091 return this.resourceMetadataUri; 092 } 093 094 /** @return the RFC 6750 Bearer error, when supplied */ 095 @NonNull 096 public Optional<@NonNull BearerAuthenticationError> getError() { 097 return Optional.ofNullable(this.error); 098 } 099 100 /** @return immutable scope tokens in first-encounter order */ 101 @NonNull 102 public List<@NonNull String> getRequiredScopes() { 103 return this.requiredScopes; 104 } 105 106 /** @return the protection realm, when supplied */ 107 @NonNull 108 public Optional<@NonNull String> getRealm() { 109 return Optional.ofNullable(this.realm); 110 } 111 112 /** @return the developer-facing error description, when supplied */ 113 @NonNull 114 public Optional<@NonNull String> getErrorDescription() { 115 return Optional.ofNullable(this.errorDescription); 116 } 117 118 /** @return the error explanation URI, when supplied */ 119 @NonNull 120 public Optional<@NonNull URI> getErrorUri() { 121 return Optional.ofNullable(this.errorUri); 122 } 123 124 /** 125 * Returns the RFC-recommended HTTP status for this challenge: 400 for an 126 * invalid request, 403 for insufficient scope, and 401 otherwise. 127 * 128 * @return recommended HTTP status 129 */ 130 @NonNull 131 public Integer getRecommendedStatusCode() { 132 if (this.error == BearerAuthenticationError.INVALID_REQUEST) 133 return 400; 134 if (this.error == BearerAuthenticationError.INSUFFICIENT_SCOPE) 135 return 403; 136 return 401; 137 } 138 139 /** @return the complete {@code WWW-Authenticate} field value */ 140 @NonNull 141 public String getHeaderValue() { 142 return this.headerValue; 143 } 144 145 private @NonNull String render() { 146 List<String> parameters = new ArrayList<>(); 147 if (this.realm != null) 148 parameters.add("realm=" + quoted(this.realm)); 149 if (this.error != null) 150 parameters.add("error=" + quoted(switch (this.error) { 151 case INVALID_REQUEST -> "invalid_request"; 152 case INVALID_TOKEN -> "invalid_token"; 153 case INSUFFICIENT_SCOPE -> "insufficient_scope"; 154 })); 155 if (!this.requiredScopes.isEmpty()) 156 parameters.add("scope=" + quoted(String.join(" ", this.requiredScopes))); 157 parameters.add("resource_metadata=" 158 + quoted(this.resourceMetadataUri.toASCIIString())); 159 if (this.errorDescription != null) 160 parameters.add("error_description=" + quoted(this.errorDescription)); 161 if (this.errorUri != null) 162 parameters.add("error_uri=" + quoted(this.errorUri.toASCIIString())); 163 return "Bearer " + String.join(", ", parameters); 164 } 165 166 private static @NonNull String quoted(@NonNull String value) { 167 return "\"" + value.replace("\\", "\\\\") 168 .replace("\"", "\\\"") + "\""; 169 } 170 171 private static @NonNull URI requireMetadataUri(@NonNull URI uri) { 172 requireNonNull(uri); 173 if (!uri.isAbsolute() || uri.getHost() == null 174 || !("https".equalsIgnoreCase(uri.getScheme()) 175 || "http".equalsIgnoreCase(uri.getScheme())) 176 || uri.getRawFragment() != null || uri.getRawUserInfo() != null) 177 throw new IllegalArgumentException( 178 "resourceMetadataUri must be an absolute HTTP(S) URL without a fragment or user information."); 179 return uri; 180 } 181 182 private static @NonNull URI requireErrorUri(@NonNull URI uri) { 183 requireNonNull(uri); 184 if (!uri.isAbsolute()) 185 throw new IllegalArgumentException( 186 "errorUri must be an absolute URI."); 187 requireBearerText(uri.toASCIIString(), "errorUri", false); 188 return uri; 189 } 190 191 private static @NonNull String requireQuotedRealm(@NonNull String realm) { 192 requireNonNull(realm); 193 for (int index = 0; index < realm.length(); index++) { 194 char character = realm.charAt(index); 195 if (character < 0x20 || character > 0x7e) 196 throw new IllegalArgumentException( 197 "realm must contain printable ASCII characters."); 198 } 199 return realm; 200 } 201 202 private static @NonNull String requireBearerText(@NonNull String value, 203 @NonNull String field, boolean allowSpace) { 204 requireNonNull(value); 205 for (int index = 0; index < value.length(); index++) { 206 char character = value.charAt(index); 207 if ((character == 0x20 && allowSpace) 208 || character == 0x21 209 || (character >= 0x23 && character <= 0x5b) 210 || (character >= 0x5d && character <= 0x7e)) 211 continue; 212 throw new IllegalArgumentException( 213 field + " contains a character forbidden by RFC 6750."); 214 } 215 return value; 216 } 217 218 private static @NonNull List<@NonNull String> validatedScopes( 219 @NonNull List<@NonNull String> requiredScopes) { 220 requireNonNull(requiredScopes); 221 LinkedHashSet<String> distinct = new LinkedHashSet<>(); 222 for (String requiredScope : requiredScopes) { 223 String scope = requireNonNull(requiredScope); 224 if (scope.isEmpty()) 225 throw new IllegalArgumentException("A required scope must not be empty."); 226 requireBearerText(scope, "requiredScope", false); 227 distinct.add(scope); 228 } 229 return List.copyOf(distinct); 230 } 231 232 /** 233 * Compares the validated challenge fields. 234 * 235 * @return whether both challenges contain the same fields 236 */ 237 @Override 238 public boolean equals(@Nullable Object other) { 239 if (this == other) 240 return true; 241 if (!(other instanceof BearerAuthenticationChallenge challenge)) 242 return false; 243 return this.resourceMetadataUri.equals(challenge.resourceMetadataUri) 244 && Objects.equals(this.error, challenge.error) 245 && this.requiredScopes.equals(challenge.requiredScopes) 246 && Objects.equals(this.realm, challenge.realm) 247 && Objects.equals(this.errorDescription, challenge.errorDescription) 248 && Objects.equals(this.errorUri, challenge.errorUri); 249 } 250 251 /** @return a hash code for the validated challenge fields */ 252 @Override 253 public int hashCode() { 254 return Objects.hash(this.resourceMetadataUri, this.error, 255 this.requiredScopes, this.realm, this.errorDescription, this.errorUri); 256 } 257 258 /** @return a concise description that omits scope and URI details */ 259 @Override 260 @NonNull 261 public String toString() { 262 return "BearerAuthenticationChallenge[error=" + this.error 263 + ", requiredScopeCount=" + this.requiredScopes.size() + "]"; 264 } 265 266 /** 267 * Mutable builder for one immutable Bearer challenge. 268 * 269 * @author <a href="https://www.revetkn.com">Mark Allen</a> 270 */ 271 @NotThreadSafe 272 public static final class Builder { 273 @NonNull 274 private final URI resourceMetadataUri; 275 @Nullable 276 private BearerAuthenticationError error; 277 @NonNull 278 private List<@NonNull String> requiredScopes = List.of(); 279 @Nullable 280 private String realm; 281 @Nullable 282 private String errorDescription; 283 @Nullable 284 private URI errorUri; 285 286 private Builder(@NonNull URI resourceMetadataUri) { 287 this.resourceMetadataUri = requireMetadataUri(resourceMetadataUri); 288 } 289 290 /** @return this builder */ 291 @NonNull 292 public Builder error(@NonNull BearerAuthenticationError error) { 293 this.error = requireNonNull(error); 294 return this; 295 } 296 297 /** @return this builder */ 298 @NonNull 299 public Builder requiredScopes( 300 @NonNull List<@NonNull String> requiredScopes) { 301 this.requiredScopes = validatedScopes(requiredScopes); 302 return this; 303 } 304 305 /** @return this builder */ 306 @NonNull 307 public Builder realm(@NonNull String realm) { 308 this.realm = requireQuotedRealm(realm); 309 return this; 310 } 311 312 /** @return this builder */ 313 @NonNull 314 public Builder errorDescription(@NonNull String errorDescription) { 315 this.errorDescription = requireBearerText(errorDescription, 316 "errorDescription", true); 317 return this; 318 } 319 320 /** @return this builder */ 321 @NonNull 322 public Builder errorUri(@NonNull URI errorUri) { 323 this.errorUri = requireErrorUri(errorUri); 324 return this; 325 } 326 327 /** @return one immutable, validated challenge */ 328 @NonNull 329 public BearerAuthenticationChallenge build() { 330 return new BearerAuthenticationChallenge(this); 331 } 332 } 333}