refactor(ext-oidc): replace auth modules with security extensions

This commit is contained in:
Zakaria El Orche
2026-09-16 15:54:19 +00:00
parent 017c2443f4
commit e0795299fc
126 changed files with 2944 additions and 5130 deletions
@@ -0,0 +1,15 @@
# flash-ext-security-test
Test-scope utilities for applications on `flash-ext-security-core`.
```java
FlashTest app = FlashTest.of(flash -> flash.apply(new MyApp()).install(new TestSecurity()));
app.request().with(TestSecurity.as(() -> "alice")).get("/me"); // any principal
app.request().with(TestSecurity.as(new OidcPrincipal(...))).get("/projects"); // a mechanism's own type
```
`TestSecurity.as(principal)` hands the principal to the application by reference — `FlashTest`
serves it in the same JVM — so tests exercise the real `UserResolver`, `RoleResolver` and policies
with no identity provider running. Tests of the flows themselves use the mechanism's own kit:
`FakeOidcProvider` and `OidcTokens` in this module for OIDC, a real `POST` for form login.
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId>
<version>2.1.0-SNAPSHOT</version>
</parent>
<artifactId>flash-ext-security-test</artifactId>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-security-core</artifactId>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-testing</artifactId>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,150 @@
package dev.relism.flash.ext.security.test;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.crypto.RSASSASigner;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.gen.RSAKeyGenerator;
import com.nimbusds.jose.util.JSONObjectUtils;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import dev.relism.flash.testing.FlashRequest;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.net.URLEncoder;
import java.util.Set;
import java.util.Base64;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
/**
* An OpenID Provider on a random local port: discovery, keys, and a token endpoint for the
* authorization code and refresh flows. Sign-in is approved at once, as {@link #signInAs}.
*/
public final class FakeOidcProvider implements AutoCloseable {
private final HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
private final RSAKey key = new RSAKeyGenerator(2048).keyID(UUID.randomUUID().toString()).generate();
private final Map<String, String> nonces = new ConcurrentHashMap<>();
private final String issuer = "http://127.0.0.1:" + server.getAddress().getPort();
private volatile String subject = "alice";
private volatile Map<String, Object> claims = Map.of();
private volatile long expiresIn = 300;
/** Null accepts any client, which is what every test that is not about client registration wants. */
private volatile String clientSecret;
private volatile Set<String> redirectUris = Set.of();
public FakeOidcProvider() throws Exception {
server.createContext("/.well-known/openid-configuration", ex -> send(ex, 200, JSONObjectUtils.toJSONString(Map.of(
"issuer", issuer, "authorization_endpoint", issuer + "/authorize", "token_endpoint", issuer + "/token",
"jwks_uri", issuer + "/jwks", "end_session_endpoint", issuer + "/logout"))));
server.createContext("/jwks", ex -> send(ex, 200, new JWKSet(key.toPublicJWK()).toString()));
server.createContext("/authorize", ex -> {
Map<String, String> query = form(ex.getRequestURI().getRawQuery());
if (!redirectUris.isEmpty() && !redirectUris.contains(query.get("redirect_uri"))) {
send(ex, 400, "Unregistered redirect_uri");
return;
}
String code = UUID.randomUUID().toString();
nonces.put(code, query.get("nonce"));
ex.getResponseHeaders().add("Location", query.get("redirect_uri") + "?code=" + code + "&state=" + query.get("state"));
send(ex, 302, "");
});
server.createContext("/token", ex -> {
String basic = ex.getRequestHeaders().getFirst("Authorization");
if (clientSecret != null && (basic == null || !new String(Base64.getDecoder().decode(basic.substring(6)), StandardCharsets.UTF_8)
.endsWith(":" + URLEncoder.encode(clientSecret, StandardCharsets.UTF_8)))) {
send(ex, 401, "{\"error\":\"invalid_client\"}");
return;
}
Map<String, String> body = form(new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
String nonce = nonces.remove(body.getOrDefault("code", ""));
if (body.get("grant_type").equals("authorization_code") && nonce == null) {
send(ex, 400, "{\"error\":\"invalid_grant\"}");
return;
}
Map<String, Object> id = new HashMap<>(claims);
id.put("aud", "app");
if (nonce != null) id.put("nonce", nonce);
send(ex, 200, JSONObjectUtils.toJSONString(Map.of("access_token", token(subject, claims), "id_token", token(subject, id),
"refresh_token", UUID.randomUUID().toString(), "expires_in", expiresIn, "scope", "openid email")));
});
server.start();
}
/** Refuses, from now on, any other secret or redirect URI — as a provider with this client registered would. */
public FakeOidcProvider client(String secret, String... redirectUris) {
this.clientSecret = secret;
this.redirectUris = Set.of(redirectUris);
return this;
}
public String issuer() {
return issuer;
}
/** Who sign-in and refresh vouch for, with what extra claims; the client id is always {@code app}. */
public FakeOidcProvider signInAs(String subject, Map<String, Object> claims) {
this.subject = subject;
this.claims = claims;
return this;
}
/** Lifetime of the tokens sign-in and refresh issue — {@code 0} expires a session at once. */
public FakeOidcProvider expiresIn(long seconds) {
this.expiresIn = seconds;
return this;
}
/** A signed token for {@code subject}, valid five minutes, with {@code claims} added. */
public String token(String subject, Map<String, Object> claims) {
try {
JWTClaimsSet.Builder set = new JWTClaimsSet.Builder().issuer(issuer).subject(subject)
.issueTime(new Date()).expirationTime(new Date(System.currentTimeMillis() + 300_000));
claims.forEach(set::claim);
SignedJWT jwt = new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(key.getKeyID()).build(), set.build());
jwt.sign(new RSASSASigner(key));
return jwt.serialize();
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
/** Sends the request with a bearer {@link #token}. */
public Consumer<FlashRequest> bearer(String subject, Map<String, Object> claims) {
String header = "Bearer " + token(subject, claims);
return request -> request.header("Authorization", header);
}
@Override
public void close() {
server.stop(0);
}
private static Map<String, String> form(String encoded) {
Map<String, String> fields = new HashMap<>();
if (encoded != null) for (String pair : encoded.split("&")) {
int eq = pair.indexOf('=');
if (eq > 0) fields.put(pair.substring(0, eq), URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8));
}
return fields;
}
private static void send(HttpExchange exchange, int status, String body) throws IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(status, bytes.length == 0 ? -1 : bytes.length);
if (bytes.length > 0) try (var out = exchange.getResponseBody()) { out.write(bytes); }
exchange.close();
}
}
@@ -0,0 +1,47 @@
package dev.relism.flash.ext.security.test;
import com.nimbusds.jose.util.JSONObjectUtils;
import dev.relism.flash.testing.FlashRequest;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.function.Consumer;
/** Real tokens from a real provider — Keycloak in Testcontainers, typically. */
public final class OidcTokens {
private static final HttpClient HTTP = HttpClient.newHttpClient();
private OidcTokens() {}
/**
* Sends the request with an access token obtained by the resource owner password grant. The
* client must allow direct access grants — a test realm's setting, never a production one.
*/
public static Consumer<FlashRequest> passwordGrant(String issuer, String clientId, String clientSecret, String username, String password) {
try {
String discovery = issuer + (issuer.endsWith("/") ? "" : "/") + ".well-known/openid-configuration";
String tokenEndpoint = (String) JSONObjectUtils.parse(get(HttpRequest.newBuilder(URI.create(discovery)))).get("token_endpoint");
String form = "grant_type=password&scope=openid&client_id=" + encode(clientId) + "&client_secret=" + encode(clientSecret)
+ "&username=" + encode(username) + "&password=" + encode(password);
String header = "Bearer " + JSONObjectUtils.parse(get(HttpRequest.newBuilder(URI.create(tokenEndpoint))
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(form)))).get("access_token");
return request -> request.header("Authorization", header);
} catch (Exception e) {
throw new IllegalStateException("Could not obtain a token from " + issuer, e);
}
}
private static String get(HttpRequest.Builder request) throws Exception {
return HTTP.send(request.build(), HttpResponse.BodyHandlers.ofString()).body();
}
private static String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,46 @@
package dev.relism.flash.ext.security.test;
import dev.relism.flash.ext.security.Principal;
import dev.relism.flash.ext.security.SecurityExtension;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.testing.FlashRequest;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
/**
* Authenticates test requests as any {@link Principal}, whatever mechanisms the application
* installs — an OIDC user, an API key, a plain name — without an identity provider.
*
* <pre>{@code
* FlashTest app = FlashTest.of(flash -> flash.apply(new MyApp()).install(new TestSecurity()));
* app.request().with(TestSecurity.as(() -> "alice")).get("/me");
* }</pre>
*
* <p>Works because {@code FlashTest} serves the application in the test's own JVM: the principal
* is handed over by reference, under a token only this process can have issued.
*/
public final class TestSecurity implements FlashExtension {
private static final String SCHEME = "Test ";
private static final Map<String, Principal> PRINCIPALS = new ConcurrentHashMap<>();
/** Sends the request as {@code principal}. */
public static Consumer<FlashRequest> as(Principal principal) {
String token = UUID.randomUUID().toString();
PRINCIPALS.put(token, principal);
return request -> request.header("Authorization", SCHEME + token);
}
@Override
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
ctx.onReady(() -> ctx.require(SecurityExtension.class).mechanism(req -> {
String header = req.header("Authorization");
return header != null && header.startsWith(SCHEME) ? PRINCIPALS.get(header.substring(SCHEME.length())) : null;
}));
}
}
@@ -0,0 +1,29 @@
package dev.relism.flash.ext.security.test;
import dev.relism.flash.ext.security.SecurityExtension;
import dev.relism.flash.ext.security.SecurityIdentity;
import dev.relism.flash.ext.security.SecurityPolicy;
import dev.relism.flash.testing.FlashTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
class TestSecurityTest {
static final SecurityExtension security = new SecurityExtension();
@RegisterExtension
static final FlashTest app = FlashTest.of(flash -> flash
.install(security)
.install(new TestSecurity())
.get("/me", (req, res) -> SecurityIdentity.current().principal().name(), security.enforce(SecurityPolicy.AUTHENTICATED)));
@Test
void aRequestIsAuthenticatedAsTheGivenPrincipal() {
app.request().with(TestSecurity.as(() -> "alice")).get("/me").expectStatus(200).expectBody("alice");
}
@Test
void anUnknownTokenIsNobody() {
app.request().header("Authorization", "Test forged").get("/me").expectStatus(401);
}
}