implement OpenAPI contributor integration for rate limiting and response headers

This commit is contained in:
Relism
2026-04-19 23:42:50 +02:00
parent e161497f2c
commit 34fd74068a
26 changed files with 703 additions and 331 deletions
@@ -263,62 +263,64 @@ public class OidcExtension implements FlashExtension {
/**
* Loaded lazily so that {@code flash-ext-openapi} classes are only resolved at
* runtime when {@link dev.relism.ext.openapi.OpenApiSecurityRegistry} is actually on the classpath.
* runtime when {@link dev.relism.ext.openapi.OpenApiContributorRegistry} is actually on the classpath.
*/
private static final class OpenApiIntegration {
static void register(dev.relism.extension.FlashContext ctx,
OidcConfig config, OidcProviderMetadata meta) {
ctx.find(dev.relism.ext.openapi.OpenApiSecurityRegistry.class)
.ifPresent(registry -> registry.add(new dev.relism.ext.openapi.OpenApiSecurityContributor() {
ctx.find(dev.relism.ext.openapi.OpenApiContributorRegistry.class)
.ifPresent(registry -> registry.add(new dev.relism.ext.openapi.OpenApiContributor() {
@Override
public String schemeName() { return config.schemeName(); }
@Override
public java.util.Map<String, Object> schemeDefinition() {
java.util.Map<String, String> scopesMap = new java.util.LinkedHashMap<>();
for (String s : config.scopes().split("\\s+")) {
if (!s.isBlank()) scopesMap.put(s, s);
@Override
public java.util.Map<String, Object> componentContributions() {
java.util.Map<String, String> scopesMap = new java.util.LinkedHashMap<>();
for (String s : config.scopes().split("\\s+")) {
if (!s.isBlank()) scopesMap.put(s, s);
}
java.util.Map<String, Object> flow = new java.util.LinkedHashMap<>();
flow.put("authorizationUrl", meta.authorizationEndpoint());
flow.put("tokenUrl", meta.tokenEndpoint());
flow.put("scopes", scopesMap);
flow.put("scopes", scopesMap);
java.util.Map<String, Object> scheme = new java.util.LinkedHashMap<>();
scheme.put("type", "oauth2");
scheme.put("flows", java.util.Map.of("authorizationCode", flow));
return scheme;
}
java.util.Map<String, Object> scheme = new java.util.LinkedHashMap<>();
scheme.put("type", "oauth2");
scheme.put("flows", java.util.Map.of("authorizationCode", flow));
@Override
public java.util.List<String> requiredFor(Class<?> handlerClass) {
return OidcAuthPolicy.openApiScopesFor(handlerClass);
java.util.Map<String, Object> securitySchemes = new java.util.LinkedHashMap<>();
securitySchemes.put(config.schemeName(), scheme);
return java.util.Map.of("securitySchemes", securitySchemes);
}
@Override
public java.util.Map<Integer, String> autoResponsesFor(Class<?> handlerClass) {
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(handlerClass);
if (policy == null || policy.optionalAuth()) return java.util.Map.of();
@Override
public dev.relism.ext.openapi.OpenApiOperationContribution operationFor(Class<?> handlerClass) {
dev.relism.ext.openapi.OpenApiOperationContribution.Builder out =
dev.relism.ext.openapi.OpenApiOperationContribution.builder();
java.util.LinkedHashMap<Integer, String> out = new java.util.LinkedHashMap<>();
out.put(401, "Authentication required");
java.util.List<String> operationScopes = OidcAuthPolicy.openApiScopesFor(handlerClass);
if (operationScopes != null) {
out.security(config.schemeName(), operationScopes);
}
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(handlerClass);
if (policy == null || policy.optionalAuth()) return out.build();
out.response(401, dev.relism.ext.openapi.OpenApiResponseContribution.of("Authentication required"));
String[] roles = policy.requiredRoles();
String[] scopes = policy.requiredScopes();
if (roles.length == 0 && scopes.length == 0) return out;
if (roles.length == 0 && scopes.length == 0) return out.build();
String roleMessage = roles.length == 0 ? null : roleRequiredMessage(roles);
String scopeMessage = scopes.length == 0 ? null : scopeRequiredMessage(scopes);
if (roleMessage != null && scopeMessage != null) {
out.put(403, roleMessage + "; " + scopeMessage);
out.response(403, dev.relism.ext.openapi.OpenApiResponseContribution.of(roleMessage + "; " + scopeMessage));
} else if (roleMessage != null) {
out.put(403, roleMessage);
out.response(403, dev.relism.ext.openapi.OpenApiResponseContribution.of(roleMessage));
} else {
out.put(403, scopeMessage);
out.response(403, dev.relism.ext.openapi.OpenApiResponseContribution.of(scopeMessage));
}
return out;
}
return out.build();
}
}));
}
@@ -1,10 +1,11 @@
package dev.relism.ext.oidc;
import dev.relism.ext.openapi.OpenApiSecurityContributor;
import dev.relism.ext.openapi.OpenApiContributor;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -37,49 +38,57 @@ class OidcOpenApiInteropTest {
@Test
void autoResponses_authOnly() throws Exception {
Map<Integer, String> responses = contributor().autoResponsesFor(AuthOnly.class);
Map<Integer, String> responses = responses(AuthOnly.class);
assertEquals("Authentication required", responses.get(401));
assertFalse(responses.containsKey(403));
}
@Test
void autoResponses_optionalAuth_addsNothing() throws Exception {
Map<Integer, String> responses = contributor().autoResponsesFor(AuthOptional.class);
Map<Integer, String> responses = responses(AuthOptional.class);
assertTrue(responses.isEmpty());
}
@Test
void autoResponses_oneRole_formatsSingular() throws Exception {
Map<Integer, String> responses = contributor().autoResponsesFor(OneRole.class);
Map<Integer, String> responses = responses(OneRole.class);
assertEquals("Authentication required", responses.get(401));
assertEquals("\"admin\" role required", responses.get(403));
}
@Test
void autoResponses_multiRoles_formatsPlural() throws Exception {
Map<Integer, String> responses = contributor().autoResponsesFor(MultiRole.class);
Map<Integer, String> responses = responses(MultiRole.class);
assertEquals("Roles \"admin, operator\" are required", responses.get(403));
}
@Test
void autoResponses_oneScope_formatsSingular() throws Exception {
Map<Integer, String> responses = contributor().autoResponsesFor(OneScope.class);
Map<Integer, String> responses = responses(OneScope.class);
assertEquals("\"orders:write\" scope required", responses.get(403));
}
@Test
void autoResponses_multiScopes_formatsPlural() throws Exception {
Map<Integer, String> responses = contributor().autoResponsesFor(MultiScope.class);
Map<Integer, String> responses = responses(MultiScope.class);
assertEquals("Scopes \"orders:write, payments:write\" are required", responses.get(403));
}
@Test
void autoResponses_roleAndScope_combinesMessages() throws Exception {
Map<Integer, String> responses = contributor().autoResponsesFor(RoleAndScope.class);
Map<Integer, String> responses = responses(RoleAndScope.class);
assertEquals("\"admin\" role required; \"orders:write\" scope required", responses.get(403));
}
private static OpenApiSecurityContributor contributor() throws Exception {
@Test
void securityContribution_presentForAuthenticatedHandler() throws Exception {
dev.relism.ext.openapi.OpenApiOperationContribution operation = contributor().operationFor(AuthOnly.class);
List<Map<String, List<String>>> security = operation.security();
assertEquals(1, security.size());
assertTrue(security.getFirst().containsKey("issuer"));
}
private static OpenApiContributor contributor() throws Exception {
Class<?> clazz = Class.forName("dev.relism.ext.oidc.OidcExtension$OpenApiIntegration");
Constructor<?> ctor = clazz.getDeclaredConstructor();
ctor.setAccessible(true);
@@ -89,8 +98,8 @@ class OidcOpenApiInteropTest {
m.setAccessible(true);
dev.relism.extension.FlashContext ctx = new dev.relism.extension.FlashContext();
dev.relism.ext.openapi.OpenApiSecurityRegistry registry = new dev.relism.ext.openapi.OpenApiSecurityRegistry();
ctx.provide(dev.relism.ext.openapi.OpenApiSecurityRegistry.class, registry);
dev.relism.ext.openapi.OpenApiContributorRegistry registry = new dev.relism.ext.openapi.OpenApiContributorRegistry();
ctx.provide(dev.relism.ext.openapi.OpenApiContributorRegistry.class, registry);
OidcConfig config = OidcConfig.builder("https://issuer", "c", "s", "/cb").build();
OidcProviderMetadata meta = new OidcProviderMetadata("a", "t", "u", "j", "e");
@@ -98,4 +107,13 @@ class OidcOpenApiInteropTest {
return registry.contributors().getFirst();
}
private static Map<Integer, String> responses(Class<?> cls) throws Exception {
Map<Integer, dev.relism.ext.openapi.OpenApiResponseContribution> byCode = contributor().operationFor(cls).responses();
java.util.LinkedHashMap<Integer, String> out = new java.util.LinkedHashMap<>();
for (Map.Entry<Integer, dev.relism.ext.openapi.OpenApiResponseContribution> e : byCode.entrySet()) {
out.put(e.getKey(), e.getValue().description());
}
return out;
}
}