refactor(ext-oidc): replace auth modules with security extensions
This commit is contained in:
+150
@@ -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();
|
||||
}
|
||||
}
|
||||
+47
@@ -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);
|
||||
}
|
||||
}
|
||||
+46
@@ -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;
|
||||
}));
|
||||
}
|
||||
}
|
||||
+29
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user