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
@@ -47,11 +47,30 @@ On a handler or an MCP tool class:
Mechanisms are tried in registration order, then the session cookie. The first to return a
principal wins. When none does:
- a browser (`Accept: text/html`) is redirected to the only login method, or to `loginPage` when there are several;
- a browser (`Accept: text/html`) is redirected to `loginPage` when one is set, otherwise to the only
login method when it is a redirect, otherwise to `/login`;
- anything else gets `401` with every mechanism's challenge in `WWW-Authenticate`.
`entryPoint(...)` replaces that, e.g. to pick an identity provider from the user's email domain.
`enforce(policy, entryPoint, mechanisms)` restricts a route to some mechanisms: any other credential,
the session cookie included, is no credential there — a bearer-only endpoint a browser's cookie must
not reach.
## Origin
`origin("https://app.example")` is where the application is served. Session cookies, sign-in
callbacks and token audiences are built from `origin(req)`: the configured origin, or the request's
own scheme and `Host` when none is. `X-Forwarded-*` is never read: the client can send anything, and
an origin taken from it lets a caller choose which audience a token must have. Configure it in every
deployment; the fallback is for development and tests.
## Addresses someone else chose
`PublicUrl.require(url)` refuses anything that is not https on a public address. Call it before the
server fetches a URL a customer or a client supplied — an identity provider, a metadata document — or
that fetch becomes a request forgery against the server's own network.
## Sessions
`signIn(req, res, principal[, expiresAt])` stores the principal under a `flash_session` cookie;
@@ -78,7 +97,7 @@ security.mechanism(new AuthenticationMechanism() {
if (p == null) throw new AuthenticationFailedException(null); // mine, and invalid
return p;
}
public SecurityScheme scheme() { return SecurityScheme.bearer("key", "opaque"); }
public List<SecurityScheme> schemes() { return List.of(SecurityScheme.bearer("key", "opaque")); }
});
```
@@ -2,6 +2,8 @@ package dev.relism.flash.ext.security;
import dev.relism.flash.models.Request;
import java.util.List;
/**
* Reads one kind of credential off a request. A mechanism never writes the response: an anonymous
* request is answered by the {@link AuthenticationEntryPoint}, a rejected one by the 401 its
@@ -16,6 +18,6 @@ public interface AuthenticationMechanism {
*/
Principal authenticate(Request req);
/** How OpenAPI documents the credential and a 401 challenges for it; {@code null} for neither. */
default SecurityScheme scheme() { return null; }
/** How OpenAPI documents each credential this mechanism reads, and how a 401 challenges for it. */
default List<SecurityScheme> schemes() { return List.of(); }
}
@@ -0,0 +1,32 @@
package dev.relism.flash.ext.security;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.URI;
import java.net.UnknownHostException;
/**
* The guard for a URL someone other than the operator chose — an identity provider an organization
* registers, a client's metadata document: fetching it must not become a request forgery against the
* server's own network.
*/
public final class PublicUrl {
private PublicUrl() {}
/**
* ponytail: resolved here and again by the HTTP client, so DNS rebinding between the two is not covered.
*
* @throws IllegalArgumentException {@code url} is not https, or one of its host's addresses is not public
*/
public static void require(String url) throws UnknownHostException {
URI uri = URI.create(url);
if (!"https".equals(uri.getScheme())) throw new IllegalArgumentException("Not https: " + url);
for (InetAddress address : InetAddress.getAllByName(uri.getHost())) {
if (address.isLoopbackAddress() || address.isSiteLocalAddress() || address.isLinkLocalAddress() || address.isAnyLocalAddress()
|| address.isMulticastAddress() || (address instanceof Inet6Address && (address.getAddress()[0] & 0xfe) == 0xfc)) {
throw new IllegalArgumentException("Not a public address: " + url);
}
}
}
}
@@ -60,7 +60,8 @@ public class SecurityExtension implements FlashExtension {
private AuthenticationEntryPoint entryPoint = this::commence;
private SessionStore sessions = new InMemorySessionStore();
private Duration sessionTimeout = Duration.ofHours(12);
private String loginPage = "/login";
private String loginPage;
private String origin;
// -- Configuration --------------------------------------------------------
@@ -92,23 +93,33 @@ public class SecurityExtension implements FlashExtension {
return this;
}
/** Where a browser signs in, unless the only {@link LoginMethod} is a redirect it can follow directly. */
/**
* Where a browser signs in. Unset: straight to the only {@link LoginMethod} when it is a redirect, {@code /login}
* otherwise. Set it when the application's page knows ways in the list does not, like a provider picked by email.
*/
public SecurityExtension loginPage(String loginPage) {
this.loginPage = loginPage;
return this;
}
/**
* Where the application is served, e.g. {@code https://app.example}: what session cookies, sign-in
* callbacks and token audiences are built from. Unset, each request's own {@link Request#origin()}
* is used, which the client chooses — set it wherever that matters, which is every deployment.
*/
public SecurityExtension origin(String origin) {
this.origin = origin.endsWith("/") ? origin.substring(0, origin.length() - 1) : origin;
return this;
}
// -- Registration (boot time) ---------------------------------------------
public synchronized SecurityExtension mechanism(AuthenticationMechanism mechanism) {
mechanisms = append(mechanisms, mechanism);
return mechanism.scheme() == null ? this : scheme(mechanism.scheme());
}
/** Documents and challenges for a credential beyond the one {@link AuthenticationMechanism#scheme()} names. */
public synchronized SecurityExtension scheme(SecurityScheme scheme) {
schemes = append(schemes, scheme);
challenges = challenges == null ? scheme.challenge() : challenges + ", " + scheme.challenge();
for (SecurityScheme scheme : mechanism.schemes()) {
schemes = append(schemes, scheme);
challenges = challenges == null ? scheme.challenge() : challenges + ", " + scheme.challenge();
}
return this;
}
@@ -136,17 +147,27 @@ public class SecurityExtension implements FlashExtension {
// -- Runtime --------------------------------------------------------------
/** The configured {@link #origin(String)}, or the one {@code req} names when none is. */
public String origin(Request req) {
return origin != null ? origin : req.origin();
}
/**
* The caller, or {@code null} when no mechanism recognises a credential.
*
* @throws AuthenticationFailedException a mechanism recognised one and rejected it
*/
public SecurityIdentity authenticate(Request req) {
for (AuthenticationMechanism mechanism : mechanisms) {
return authenticate(req, null);
}
/** {@code only} restricts the chain to those mechanisms, without the session; {@code null} is every one of them and the session. */
private SecurityIdentity authenticate(Request req, AuthenticationMechanism[] only) {
for (AuthenticationMechanism mechanism : only != null ? only : mechanisms) {
Principal principal = mechanism.authenticate(req);
if (principal != null) return new SecurityIdentity(principal, this, req);
}
Principal principal = sessionPrincipal(req);
Principal principal = only != null ? null : sessionPrincipal(req);
return principal == null ? null : new SecurityIdentity(principal, this, req);
}
@@ -168,10 +189,23 @@ public class SecurityExtension implements FlashExtension {
/** {@code anonymous} answers a caller without credentials on this route instead of the configured entry point. */
public Middleware enforce(SecurityPolicy policy, AuthenticationEntryPoint anonymous) {
return guard(policy, anonymous, null);
}
/**
* A route only {@code mechanisms} authenticate: every other credential, the session cookie included,
* is no credential at all here — a bearer route that must not be reached with a browser's cookie or
* with another mechanism's token.
*/
public Middleware enforce(SecurityPolicy policy, AuthenticationEntryPoint anonymous, List<AuthenticationMechanism> mechanisms) {
return guard(policy, anonymous, mechanisms.toArray(AuthenticationMechanism[]::new));
}
private Middleware guard(SecurityPolicy policy, AuthenticationEntryPoint anonymous, AuthenticationMechanism[] only) {
return next -> (req, res) -> {
SecurityIdentity identity;
try {
identity = authenticate(req);
identity = authenticate(req, only);
} catch (AuthenticationFailedException rejected) {
if (policy.required) {
if (rejected.challenge() != null) res.header(WWW_AUTHENTICATE, rejected.challenge());
@@ -209,7 +243,7 @@ public class SecurityExtension implements FlashExtension {
RANDOM.nextBytes(id);
Session session = new Session(Base64.getUrlEncoder().withoutPadding().encodeToString(id), principal, expiresAt);
sessions.save(session);
res.header("Set-Cookie", COOKIE + "=" + session.id() + "; Path=/; HttpOnly; SameSite=Lax" + (req.origin().startsWith("https") ? "; Secure" : ""));
res.header("Set-Cookie", COOKIE + "=" + session.id() + "; Path=/; HttpOnly; SameSite=Lax" + (origin(req).startsWith("https") ? "; Secure" : ""));
}
/** Ends the caller's session and returns where the browser goes next. */
@@ -251,8 +285,9 @@ public class SecurityExtension implements FlashExtension {
private Object commence(Request req, Response res) {
LoginMethod[] methods = loginMethods;
String accept = req.header("Accept");
if (methods.length > 0 && accept != null && accept.contains("text/html")) {
String login = methods.length == 1 && methods[0].kind() == LoginMethod.Kind.REDIRECT ? methods[0].url() : loginPage;
if ((loginPage != null || methods.length > 0) && accept != null && accept.contains("text/html")) {
String login = loginPage != null ? loginPage
: methods.length == 1 && methods[0].kind() == LoginMethod.Kind.REDIRECT ? methods[0].url() : "/login";
ByteView query = req.getRequestLine().getQuery();
byte[] raw = new byte[query == null ? 0 : query.length()];
for (int i = 0; i < raw.length; i++) raw[i] = query.byteAt(i);
@@ -7,7 +7,9 @@ import java.util.Map;
* API call receives for it, and — for OAuth — the issuer that grants it.
*
* @param definition the OpenAPI Security Scheme Object, verbatim
* @param issuer the authorization server's issuer identifier, {@code null} for anything else
* @param issuer the authorization server's issuer identifier {@code "/"} when the application is its own,
* at whatever {@link SecurityExtension#origin(dev.relism.flash.models.Request)} it is reached by;
* {@code null} for anything else
*/
public record SecurityScheme(String name, Map<String, Object> definition, String challenge, String issuer) {
@@ -0,0 +1,18 @@
package dev.relism.flash.ext.security;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
class PublicUrlTest {
@Test
void onlyHttpsOnAPublicAddressPasses() {
assertDoesNotThrow(() -> PublicUrl.require("https://1.1.1.1/.well-known/openid-configuration"));
for (String url : new String[]{"http://1.1.1.1/", "https://127.0.0.1/", "https://10.0.0.8/", "https://192.168.1.1/",
"https://169.254.169.254/latest/meta-data", "https://[::1]/", "https://[fd00::1]/", "https://0.0.0.0/"}) {
assertThrows(IllegalArgumentException.class, () -> PublicUrl.require(url), url);
}
}
}
@@ -2,6 +2,7 @@ package dev.relism.flash.ext.security;
import dev.relism.flash.ext.openapi.OpenApiExtension;
import dev.relism.flash.models.Request;
import dev.relism.flash.testing.FlashResponse;
import dev.relism.flash.testing.FlashTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
@@ -9,7 +10,9 @@ import org.junit.jupiter.api.extension.RegisterExtension;
import java.time.Instant;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class SecurityExtensionTest {
@@ -29,8 +32,8 @@ class SecurityExtensionTest {
}
@Override
public SecurityScheme scheme() {
return SecurityScheme.bearer("key", "opaque");
public java.util.List<SecurityScheme> schemes() {
return java.util.List.of(SecurityScheme.bearer("key", "opaque"));
}
};
@@ -45,6 +48,8 @@ class SecurityExtensionTest {
.install(security)
.install(new OpenApiExtension("/openapi", "test", "1"))
.get("/me", (req, res) -> SecurityIdentity.current().principal().name(), security.enforce(SecurityPolicy.AUTHENTICATED))
.get("/key-only", (req, res) -> SecurityIdentity.current().principal().name(), security.enforce(SecurityPolicy.AUTHENTICATED,
(req, res) -> { throw dev.relism.flash.exceptions.HttpException.unauthorized(); }, java.util.List.of(KEY)))
.post("/login", (req, res) -> {
security.signIn(req, res, new KeyPrincipal("carol", Set.of()), Instant.now().minusSeconds(1));
return "signed in";
@@ -76,6 +81,19 @@ class SecurityExtensionTest {
.expectHeader("Location", "/auth/key/login?redirect=%2Fme%3Ftab%3Dkeys");
}
/** An application's own page may know ways in the list does not, so a configured one always wins. */
@Test
void aConfiguredLoginPageWinsOverTheOnlyLoginMethod() {
SecurityExtension paged = new SecurityExtension().loginPage("/sign-in")
.loginMethod(new LoginMethod("key", "Key", "/auth/key/login", LoginMethod.Kind.REDIRECT));
FlashTest own = FlashTest.of(flash -> flash.install(paged).get("/me", (req, res) -> "me", paged.enforce(SecurityPolicy.AUTHENTICATED)));
try {
own.request().header("Accept", "text/html").get("/me").expectStatus(302).expectHeader("Location", "/sign-in?redirect=%2Fme");
} finally {
own.app().stop().join();
}
}
@Test
void permitAllIdentifiesWhoeverAuthenticatesAndToleratesEveryoneElse() {
app.request().header("Authorization", "Key bob").get("/open").expectBody("bob");
@@ -108,6 +126,37 @@ class SecurityExtensionTest {
app.request().header("Cookie", session).get("/me").expectStatus(401);
}
/** A route restricted to one mechanism takes that credential and nothing else, not even a signed-in browser. */
@Test
void aRouteRestrictedToAMechanismIgnoresTheSession() {
String cookie = app.request().post("/login").expectStatus(200).header("Set-Cookie");
String session = cookie.substring(0, cookie.indexOf(';'));
app.request().header("Cookie", session).get("/me").expectStatus(200);
app.request().header("Cookie", session).get("/key-only").expectStatus(401);
app.request().header("Authorization", "Key alice").get("/key-only").expectStatus(200).expectBody("alice");
}
/** The client chooses its Host and any X-Forwarded-* it likes: only a configured origin is the application's own. */
@Test
void theOriginIsConfiguredNeverTakenFromForwardedHeaders() {
String forwarded = app.request().header("X-Forwarded-Proto", "https").header("X-Forwarded-Host", "evil.example")
.post("/login").expectStatus(200).header("Set-Cookie");
assertFalse(forwarded.contains("Secure"), forwarded);
SecurityExtension served = new SecurityExtension().origin("https://app.example/");
FlashTest configured = FlashTest.of(flash -> flash.install(served).post("/login", (req, res) -> {
served.signIn(req, res, new KeyPrincipal("carol", Set.of()));
return served.origin(req);
}));
try {
FlashResponse login = configured.request().post("/login").expectStatus(200).expectBody("https://app.example");
assertTrue(login.header("Set-Cookie").contains("; Secure"));
} finally {
configured.app().stop().join();
}
}
@Test
void loginMethodsAreListed() {
app.get("/auth/methods").expectStatus(200).expectBody("[{\"id\":\"key\",\"name\":\"Key\",\"url\":\"/auth/key/login\",\"kind\":\"redirect\"}]");