refactor(ext-oidc): replace auth modules with security extensions
This commit is contained in:
+17
@@ -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());
|
||||
}
|
||||
}
|
||||
+97
@@ -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);
|
||||
}
|
||||
}
|
||||
+6
@@ -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 {}
|
||||
+9
@@ -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);
|
||||
}
|
||||
+7
@@ -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) {}
|
||||
+57
@@ -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\"");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user