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,20 @@
# flash-ext-security-apikey
API keys for [`flash-ext-security-core`](../../flash-ext-security-core/docs/README.md), sent as
`Authorization: Bearer <prefix>_<id>.<secret>`.
```java
ApiKeyExtension<Grant> apiKeys = new ApiKeyExtension<>("gk", id -> rows.find(id)); // ApiKeyStore<Grant>
app.install(new SecurityExtension().roles(...)).install(apiKeys);
GeneratedApiKey key = apiKeys.generate(); // show key.token() once
rows.save(key.id(), key.secretHash(), grant); // never the token
```
The store returns `ApiKey<G>(id, secretHash, grant, expiresAt, revokedAt)`; `G` is whatever the
application authorizes on. An authenticated caller is an `ApiKeyPrincipal<G>` carrying that grant —
read it in a `RoleResolver` with `identity.principal(ApiKeyPrincipal.class)`.
A bearer token without this prefix is left to other mechanisms; one with it that fails — unknown id,
wrong secret, expired, revoked — is a `401 invalid_token`. Only a SHA-256 of the secret is stored: the
secret is 192 random bits, so a slow KDF would protect nothing and cost every request.
@@ -0,0 +1,30 @@
<?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-apikey</artifactId>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-security-core</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-testing</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,17 @@
package dev.relism.flash.ext.security.apikey;
import java.time.Instant;
/**
* An API key as the application stores it: never the secret, only its hash, and the grant it was
* issued with — whatever the application authorizes on.
*
* @param expiresAt {@code null} for a key that does not expire
* @param revokedAt {@code null} for a key that has not been revoked
*/
public record ApiKey<G>(String id, String secretHash, G grant, Instant expiresAt, Instant revokedAt) {
boolean isActive() {
return revokedAt == null && (expiresAt == null || expiresAt.toEpochMilli() > System.currentTimeMillis());
}
}
@@ -0,0 +1,97 @@
package dev.relism.flash.ext.security.apikey;
import dev.relism.flash.ext.security.AuthenticationFailedException;
import dev.relism.flash.ext.security.AuthenticationMechanism;
import dev.relism.flash.ext.security.Principal;
import dev.relism.flash.ext.security.SecurityExtension;
import dev.relism.flash.ext.security.SecurityScheme;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.models.Request;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Base64;
/**
* API keys sent as {@code Authorization: Bearer <prefix>_<id>.<secret>}. The prefix makes a key
* recognisable at a glance and to secret scanners; the id is what the store is queried by; only a
* SHA-256 of the secret is ever stored — a KDF would add nothing to 192 random bits.
*
* <pre>{@code
* app.install(new SecurityExtension())
* .install(new ApiKeyExtension<>("gk", keys::find));
* }</pre>
*/
public final class ApiKeyExtension<G> implements FlashExtension, AuthenticationMechanism {
private static final AuthenticationFailedException INVALID = new AuthenticationFailedException("Bearer error=\"invalid_token\"");
private static final SecureRandom RANDOM = new SecureRandom();
private static final Base64.Encoder BASE64URL = Base64.getUrlEncoder().withoutPadding();
private final String prefix;
private final String bearer;
private final ApiKeyStore<G> store;
/** @param prefix identifies this application's keys; letters and digits only */
public ApiKeyExtension(String prefix, ApiKeyStore<G> store) {
if (!prefix.matches("[A-Za-z0-9]+")) throw new IllegalArgumentException("API key prefix must be alphanumeric: " + prefix);
this.prefix = prefix;
this.bearer = "Bearer " + prefix + "_";
this.store = store;
}
/** A new key: 72 bits of id, 192 bits of secret. */
public GeneratedApiKey generate() {
String id = random(9);
String secret = random(24);
return new GeneratedApiKey(id, prefix + "_" + id + "." + secret, hash(secret));
}
@Override
public Principal authenticate(Request req) {
String header = req.header("Authorization");
if (header == null || !header.startsWith(bearer)) return null;
int dot = header.indexOf('.', bearer.length());
if (dot < 0) throw INVALID;
ApiKey<G> key = store.find(header.substring(bearer.length(), dot));
if (key == null || !matches(header.substring(dot + 1), key.secretHash()) || !key.isActive()) throw INVALID;
return new ApiKeyPrincipal<>(key.id(), key.grant());
}
@Override
public SecurityScheme scheme() {
return SecurityScheme.bearer("apiKey", prefix + "_<id>.<secret>");
}
@Override
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
ctx.provide(ApiKeyExtension.class, this);
ctx.onReady(() -> ctx.require(SecurityExtension.class).mechanism(this));
}
private static boolean matches(String secret, String secretHash) {
return MessageDigest.isEqual(digest(secret), Base64.getUrlDecoder().decode(secretHash));
}
private static String hash(String secret) {
return BASE64URL.encodeToString(digest(secret));
}
private static byte[] digest(String secret) {
try {
return MessageDigest.getInstance("SHA-256").digest(secret.getBytes(StandardCharsets.US_ASCII));
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException(impossible);
}
}
private static String random(int bytes) {
byte[] value = new byte[bytes];
RANDOM.nextBytes(value);
return BASE64URL.encodeToString(value);
}
}
@@ -0,0 +1,6 @@
package dev.relism.flash.ext.security.apikey;
import dev.relism.flash.ext.security.Principal;
/** A caller authenticated by an API key, carrying the grant the key was issued with. */
public record ApiKeyPrincipal<G>(String name, G grant) implements Principal {}
@@ -0,0 +1,9 @@
package dev.relism.flash.ext.security.apikey;
/** Where the application keeps its API keys. */
@FunctionalInterface
public interface ApiKeyStore<G> {
/** The key with this id, or {@code null}. */
ApiKey<G> find(String id);
}
@@ -0,0 +1,7 @@
package dev.relism.flash.ext.security.apikey;
/**
* A freshly generated key. {@code token} goes to the caller exactly once; the application stores
* {@code id} and {@code secretHash}, never the token.
*/
public record GeneratedApiKey(String id, String token, String secretHash) {}
@@ -0,0 +1,57 @@
package dev.relism.flash.ext.security.apikey;
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;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
class ApiKeyExtensionTest {
static final Map<String, ApiKey<String>> KEYS = new ConcurrentHashMap<>();
static final SecurityExtension security = new SecurityExtension();
static final ApiKeyExtension<String> apiKeys = new ApiKeyExtension<>("fk", KEYS::get);
@RegisterExtension
static final FlashTest app = FlashTest.of(flash -> flash
.install(security)
.install(apiKeys)
.get("/grant", (req, res) -> SecurityIdentity.current().principal(ApiKeyPrincipal.class).grant(),
security.enforce(SecurityPolicy.AUTHENTICATED)));
static String issue(String grant, Instant expiresAt, Instant revokedAt) {
GeneratedApiKey key = apiKeys.generate();
KEYS.put(key.id(), new ApiKey<>(key.id(), key.secretHash(), grant, expiresAt, revokedAt));
return "Bearer " + key.token();
}
@Test
void anIssuedKeyAuthenticatesWithItsGrant() {
app.request().header("Authorization", issue("project-42", null, null)).get("/grant").expectStatus(200).expectBody("project-42");
}
@Test
void aWrongSecretAnExpiredKeyAndARevokedKeyAreRejectedAsInvalid() {
String valid = issue("x", null, null);
for (String token : new String[]{
valid.substring(0, valid.length() - 1) + (valid.endsWith("A") ? "B" : "A"),
issue("x", Instant.now().minusSeconds(1), null),
issue("x", null, Instant.now()),
"Bearer fk_no-secret-here"}) {
app.request().header("Authorization", token).get("/grant")
.expectStatus(401).expectHeader("WWW-Authenticate", "Bearer error=\"invalid_token\"");
}
}
/** Another application's bearer token is not a key of ours: it is left to other mechanisms, not rejected. */
@Test
void aForeignBearerTokenIsNotThisMechanisms() {
app.request().header("Authorization", "Bearer eyJhbGciOi.payload.signature").get("/grant")
.expectStatus(401).expectHeader("WWW-Authenticate", "Bearer realm=\"apiKey\"");
}
}