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}