feat(ext-security): add an OAuth 2.1 authorization server

flash-ext-security-oauth-server issues RFC 9068 access tokens (code + PKCE S256,
CIMD and DCR clients, RFC 8707 resources, rotating refresh tokens) for resources on
the application's own origin. Around it: SecurityExtension resolves a configured
origin instead of X-Forwarded-* headers, mechanisms expose schemes() and a route can
be restricted to some of them, McpConfig.mechanisms(...) uses that, OIDC bearers must
be typed at+jwt, and PublicUrl guards outbound fetches against internal addresses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Zakaria El Orche
2026-09-22 11:39:09 +00:00
co-authored by Claude Opus 5
parent 7f225e0faf
commit f28fc43150
35 changed files with 1958 additions and 80 deletions
@@ -1,5 +1,6 @@
package dev.relism.flash.ext.security.test;
import com.nimbusds.jose.JOSEObjectType;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.crypto.RSASSASigner;
@@ -76,7 +77,7 @@ public final class FakeOidcProvider implements AutoCloseable {
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),
send(ex, 200, JSONObjectUtils.toJSONString(Map.of("access_token", token(subject, claims), "id_token", idToken(subject, id),
"refresh_token", UUID.randomUUID().toString(), "expires_in", expiresIn, "scope", "openid email")));
});
server.start();
@@ -106,13 +107,22 @@ public final class FakeOidcProvider implements AutoCloseable {
return this;
}
/** A signed token for {@code subject}, valid five minutes, with {@code claims} added. */
/** A signed access token for {@code subject}, valid five minutes, with {@code claims} added — {@code typ} {@code at+jwt} (RFC 9068). */
public String token(String subject, Map<String, Object> claims) {
return sign(new JOSEObjectType("at+jwt"), subject, claims);
}
/** A signed ID token for {@code subject}, which a resource server must never take for an access token. */
public String idToken(String subject, Map<String, Object> claims) {
return sign(JOSEObjectType.JWT, subject, claims);
}
private String sign(JOSEObjectType type, 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());
SignedJWT jwt = new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.RS256).type(type).keyID(key.getKeyID()).build(), set.build());
jwt.sign(new RSASSASigner(key));
return jwt.serialize();
} catch (Exception e) {
@@ -0,0 +1,138 @@
package dev.relism.flash.ext.security.test;
import com.nimbusds.jose.util.JSONObjectUtils;
import dev.relism.flash.ext.security.Principal;
import dev.relism.flash.testing.FlashRequest;
import dev.relism.flash.testing.FlashResponse;
import dev.relism.flash.testing.FlashTest;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
/**
* An OAuth 2.1 client of the application under test, driving its own authorization server the way an MCP
* client does: discovery (RFC 8414), registration (RFC 7591), authorization code with PKCE, consent, and
* the token exchange. Its user signs in as any principal, through {@link TestSecurity}.
*
* <pre>{@code
* OAuthTestClient.Tokens tokens = OAuthTestClient.register(app).authorize(() -> "alice");
* app.request().with(tokens.bearer()).post("/mcp");
* }</pre>
*/
public final class OAuthTestClient {
/** Where the authorization server sends the code: never contacted, only read off the redirect. */
public static final String REDIRECT_URI = "http://127.0.0.1/callback";
private static final Base64.Encoder BASE64URL = Base64.getUrlEncoder().withoutPadding();
private static final SecureRandom RANDOM = new SecureRandom();
private final FlashTest app;
private final Map<String, Object> metadata;
private final String clientId;
/** What the exchange returned. */
public record Tokens(String accessToken, String refreshToken, String scope) {
public Consumer<FlashRequest> bearer() {
return request -> request.header("Authorization", "Bearer " + accessToken);
}
}
private OAuthTestClient(FlashTest app, Map<String, Object> metadata, String clientId) {
this.app = app;
this.metadata = metadata;
this.clientId = clientId;
}
/** A public client, registered at the server's registration endpoint, allowed refresh tokens. */
public static OAuthTestClient register(FlashTest app) {
Map<String, Object> metadata = json(app.get("/.well-known/oauth-authorization-server").expectStatus(200));
Map<String, Object> client = json(app.request().header("Content-Type", "application/json")
.body(JSONObjectUtils.toJSONString(Map.of("client_name", "Test client", "redirect_uris", List.of(REDIRECT_URI),
"grant_types", List.of("authorization_code", "refresh_token"), "token_endpoint_auth_method", "none")))
.post(path(metadata, "registration_endpoint")).expectStatus(201));
return new OAuthTestClient(app, metadata, (String) client.get("client_id"));
}
public String clientId() {
return clientId;
}
/** Signs in as {@code user}, allows this client if asked to, and exchanges the code: the application's default resource and no scope. */
public Tokens authorize(Principal user) {
return authorize(user, Map.of());
}
/** The same, with extra authorization request parameters — {@code scope}, {@code resource}. */
public Tokens authorize(Principal user, Map<String, String> parameters) {
byte[] secret = new byte[32];
RANDOM.nextBytes(secret);
String verifier = BASE64URL.encodeToString(secret);
StringBuilder query = new StringBuilder("response_type=code&client_id=" + encode(clientId) + "&redirect_uri=" + encode(REDIRECT_URI)
+ "&state=test&code_challenge=" + s256(verifier) + "&code_challenge_method=S256");
parameters.forEach((key, value) -> query.append('&').append(key).append('=').append(encode(value)));
String authorize = path(metadata, "authorization_endpoint");
String location = app.request().with(TestSecurity.as(user)).get(authorize + "?" + query).expectStatus(302).header("Location");
if (!location.startsWith(REDIRECT_URI)) {
location = app.request().with(TestSecurity.as(user)).header("Content-Type", "application/x-www-form-urlencoded")
.body(query + "&consent=allow").post(authorize).expectStatus(303).header("Location");
}
String code = parameter(location, "code");
if (code == null) throw new AssertionError("No code: " + location);
return tokens(json(app.request().header("Content-Type", "application/x-www-form-urlencoded")
.body("grant_type=authorization_code&client_id=" + encode(clientId) + "&code=" + encode(code)
+ "&redirect_uri=" + encode(REDIRECT_URI) + "&code_verifier=" + verifier)
.post(path(metadata, "token_endpoint")).expectStatus(200)));
}
/** Trades a refresh token for a new pair. */
public Tokens refresh(String refreshToken) {
return tokens(json(app.request().header("Content-Type", "application/x-www-form-urlencoded")
.body("grant_type=refresh_token&client_id=" + encode(clientId) + "&refresh_token=" + encode(refreshToken))
.post(path(metadata, "token_endpoint")).expectStatus(200)));
}
private static Tokens tokens(Map<String, Object> response) {
return new Tokens((String) response.get("access_token"), (String) response.get("refresh_token"), (String) response.get("scope"));
}
private static String path(Map<String, Object> metadata, String endpoint) {
return URI.create((String) metadata.get(endpoint)).getRawPath();
}
private static String parameter(String uri, String name) {
for (String pair : URI.create(uri).getRawQuery().split("&")) {
if (pair.startsWith(name + "=")) return java.net.URLDecoder.decode(pair.substring(name.length() + 1), StandardCharsets.UTF_8);
}
return null;
}
private static Map<String, Object> json(FlashResponse response) {
try {
return JSONObjectUtils.parse(response.body());
} catch (java.text.ParseException e) {
throw new AssertionError("Not JSON: " + response.body(), e);
}
}
private static String s256(String verifier) {
try {
return BASE64URL.encodeToString(MessageDigest.getInstance("SHA-256").digest(verifier.getBytes(StandardCharsets.US_ASCII)));
} catch (java.security.NoSuchAlgorithmException impossible) {
throw new IllegalStateException(impossible);
}
}
private static String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
}