Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,30 +27,42 @@
import io.micronaut.security.authentication.WwwAuthenticateChallengeProvider;
import jakarta.inject.Singleton;

import java.util.List;
import java.util.stream.Collectors;

/**
* Use of WWW-Authenticate for Protected Resource Metadata.
* <a href="https://datatracker.ietf.org/doc/html/rfc9728#WWW-Authenticate">RFC 9728 WWW Authenticate</a>
*/
@Requires(classes = { HttpRequest.class, HttpHostResolver.class })
@Requires(beans = { HttpHostResolver.class })
@Requires(beans = { HttpHostResolver.class, ProtectedResourceMetadataProvider.class })
@Requires(property = ProtectedResourceMetadataConfiguration.PROPERTY_WWW_AUTHENTICATE, notEquals = StringUtils.FALSE, defaultValue = StringUtils.TRUE)
@Internal
@Singleton
class ResourceMetadataWwwAuthenticateChallengeProvider implements WwwAuthenticateChallengeProvider<HttpRequest<?>> {
private static final String PARAM_RESOURCE_METADATA = "resource_metadata";
private static final String PARAM_SCOPE = "scope";
private static final String SLASH = "/";
private final HttpHostResolver httpHostResolver;
private final HttpHostResolver httpHostResolver;
private final ProtectedResourceMetadataProvider<HttpRequest<?>> protectedResourceMetadataProvider;

ResourceMetadataWwwAuthenticateChallengeProvider(HttpHostResolver httpHostResolver) {
ResourceMetadataWwwAuthenticateChallengeProvider(HttpHostResolver httpHostResolver,
ProtectedResourceMetadataProvider<HttpRequest<?>> protectedResourceMetadataProvider) {
this.httpHostResolver = httpHostResolver;
this.protectedResourceMetadataProvider = protectedResourceMetadataProvider;
}

@Override
@NonNull
public String getWwwAuthenticateChallenge(@Nullable HttpRequest<?> request) {
return WwwAuthenticateChallenge.builder()
WwwAuthenticateChallenge.Builder builder = WwwAuthenticateChallenge.builder()
.authScheme(HttpHeaderValues.AUTHORIZATION_PREFIX_BEARER)
.param(PARAM_RESOURCE_METADATA, resourceMetadata(request))
.param(PARAM_RESOURCE_METADATA, resourceMetadata(request));
String scope = scope(request);
if (StringUtils.isNotEmpty(scope)) {
builder.param(PARAM_SCOPE, scope);
}
return builder
.build()
.toString();
}
Expand All @@ -60,9 +72,38 @@ private String resourceMetadata(@Nullable HttpRequest<?> request) {
StringBuilder sb = new StringBuilder();
sb.append(httpHostResolver.resolve(request));
sb.append(ProtectedResourceMetadataConfiguration.PATH);
if (request != null && StringUtils.isNotEmpty(request.getPath()) && !request.getPath().equals(SLASH)) {
sb.append(request.getPath());
String path = resourcePath(request);
if (path != null) {
sb.append(path);
}
return sb.toString();
}

@Nullable
private String resourcePath(@Nullable HttpRequest<?> request) {
if (request == null) {
return null;
}
String path = request.getPath();
return StringUtils.isNotEmpty(path) && !SLASH.equals(path) ? path : null;
}

@NonNull
private String scope(@Nullable HttpRequest<?> request) {
if (request == null) {
return "";
}
String path = resourcePath(request);
ProtectedResourceMetadata metadata = path == null
? protectedResourceMetadataProvider.get(request)
: protectedResourceMetadataProvider.get(path, request);
List<String> scopes = metadata.scopesSupported();
if (scopes == null || scopes.isEmpty()) {
return "";
}
return scopes.stream()
.filter(scope -> scope != null && StringUtils.isNotEmpty(scope.trim()))
.map(String::trim)
.collect(Collectors.joining(" "));
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.micronaut.security.oauth2.metadata;

import io.micronaut.context.ApplicationContext;
import io.micronaut.context.annotation.Replaces;
import io.micronaut.context.annotation.Requires;
import io.micronaut.http.HttpRequest;
import io.micronaut.http.HttpStatus;
Expand All @@ -13,16 +14,24 @@
import io.micronaut.http.client.BlockingHttpClient;
import io.micronaut.http.client.HttpClient;
import io.micronaut.http.client.exceptions.HttpClientResponseException;
import io.micronaut.http.server.util.HttpHostResolver;
import io.micronaut.runtime.server.EmbeddedServer;
import jakarta.inject.Singleton;
import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;

import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

Expand All @@ -39,8 +48,48 @@ static Stream<Object[]> wwwAuthenticateCases() {
@ParameterizedTest
@MethodSource("wwwAuthenticateCases")
void wwwAuthenticateResourceMetadata(String method, String path, List<String> allowedSuffixes) {
Challenge challenge = challenge("ResourceMetadataWwwAuthenticateChallengeProviderTest", method, path);

Set<String> allowed = allowedSuffixes.stream()
.map(suffix -> String.format("Bearer resource_metadata=\"%s/.well-known/oauth-protected-resource%s\"", challenge.baseUrl(), suffix))
.collect(Collectors.toSet());

assertTrue(allowed.contains(challenge.header()), () -> "Unexpected WWW-Authenticate: " + challenge.header() + ", allowed: " + allowed);
}

@Test
void wwwAuthenticateScopeFromPathSpecificMetadata() {
Challenge challenge = challenge("ResourceMetadataWwwAuthenticateChallengeProviderScopeTest", "GET", "/foobar");

assertEquals(
String.format("Bearer resource_metadata=\"%s/.well-known/oauth-protected-resource/foobar\", scope=\"foo bar\"", challenge.baseUrl()),
challenge.header()
);
}

static Stream<String> noScopeCases() {
return Stream.of(
"ResourceMetadataWwwAuthenticateChallengeProviderNullScopeTest",
"ResourceMetadataWwwAuthenticateChallengeProviderEmptyScopeTest",
"ResourceMetadataWwwAuthenticateChallengeProviderBlankScopeTest"
);
}

@ParameterizedTest
@MethodSource("noScopeCases")
void wwwAuthenticateOmitsUnavailableScopes(String specName) {
Challenge challenge = challenge(specName, "GET", "/foobar");

assertEquals(
String.format("Bearer resource_metadata=\"%s/.well-known/oauth-protected-resource/foobar\"", challenge.baseUrl()),
challenge.header()
);
assertFalse(challenge.header().contains("scope="));
}

private Challenge challenge(String specName, String method, String path) {
Map<String, Object> configuration = Map.of(
"spec.name", "ResourceMetadataWwwAuthenticateChallengeProviderTest");
"spec.name", specName);
EmbeddedServer server = ApplicationContext.run(EmbeddedServer.class, configuration);
HttpClient httpClient = server.getApplicationContext().createBean(HttpClient.class, server.getURL());
BlockingHttpClient client = httpClient.toBlocking();
Expand All @@ -52,21 +101,18 @@ void wwwAuthenticateResourceMetadata(String method, String path, List<String> al
};
HttpClientResponseException ex = assertThrows(HttpClientResponseException.class, () -> client.exchange(request));
String header = ex.getResponse().getHeaders().get("WWW-Authenticate");

String base = server.getURL().toString();
Set<String> allowed = allowedSuffixes.stream()
.map(suffix -> String.format("Bearer resource_metadata=\"%s/.well-known/oauth-protected-resource%s\"", base, suffix))
.collect(java.util.stream.Collectors.toSet());

assertTrue(allowed.contains(header), () -> "Unexpected WWW-Authenticate: " + header + ", allowed: " + allowed);
return new Challenge(server.getURL().toString(), header);
} finally {
client.close();
httpClient.close();
server.close();
}
}

@Requires(property = "spec.name", value = "ResourceMetadataWwwAuthenticateChallengeProviderTest")
private record Challenge(String baseUrl, String header) {
}

@Requires(property = "spec.name", pattern = "ResourceMetadataWwwAuthenticateChallengeProvider.*")
@Controller("/foobar")
static class FooBarController {
@Produces(MediaType.TEXT_PLAIN)
Expand All @@ -81,4 +127,90 @@ void save() {
}
}

static class AbstractProtectedResourceMetadataProviderMock implements ProtectedResourceMetadataProvider<HttpRequest<?>> {
private final HttpHostResolver httpHostResolver;

AbstractProtectedResourceMetadataProviderMock(HttpHostResolver httpHostResolver) {
this.httpHostResolver = httpHostResolver;
}

@Override
@NonNull
public ProtectedResourceMetadata get(@NonNull HttpRequest<?> request) {
return metadata(null, request, null);
}

@NonNull
protected ProtectedResourceMetadata metadata(@Nullable String path,
@NonNull HttpRequest<?> request,
@Nullable List<String> scopesSupported) {
String resource = httpHostResolver.resolve(request) + (path == null ? "" : path);
return ProtectedResourceMetadata.builder()
.resource(resource)
.scopesSupported(scopesSupported)
.build();
}
}

@Requires(property = "spec.name", value = "ResourceMetadataWwwAuthenticateChallengeProviderScopeTest")
@Replaces(ProtectedResourceMetadataProvider.class)
@Singleton
static class ScopeProtectedResourceMetadataProviderMock extends AbstractProtectedResourceMetadataProviderMock {
ScopeProtectedResourceMetadataProviderMock(HttpHostResolver httpHostResolver) {
super(httpHostResolver);
}

@Override
@NonNull
public ProtectedResourceMetadata get(@NonNull String path, @NonNull HttpRequest<?> request) {
assertEquals("/foobar", path);
return metadata(path, request, List.of(" foo ", "bar"));
}
}

@Requires(property = "spec.name", value = "ResourceMetadataWwwAuthenticateChallengeProviderNullScopeTest")
@Replaces(ProtectedResourceMetadataProvider.class)
@Singleton
static class NullScopeProtectedResourceMetadataProviderMock extends AbstractProtectedResourceMetadataProviderMock {
NullScopeProtectedResourceMetadataProviderMock(HttpHostResolver httpHostResolver) {
super(httpHostResolver);
}

@Override
@NonNull
public ProtectedResourceMetadata get(@NonNull String path, @NonNull HttpRequest<?> request) {
return metadata(path, request, null);
}
}

@Requires(property = "spec.name", value = "ResourceMetadataWwwAuthenticateChallengeProviderEmptyScopeTest")
@Replaces(ProtectedResourceMetadataProvider.class)
@Singleton
static class EmptyScopeProtectedResourceMetadataProviderMock extends AbstractProtectedResourceMetadataProviderMock {
EmptyScopeProtectedResourceMetadataProviderMock(HttpHostResolver httpHostResolver) {
super(httpHostResolver);
}

@Override
@NonNull
public ProtectedResourceMetadata get(@NonNull String path, @NonNull HttpRequest<?> request) {
return metadata(path, request, List.of());
}
}

@Requires(property = "spec.name", value = "ResourceMetadataWwwAuthenticateChallengeProviderBlankScopeTest")
@Replaces(ProtectedResourceMetadataProvider.class)
@Singleton
static class BlankScopeProtectedResourceMetadataProviderMock extends AbstractProtectedResourceMetadataProviderMock {
BlankScopeProtectedResourceMetadataProviderMock(HttpHostResolver httpHostResolver) {
super(httpHostResolver);
}

@Override
@NonNull
public ProtectedResourceMetadata get(@NonNull String path, @NonNull HttpRequest<?> request) {
return metadata(path, request, List.of(" ", "\t", ""));
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ A GET endpoint is exposed in `/.well-known/oauth-protected-resource`. The API to
api:security.oauth2.metadata.ProtectedResourceMetadataProvider[]

You can create a bean replacement for it if the default implementation does not fit your needs.
The same provider is used for the `WWW-Authenticate` Bearer challenge: if it returns non-empty `scopes_supported` values for the current request or path-specific metadata, those scopes are emitted as the challenge `scope` parameter.
3 changes: 2 additions & 1 deletion src/main/docs/guide/rejection/wwwAuthenticate.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ If any bean of type api:security.authentication.WwwAuthenticateChallengeProvider

If you have <<basicAuth, basic auth> enabled, an entry for "Basic" authentication scheme is added by default. You can disable it setting `micronaut.security.basic-auth.www-authenticate` to `false`.

If you have <<protectedResourceMetadata, Resource Metadata> enabled, an entry as defined in the https://datatracker.ietf.org/doc/html/rfc9728#name-www-authenticate-response[WWW-Authenticate Response] section of RFC9728 is added
If you have <<protectedResourceMetadata, Resource Metadata> enabled, an entry as defined in the https://datatracker.ietf.org/doc/html/rfc9728#name-www-authenticate-response[WWW-Authenticate Response] section of RFC9728 is added.
When the api:security.oauth2.metadata.ProtectedResourceMetadataProvider[] returns non-empty `scopes_supported` values for the current request, Micronaut Security also includes those values in the Bearer challenge `scope` parameter.
Loading