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,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\"");
}
}