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:
co-authored by
Claude Opus 5
parent
7f225e0faf
commit
f28fc43150
@@ -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")); }
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
+4
-2
@@ -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(); }
|
||||
}
|
||||
|
||||
+32
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
-15
@@ -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);
|
||||
|
||||
+3
-1
@@ -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) {
|
||||
|
||||
|
||||
+18
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
-2
@@ -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\"}]");
|
||||
|
||||
Reference in New Issue
Block a user