feat(ext-security): add an OAuth 2.1 authorization server #18
@@ -15,6 +15,7 @@ a zero-allocation FSM router, bounded protocol state, and one shared request/res
|
||||
| `flash-extensions/flash-ext-security-oidc` | OpenID Connect: bearer tokens, code flow + PKCE |
|
||||
| `flash-extensions/flash-ext-security-apikey` | API keys |
|
||||
| `flash-extensions/flash-ext-security-form` | Password sign-in |
|
||||
| `flash-extensions/flash-ext-security-oauth-server` | OAuth 2.1 authorization server for the application's own users and resources |
|
||||
| `flash-extensions/flash-ext-security-test` | Test identities, fake OpenID Provider |
|
||||
| `flash-extensions/flash-ext-mcp` | MCP (Model Context Protocol) server — Streamable HTTP, secured by flash-ext-security-core |
|
||||
| `flash-extensions/flash-ext-view-core` | Minimal shared SSR runtime primitives |
|
||||
|
||||
@@ -14,9 +14,9 @@ authenticate `/mcp` exactly as they authenticate every other route.
|
||||
When a registered mechanism publishes an OAuth2 issuer — `flash-ext-security-oidc` does — the endpoint
|
||||
behaves as the MCP authorization spec requires, with nothing to configure:
|
||||
|
||||
- `GET /.well-known/oauth-protected-resource/mcp` serves RFC 9728 metadata: the `resource` (derived per
|
||||
request from `X-Forwarded-Proto`/`-Host` or `Host`), every issuer as `authorization_servers`, and
|
||||
`scopes_supported` when `McpConfig.scopesSupported(...)` is set;
|
||||
- `GET /.well-known/oauth-protected-resource/mcp` serves RFC 9728 metadata: the `resource` (the
|
||||
application's `SecurityExtension.origin(...)` plus the path), every issuer as `authorization_servers`,
|
||||
and `scopes_supported` when `McpConfig.scopesSupported(...)` is set;
|
||||
- an anonymous call gets `401` with `WWW-Authenticate: Bearer resource_metadata="…"`;
|
||||
- a token whose `aud` does not include the resource is `403` (RFC 8707) and logged at `WARN`. Credentials
|
||||
that are not audience-bound, such as API keys, are unaffected.
|
||||
@@ -31,6 +31,17 @@ mint a resource audience at all — Keycloak ignores RFC 8707's `resource` param
|
||||
cannot add the mapper has no other way in. Every token a registered issuer signs is then accepted on the
|
||||
endpoint, and the boot logs say so.
|
||||
|
||||
## Which credentials
|
||||
|
||||
By default every mechanism in the chain authenticates `/mcp`, and the session cookie too.
|
||||
`McpConfig.mechanisms(...)` narrows that to the ones named: nothing else is a credential on the endpoint,
|
||||
and only their issuers are published — so a client is sent to exactly the authorization server the
|
||||
endpoint trusts.
|
||||
|
||||
```java
|
||||
McpConfig.builder("app").toolsPackage("com.example.tools").mechanisms(authorizationServer).build();
|
||||
```
|
||||
|
||||
## Tool policies
|
||||
|
||||
The core annotations work on tools as on handlers, checked per `tools/call` against the caller the route
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import dev.relism.flash.ext.security.AuthenticationMechanism;
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@@ -30,6 +31,7 @@ public final class McpConfig {
|
||||
private final List<String> allowedOrigins;
|
||||
private final List<String> scopesSupported;
|
||||
private final List<Middleware> middleware;
|
||||
private final List<AuthenticationMechanism> mechanisms;
|
||||
|
||||
private McpConfig(Builder b) {
|
||||
this.name = b.name;
|
||||
@@ -42,6 +44,7 @@ public final class McpConfig {
|
||||
this.allowedOrigins = List.copyOf(b.allowedOrigins);
|
||||
this.scopesSupported = List.copyOf(b.scopesSupported);
|
||||
this.middleware = List.copyOf(b.middleware);
|
||||
this.mechanisms = List.copyOf(b.mechanisms);
|
||||
}
|
||||
|
||||
String name() { return name; }
|
||||
@@ -54,6 +57,7 @@ public final class McpConfig {
|
||||
List<String> allowedOrigins() { return allowedOrigins; }
|
||||
List<String> scopesSupported() { return scopesSupported; }
|
||||
List<Middleware> middleware() { return middleware; }
|
||||
List<AuthenticationMechanism> mechanisms() { return mechanisms; }
|
||||
|
||||
public static Builder builder(String name) { return new Builder(name); }
|
||||
|
||||
@@ -68,6 +72,7 @@ public final class McpConfig {
|
||||
private final List<String> allowedOrigins = new ArrayList<>();
|
||||
private final List<String> scopesSupported = new ArrayList<>();
|
||||
private final List<Middleware> middleware = new ArrayList<>();
|
||||
private final List<AuthenticationMechanism> mechanisms = new ArrayList<>();
|
||||
|
||||
private Builder(String name) {
|
||||
if (name == null || name.isBlank())
|
||||
@@ -108,6 +113,15 @@ public final class McpConfig {
|
||||
/** Published as {@code scopes_supported} in the RFC 9728 metadata, so OAuth clients request them. */
|
||||
public Builder scopesSupported(String... scopes) { this.scopesSupported.addAll(List.of(scopes)); return this; }
|
||||
|
||||
/**
|
||||
* The only mechanisms that authenticate the endpoint, and the only issuers its RFC 9728 metadata
|
||||
* names: any other credential, the session cookie included, is none here. Default: the whole chain.
|
||||
*/
|
||||
public Builder mechanisms(AuthenticationMechanism... mechanisms) {
|
||||
this.mechanisms.addAll(List.of(mechanisms));
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Runs on the MCP route after the transport guards and authentication — rate limiting, auditing, tracing. */
|
||||
public Builder middleware(Middleware... middleware) {
|
||||
this.middleware.addAll(List.of(middleware));
|
||||
|
||||
+27
-11
@@ -1,6 +1,8 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import dev.relism.flash.exceptions.HttpException;
|
||||
import dev.relism.flash.ext.security.AuthenticationEntryPoint;
|
||||
import dev.relism.flash.ext.security.AuthenticationMechanism;
|
||||
import dev.relism.flash.ext.security.SecurityExtension;
|
||||
import dev.relism.flash.ext.security.SecurityIdentity;
|
||||
import dev.relism.flash.ext.security.SecurityPolicy;
|
||||
@@ -38,6 +40,7 @@ import java.util.Objects;
|
||||
public class McpExtension implements FlashExtension {
|
||||
|
||||
private final McpConfig config;
|
||||
private volatile List<SecurityScheme> schemes;
|
||||
|
||||
public McpExtension(McpConfig config) {
|
||||
this.config = config;
|
||||
@@ -65,16 +68,19 @@ public class McpExtension implements FlashExtension {
|
||||
|
||||
private void protect(FlashRegistrar<?> app, SecurityExtension security, List<Middleware> chain) {
|
||||
String metadataPath = "/.well-known/oauth-protected-resource" + config.rootPath();
|
||||
chain.add(security.enforce(SecurityPolicy.AUTHENTICATED, (req, res) -> {
|
||||
List<String> issuers = issuers(security);
|
||||
res.header("WWW-Authenticate", issuers.isEmpty()
|
||||
? String.join(", ", security.schemes().stream().map(SecurityScheme::challenge).toList())
|
||||
: "Bearer resource_metadata=\"" + req.origin() + metadataPath + "\"");
|
||||
List<AuthenticationMechanism> only = config.mechanisms();
|
||||
AuthenticationEntryPoint anonymous = (req, res) -> {
|
||||
List<SecurityScheme> schemes = schemes(security);
|
||||
res.header("WWW-Authenticate", issuers(schemes, null).isEmpty()
|
||||
? String.join(", ", schemes.stream().map(SecurityScheme::challenge).toList())
|
||||
: "Bearer resource_metadata=\"" + security.origin(req) + metadataPath + "\"");
|
||||
throw HttpException.unauthorized();
|
||||
}));
|
||||
};
|
||||
chain.add(only.isEmpty() ? security.enforce(SecurityPolicy.AUTHENTICATED, anonymous)
|
||||
: security.enforce(SecurityPolicy.AUTHENTICATED, anonymous, only));
|
||||
if (config.requireTokenAudience()) {
|
||||
chain.add(next -> (req, res) -> {
|
||||
String resource = req.origin() + config.rootPath();
|
||||
String resource = security.origin(req) + config.rootPath();
|
||||
if (!SecurityIdentity.current().principal().hasAudience(resource)) {
|
||||
log.warn("[flash-ext-mcp] Rejected a token not issued for {} (RFC 8707) — the authorization server must put it in aud", resource);
|
||||
throw HttpException.forbidden();
|
||||
@@ -85,14 +91,24 @@ public class McpExtension implements FlashExtension {
|
||||
log.warn("[flash-ext-mcp] Token audience validation (RFC 8707) is DISABLED for {} — every token a registered issuer signs is accepted.", config.rootPath());
|
||||
}
|
||||
app.get(metadataPath, (req, res) -> {
|
||||
List<String> issuers = issuers(security);
|
||||
List<String> issuers = issuers(schemes(security), security.origin(req));
|
||||
if (issuers.isEmpty()) throw HttpException.notFound("Protected resource metadata");
|
||||
res.type(ContentType.JSON);
|
||||
return McpResourceMetadata.build(req.origin() + config.rootPath(), issuers, config.scopesSupported());
|
||||
return McpResourceMetadata.build(security.origin(req) + config.rootPath(), issuers, config.scopesSupported());
|
||||
});
|
||||
}
|
||||
|
||||
private static List<String> issuers(SecurityExtension security) {
|
||||
return security.schemes().stream().map(SecurityScheme::issuer).filter(Objects::nonNull).toList();
|
||||
/** Resolved at the first request, once every mechanism has registered — which may be after this extension was ready. */
|
||||
private List<SecurityScheme> schemes(SecurityExtension security) {
|
||||
if (schemes == null) {
|
||||
schemes = config.mechanisms().isEmpty() ? security.schemes()
|
||||
: config.mechanisms().stream().flatMap(mechanism -> mechanism.schemes().stream()).toList();
|
||||
}
|
||||
return schemes;
|
||||
}
|
||||
|
||||
/** {@code "/"} is the application's own authorization server, at {@code origin}. */
|
||||
private static List<String> issuers(List<SecurityScheme> schemes, String origin) {
|
||||
return schemes.stream().map(SecurityScheme::issuer).filter(Objects::nonNull).map(issuer -> issuer.equals("/") ? origin : issuer).toList();
|
||||
}
|
||||
}
|
||||
|
||||
+48
@@ -1,6 +1,10 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
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.models.Request;
|
||||
import dev.relism.flash.ext.security.apikey.ApiKey;
|
||||
import dev.relism.flash.ext.security.apikey.ApiKeyExtension;
|
||||
import dev.relism.flash.ext.security.apikey.GeneratedApiKey;
|
||||
@@ -13,6 +17,7 @@ import dev.relism.flash.testing.FlashTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
@@ -32,6 +37,27 @@ class McpSecurityTest {
|
||||
.install(new McpExtension(McpConfig.builder("secure").toolsPackage("dev.relism.flash.ext.mcp.authfixtures.secured")
|
||||
.scopesSupported("openid", "email").build())));
|
||||
|
||||
static final OidcExtension oidc = new OidcExtension(OidcProvider.of("fake", provider.issuer(), "app", "secret"));
|
||||
|
||||
/** Another issuer the application trusts, which the restricted endpoint below must neither accept nor advertise. */
|
||||
static final AuthenticationMechanism elsewhere = new AuthenticationMechanism() {
|
||||
@Override public Principal authenticate(Request req) {
|
||||
return "Other".equals(req.header("Authorization")) ? () -> "other" : null;
|
||||
}
|
||||
@Override public List<SecurityScheme> schemes() {
|
||||
return List.of(SecurityScheme.openIdConnect("other", "https://other.example"));
|
||||
}
|
||||
};
|
||||
|
||||
/** Only the OIDC provider authenticates this endpoint, however many mechanisms the application has. */
|
||||
@RegisterExtension
|
||||
static final FlashTest restricted = FlashTest.of(flash -> flash
|
||||
.install(new SecurityExtension().mechanism(elsewhere))
|
||||
.install(oidc)
|
||||
.install(apiKeys)
|
||||
.install(new McpExtension(McpConfig.builder("restricted").toolsPackage("dev.relism.flash.ext.mcp.fixtures")
|
||||
.mechanisms(oidc).requireTokenAudience(false).build())));
|
||||
|
||||
/** The same chain with the RFC 8707 check turned off, for an authorization server that cannot mint a resource audience. */
|
||||
@RegisterExtension
|
||||
static final FlashTest relaxed = FlashTest.of(flash -> flash
|
||||
@@ -83,6 +109,28 @@ class McpSecurityTest {
|
||||
.post("/mcp").expectStatus(200).expectBodyContains("protocolVersion");
|
||||
}
|
||||
|
||||
/** The resource is the application's own origin: a forwarded header naming another host cannot make its tokens good here. */
|
||||
@Test
|
||||
void aForwardedHostCannotChooseTheResource() {
|
||||
app.request().with(provider.bearer("u", Map.of("aud", "https://evil.example/mcp")))
|
||||
.header("X-Forwarded-Proto", "https").header("X-Forwarded-Host", "evil.example").header("Accept", "application/json")
|
||||
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}").post("/mcp").expectStatus(403);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anEndpointRestrictedToSomeMechanismsAcceptsAndAdvertisesOnlyThem() {
|
||||
restricted.get("/.well-known/oauth-protected-resource/mcp").expectStatus(200)
|
||||
.expectBody("{\"resource\":\"http://127.0.0.1:" + restricted.port() + "/mcp\",\"authorization_servers\":[\"" + provider.issuer() + "\"]}");
|
||||
Consumer<FlashRequest> key = request -> request.header("Authorization", "Bearer " + KEY.token());
|
||||
Consumer<FlashRequest> other = request -> request.header("Authorization", "Other");
|
||||
for (Consumer<FlashRequest> refused : List.of(key, other)) {
|
||||
restricted.request().with(refused).header("Accept", "application/json")
|
||||
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}").post("/mcp").expectStatus(401);
|
||||
}
|
||||
restricted.request().with(provider.bearer("u", Map.of())).header("Accept", "application/json")
|
||||
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}").post("/mcp").expectStatus(200);
|
||||
}
|
||||
|
||||
/** An API key is not audience-bound: the same chain authenticates agents that never saw an authorization server. */
|
||||
@Test
|
||||
void anApiKeyIsAcceptedBesideOAuth() {
|
||||
|
||||
+3
-2
@@ -15,6 +15,7 @@ import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* API keys sent as {@code Authorization: Bearer <prefix>_<id>.<secret>}. The prefix makes a key
|
||||
@@ -63,8 +64,8 @@ public final class ApiKeyExtension<G> implements FlashExtension, AuthenticationM
|
||||
}
|
||||
|
||||
@Override
|
||||
public SecurityScheme scheme() {
|
||||
return SecurityScheme.bearer("apiKey", prefix + "_<id>.<secret>");
|
||||
public List<SecurityScheme> schemes() {
|
||||
return List.of(SecurityScheme.bearer("apiKey", prefix + "_<id>.<secret>"));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -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\"}]");
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# flash-ext-security-oauth-server
|
||||
|
||||
An OAuth 2.1 authorization server for [`flash-ext-security-core`](../../flash-ext-security-core/docs/README.md):
|
||||
the application's own users authorize clients — an MCP client, a CLI, another service — to call the
|
||||
application's own resources. It is the server side of OAuth; signing users in *through* someone else's
|
||||
provider is [`flash-ext-security-oidc`](../../flash-ext-security-oidc/docs/README.md).
|
||||
|
||||
```java
|
||||
SecurityExtension security = new SecurityExtension().origin("https://app.example").loginPage("/login");
|
||||
OAuthServerExtension oauth = new OAuthServerExtension("/mcp") // the resources tokens are for
|
||||
.store(new JdbcOAuthStore(db)) // default: in memory
|
||||
.signingKeys(System.getenv("OAUTH_SIGNING_KEYS")); // default: generated at boot
|
||||
|
||||
app.install(security).install(new CaffeineCacheExtension()).install(oauth)
|
||||
.install(new McpExtension(McpConfig.builder("app").toolsPackage("com.example.tools").mechanisms(oauth).build()));
|
||||
```
|
||||
|
||||
The issuer is `SecurityExtension.origin(...)`. The server is also an `AuthenticationMechanism`: a bearer
|
||||
token it issued authenticates as an `OAuthPrincipal` (`name()` is `sub`, `clientId()`, `claim(...)`,
|
||||
`hasScope`, `hasAudience`). Any other bearer is left to other mechanisms. A token is good only under the
|
||||
resource it was issued for: one for `/mcp` is `401 invalid_token` on `/api/...`, whatever the route asks.
|
||||
|
||||
## What it implements
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| OAuth 2.1 | authorization code with PKCE `S256` only; exact redirect URIs; no implicit, no password grant |
|
||||
| RFC 8252 | a loopback redirect (`http://127.0.0.1`, `[::1]`, `localhost`) may come back on any port |
|
||||
| RFC 8414 | `GET /.well-known/oauth-authorization-server`, CORS-enabled like every client-facing endpoint |
|
||||
| Client ID metadata documents | a `client_id` that is an https URL is fetched, under `PublicUrl`'s rules, and trusted only if it names itself |
|
||||
| RFC 7591 | `POST /oauth/register`; `registration(false)` closes it |
|
||||
| RFC 8707 | `resource` on authorize and token requests, one of the constructor's paths on the origin; the first is the default |
|
||||
| RFC 9068 | access tokens are ES256 JWTs, `typ` `at+jwt`, with `iss`, `sub`, `aud`, `client_id`, `scope`, `iat`, `exp`, `jti`; `GET /oauth/jwks` |
|
||||
| Refresh tokens | opaque, stored hashed, rotated on every use; reusing one — or replaying a code — revokes everything issued from that authorization |
|
||||
| RFC 7009 | `POST /oauth/revoke` takes a refresh token's whole authorization with it |
|
||||
| RFC 9207 | `iss` in every authorization response |
|
||||
| `client_credentials` | only for a client the application registers itself, with `register(metadata)` |
|
||||
|
||||
Clients authenticate at the token and revocation endpoints with `none` (public, PKCE), `client_secret_basic`
|
||||
or `client_secret_post` — whichever they registered. Secrets, codes and refresh tokens are 256 random bits,
|
||||
stored as SHA-256.
|
||||
|
||||
Not implemented: DPoP, mTLS, PAR, JAR, `private_key_jwt`, device authorization, token exchange,
|
||||
introspection (the access token is self-contained: verify it with the JWKS), RFC 7592 client management,
|
||||
and OpenID Connect — there are no ID tokens and no userinfo.
|
||||
|
||||
## Signing in and consent
|
||||
|
||||
`GET /oauth/authorize` runs behind the security chain: a browser without a session goes to the
|
||||
application's sign-in (set `loginPage`, so a page that knows every way in is shown rather than the only
|
||||
listed provider), and comes back when it is signed in.
|
||||
|
||||
The first time a user meets a client, and whenever it asks for more scope than they allowed, the browser
|
||||
is sent to `consentPage(...)` (default `/consent`) with the authorization request as its query string.
|
||||
That page reads what to show from `GET /oauth/authorize/request?<same query>` —
|
||||
`client_name`, `client_uri`, `logo_uri`, `redirect_uri`, `scope`, `resource` — and submits a form
|
||||
`POST /oauth/authorize` with the same parameters and `consent=allow` or `consent=deny`. The form is
|
||||
refused if its `Origin` is not the application's; the session cookie is `SameSite=Lax`, so another
|
||||
site's form does not carry it.
|
||||
|
||||
An unknown client or a redirect URI it did not register is answered with a 400, never a redirect; any
|
||||
other refusal goes back to the redirect URI as `error`, with `state` and `iss`.
|
||||
|
||||
## Subjects
|
||||
|
||||
`subjects(identity -> new OAuthSubject(id, claims))` decides what a token says about who authorized it:
|
||||
`id` becomes `sub`, `claims` go into every token issued from that authorization, refreshes included.
|
||||
Default: the principal's name, no claims. Put there whatever the application must know on the other side
|
||||
— which provider signed the user in, for instance, when that is a tenant boundary.
|
||||
|
||||
The same function decides who may authorize at all: throwing refuses the caller. `/oauth/authorize` runs
|
||||
behind the whole chain, so refuse any credential narrower than the user behind it — an API key scoped to
|
||||
one project must not come back as a token carrying everything its user may do.
|
||||
|
||||
## Caches
|
||||
|
||||
The server needs a `CacheManager` — install [`flash-ext-cache-caffeine`](../../flash-ext-cache-caffeine/docs/README.md).
|
||||
It keeps client metadata documents there, bounded and for ten minutes, failures included: the
|
||||
`client_id` is a URL whoever calls the server chose, so neither the cache nor the fetching it saves
|
||||
may grow with what they send. The RFC 8414 document is cached per issuer.
|
||||
|
||||
## Storage and keys
|
||||
|
||||
`OAuthStore` holds clients (their RFC 7591 metadata, verbatim), grants — codes and refresh tokens, one
|
||||
`OAuthGrant` record, keyed by hash and grouped in a family per authorization — and consents.
|
||||
`use(hash)` must be atomic: it is what makes a code single-use. `InMemoryOAuthStore` loses everything
|
||||
on restart.
|
||||
|
||||
`signingKeys(jwkSet)` takes a JWK set whose first key is a private P-256 key; the others only verify, so a
|
||||
key rotates by putting its successor first. `OAuthServerExtension.generateSigningKeys()` makes one.
|
||||
|
||||
## Testing
|
||||
|
||||
`OAuthTestClient` in [`flash-ext-security-test`](../../flash-ext-security-test/docs/README.md) registers,
|
||||
authorizes as any principal, consents and exchanges, discovering every endpoint from the metadata.
|
||||
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
<version>2.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-security-oauth-server</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-security-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-cache-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.nimbusds</groupId>
|
||||
<artifactId>nimbus-jose-jwt</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-security-test</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-cache-caffeine</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-mcp</artifactId>
|
||||
<version>${project.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package dev.relism.flash.ext.security.oauthserver;
|
||||
|
||||
import com.nimbusds.jose.util.JSONObjectUtils;
|
||||
import dev.relism.flash.ext.cache.Cache;
|
||||
import dev.relism.flash.ext.security.PublicUrl;
|
||||
import dev.relism.flash.ext.security.oauthserver.OAuthServerExtension.OAuthError;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Finds a client — registered, or described by the metadata document its https {@code client_id} points
|
||||
* at — and validates what a client says about itself.
|
||||
*/
|
||||
final class Clients {
|
||||
|
||||
static final List<String> AUTH_METHODS = List.of("none", "client_secret_basic", "client_secret_post");
|
||||
private static final Set<String> LOOPBACK = Set.of("127.0.0.1", "[::1]", "localhost");
|
||||
private static final HttpClient HTTP = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
|
||||
|
||||
private final OAuthStore store;
|
||||
private final boolean localDocuments;
|
||||
private final Cache<String, Optional<OAuthClient>> documents;
|
||||
|
||||
/** @param documents bounded and expiring: the keys are URLs whoever calls the server chose */
|
||||
Clients(OAuthStore store, boolean localDocuments, Cache<String, Optional<OAuthClient>> documents) {
|
||||
this.store = store;
|
||||
this.localDocuments = localDocuments;
|
||||
this.documents = documents;
|
||||
}
|
||||
|
||||
/** The client, or {@code null} when there is none by that id. */
|
||||
OAuthClient find(String id) {
|
||||
if (id == null) return null;
|
||||
if (!id.startsWith("https://") && !(localDocuments && id.startsWith("http://"))) return store.client(id);
|
||||
return documents.get(id, this::document).orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* A client ID Metadata Document: fetched from where its id points, under {@link PublicUrl}'s rules,
|
||||
* and trusted only if it names that same URL. Such a client holds no secret, so it is public. A URL
|
||||
* that fails is remembered as failing, so naming it again does not fetch it again.
|
||||
*/
|
||||
private Optional<OAuthClient> document(String url) {
|
||||
try {
|
||||
if (!localDocuments) PublicUrl.require(url);
|
||||
HttpResponse<String> response = HTTP.send(HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(5))
|
||||
.header("Accept", "application/json").build(), HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() != 200 || response.body().length() > 16_384) return Optional.empty();
|
||||
Map<String, Object> metadata = new HashMap<>(JSONObjectUtils.parse(response.body()));
|
||||
if (!url.equals(metadata.remove("client_id"))) return Optional.empty();
|
||||
metadata.putIfAbsent("token_endpoint_auth_method", "none");
|
||||
if (!"none".equals(metadata.get("token_endpoint_auth_method"))) return Optional.empty();
|
||||
return Optional.of(new OAuthClient(url, null, validate(metadata, false)));
|
||||
} catch (Exception unusable) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 7591 metadata with its defaults filled in: {@code authorization_code}, {@code code}, and
|
||||
* {@code client_secret_basic}. Only an application registering a client itself may grant it
|
||||
* {@code client_credentials}: a client registering itself would otherwise mint its own tokens.
|
||||
*
|
||||
* @throws OAuthError naming the RFC 7591 error
|
||||
*/
|
||||
static Map<String, Object> validate(Map<String, Object> given, boolean trusted) {
|
||||
Map<String, Object> metadata = new HashMap<>(given);
|
||||
metadata.putIfAbsent("grant_types", List.of("authorization_code"));
|
||||
metadata.putIfAbsent("response_types", List.of("code"));
|
||||
metadata.putIfAbsent("token_endpoint_auth_method", "client_secret_basic");
|
||||
List<?> grants = list(metadata.get("grant_types"));
|
||||
Set<String> allowed = trusted ? Set.of("authorization_code", "refresh_token", "client_credentials") : Set.of("authorization_code", "refresh_token");
|
||||
if (grants == null || grants.isEmpty() || !allowed.containsAll(grants)) throw new OAuthError("invalid_client_metadata", "Unsupported grant_types");
|
||||
if (!List.of("code").equals(metadata.get("response_types")) && grants.contains("authorization_code")) {
|
||||
throw new OAuthError("invalid_client_metadata", "response_types must be [\"code\"]");
|
||||
}
|
||||
if (!AUTH_METHODS.contains(metadata.get("token_endpoint_auth_method"))) throw new OAuthError("invalid_client_metadata", "Unsupported token_endpoint_auth_method");
|
||||
if (grants.contains("client_credentials") && "none".equals(metadata.get("token_endpoint_auth_method"))) {
|
||||
throw new OAuthError("invalid_client_metadata", "client_credentials needs a confidential client");
|
||||
}
|
||||
List<?> uris = list(metadata.getOrDefault("redirect_uris", List.of()));
|
||||
if (uris == null || grants.contains("authorization_code") && uris.isEmpty()) throw new OAuthError("invalid_redirect_uri", "redirect_uris is required");
|
||||
for (Object uri : uris) {
|
||||
if (!(uri instanceof String text) || !redirectable(text)) throw new OAuthError("invalid_redirect_uri", "Not an https or loopback URI: " + uri);
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/** OAuth 2.1 §2.3.1: exactly as registered, except that a loopback redirect may use any port (RFC 8252 §7.3). */
|
||||
static boolean matches(String registered, String requested) {
|
||||
if (registered.equals(requested)) return true;
|
||||
try {
|
||||
URI a = URI.create(registered), b = URI.create(requested);
|
||||
return "http".equals(a.getScheme()) && "http".equals(b.getScheme()) && LOOPBACK.contains(a.getHost()) && a.getHost().equals(b.getHost())
|
||||
&& a.getRawPath().equals(b.getRawPath()) && Objects.equals(a.getRawQuery(), b.getRawQuery()) && b.getRawFragment() == null;
|
||||
} catch (IllegalArgumentException malformed) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean redirectable(String uri) {
|
||||
try {
|
||||
URI parsed = URI.create(uri);
|
||||
return parsed.getRawFragment() == null && parsed.getHost() != null
|
||||
&& ("https".equals(parsed.getScheme()) || "http".equals(parsed.getScheme()) && LOOPBACK.contains(parsed.getHost()));
|
||||
} catch (IllegalArgumentException malformed) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<?> list(Object value) {
|
||||
return value instanceof List<?> list ? list : null;
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package dev.relism.flash.ext.security.oauthserver;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/** One instance's clients, grants and consents, lost on restart. Expired grants are swept whenever one is saved. */
|
||||
public final class InMemoryOAuthStore implements OAuthStore {
|
||||
|
||||
private final Map<String, OAuthClient> clients = new ConcurrentHashMap<>();
|
||||
private final Map<String, OAuthGrant> grants = new ConcurrentHashMap<>();
|
||||
private final Map<String, String> consents = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public OAuthClient client(String id) {
|
||||
return clients.get(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(OAuthClient client) {
|
||||
clients.put(client.id(), client);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(OAuthGrant grant) {
|
||||
Instant now = Instant.now();
|
||||
grants.values().removeIf(g -> g.expiresAt().isBefore(now));
|
||||
grants.put(grant.hash(), grant);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuthGrant find(String hash) {
|
||||
return grants.get(hash);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OAuthGrant use(String hash) {
|
||||
OAuthGrant[] before = new OAuthGrant[1];
|
||||
grants.computeIfPresent(hash, (key, grant) -> {
|
||||
before[0] = grant;
|
||||
return grant.usedAt() == null ? grant.used(Instant.now()) : grant;
|
||||
});
|
||||
return before[0];
|
||||
}
|
||||
|
||||
@Override
|
||||
public void revoke(String family) {
|
||||
grants.values().removeIf(grant -> grant.family().equals(family));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String consent(String subject, String clientId) {
|
||||
return consents.get(subject + " " + clientId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void consent(String subject, String clientId, String scope) {
|
||||
consents.put(subject + " " + clientId, scope);
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package dev.relism.flash.ext.security.oauthserver;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* A client of the authorization server, described by its RFC 7591 metadata exactly as it was registered
|
||||
* (defaults filled in). A confidential client's secret is kept only as a hash; a public one has none.
|
||||
*
|
||||
* @param id either issued at registration, or the https URL of the client's metadata document
|
||||
*/
|
||||
public record OAuthClient(String id, String secretHash, Map<String, Object> metadata) {
|
||||
|
||||
public OAuthClient {
|
||||
metadata = Map.copyOf(metadata);
|
||||
}
|
||||
|
||||
/** {@code none}, {@code client_secret_basic} or {@code client_secret_post}. */
|
||||
public String authMethod() {
|
||||
return (String) metadata.get("token_endpoint_auth_method");
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<String> redirectUris() {
|
||||
return (List<String>) metadata.getOrDefault("redirect_uris", List.of());
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public Set<String> grantTypes() {
|
||||
return Set.copyOf((List<String>) metadata.get("grant_types"));
|
||||
}
|
||||
|
||||
/** What a consent page calls it: its {@code client_name}, or its id when it gave none. */
|
||||
public String name() {
|
||||
return (String) metadata.getOrDefault("client_name", id);
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package dev.relism.flash.ext.security.oauthserver;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* An authorization code or a refresh token, stored under the hash of its value. Every grant descending
|
||||
* from one authorization shares a {@code family}, so a replayed code or a reused refresh token revokes
|
||||
* the whole of it (OAuth 2.1 §4.1.2, §4.3.1).
|
||||
*
|
||||
* @param subject who authorized it, as {@link OAuthSubject#id()}
|
||||
* @param claims carried into every access token issued from it
|
||||
* @param redirectUri the one the authorization request named, which the code exchange must repeat; {@code null} otherwise
|
||||
* @param challenge the PKCE {@code S256} challenge; codes only
|
||||
* @param usedAt when it was exchanged, {@code null} while it is unused
|
||||
*/
|
||||
public record OAuthGrant(String hash, Kind kind, String family, String clientId, String subject, Map<String, Object> claims,
|
||||
String scope, String resource, String redirectUri, String challenge, Instant expiresAt, Instant usedAt) {
|
||||
|
||||
public enum Kind { CODE, REFRESH }
|
||||
|
||||
public OAuthGrant {
|
||||
claims = Map.copyOf(claims);
|
||||
}
|
||||
|
||||
public OAuthGrant used(Instant at) {
|
||||
return new OAuthGrant(hash, kind, family, clientId, subject, claims, scope, resource, redirectUri, challenge, expiresAt, at);
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package dev.relism.flash.ext.security.oauthserver;
|
||||
|
||||
import dev.relism.flash.ext.security.Principal;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* A caller holding an access token this application issued. {@link #name()} is {@code sub}: the
|
||||
* {@link OAuthSubject#id()} the user authorized as, or the client's own id for {@code client_credentials}.
|
||||
*
|
||||
* @param claims the token's verified claims
|
||||
*/
|
||||
public record OAuthPrincipal(String name, String clientId, Map<String, Object> claims) implements Principal {
|
||||
|
||||
public Object claim(String name) {
|
||||
return claims.get(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasScope(String scope) {
|
||||
return claims.get("scope") instanceof String granted && List.of(granted.split(" ")).contains(scope);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasAudience(String audience) {
|
||||
Object aud = claims.get("aud");
|
||||
return aud instanceof Collection<?> list ? list.contains(audience) : audience.equals(aud);
|
||||
}
|
||||
}
|
||||
+633
@@ -0,0 +1,633 @@
|
||||
package dev.relism.flash.ext.security.oauthserver;
|
||||
|
||||
import com.nimbusds.jose.util.JSONObjectUtils;
|
||||
import com.nimbusds.jwt.SignedJWT;
|
||||
import dev.relism.flash.Flash;
|
||||
import dev.relism.flash.exceptions.HttpException;
|
||||
import dev.relism.flash.ext.cache.Cache;
|
||||
import dev.relism.flash.ext.cache.CacheManager;
|
||||
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.SecurityIdentity;
|
||||
import dev.relism.flash.ext.security.SecurityPolicy;
|
||||
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.http.ContentType;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.Response;
|
||||
import dev.relism.flash.models.SimpleHandler;
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
import dev.relism.fpr.core.ByteView;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.net.URLDecoder;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* An OAuth 2.1 authorization server for the application's own users and resources. A user signs in the
|
||||
* way the application's security chain signs anyone in, consents on the application's page, and the
|
||||
* client receives an RFC 9068 access token for one of the application's resources — which this extension,
|
||||
* as an {@link AuthenticationMechanism}, then authenticates.
|
||||
*
|
||||
* <pre>{@code
|
||||
* app.install(new SecurityExtension().origin("https://app.example").loginPage("/login"))
|
||||
* .install(new OAuthServerExtension("/mcp").store(store).signingKeys(keys));
|
||||
* }</pre>
|
||||
*
|
||||
* <p>Authorization code with PKCE {@code S256} (the only grant a user takes part in), refresh tokens that
|
||||
* rotate and revoke their family on reuse, {@code client_credentials} for clients the application registers
|
||||
* itself; RFC 8414 metadata, RFC 7591 registration, client ID metadata documents, RFC 8707 resource
|
||||
* indicators, RFC 7009 revocation, RFC 9207 {@code iss} in the authorization response. The issuer is the
|
||||
* application's {@link SecurityExtension#origin(Request)}.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class OAuthServerExtension implements FlashExtension, AuthenticationMechanism {
|
||||
|
||||
public static final String AUTHORIZE = "/oauth/authorize";
|
||||
public static final String TOKEN = "/oauth/token";
|
||||
public static final String REGISTER = "/oauth/register";
|
||||
public static final String REVOKE = "/oauth/revoke";
|
||||
public static final String JWKS = "/oauth/jwks";
|
||||
public static final String METADATA = "/.well-known/oauth-authorization-server";
|
||||
|
||||
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 static final Duration CODE_LIFETIME = Duration.ofMinutes(1);
|
||||
|
||||
private final List<String> resources;
|
||||
private OAuthStore store = new InMemoryOAuthStore();
|
||||
private SigningKeys keys;
|
||||
private Function<SecurityIdentity, OAuthSubject> subjects = identity -> new OAuthSubject(identity.principal().name(), Map.of());
|
||||
private Set<String> scopes = Set.of();
|
||||
private Duration accessTokenLifetime = Duration.ofMinutes(10);
|
||||
private Duration refreshTokenLifetime = Duration.ofDays(30);
|
||||
private String consentPage = "/consent";
|
||||
private boolean registration = true;
|
||||
private boolean localClients;
|
||||
private Clients clients;
|
||||
private Cache<String, String> metadata;
|
||||
private SecurityExtension security;
|
||||
|
||||
/**
|
||||
* @param resources the paths tokens may be issued for, as RFC 8707 resources on the application's origin;
|
||||
* the first is the audience of a request that names none
|
||||
*/
|
||||
public OAuthServerExtension(String... resources) {
|
||||
if (resources.length == 0) throw new IllegalArgumentException("Name at least one resource path, e.g. /mcp");
|
||||
for (String resource : resources) {
|
||||
if (!resource.startsWith("/")) throw new IllegalArgumentException("A resource is a path on this application: " + resource);
|
||||
}
|
||||
this.resources = List.of(resources);
|
||||
}
|
||||
|
||||
// -- Configuration --------------------------------------------------------
|
||||
|
||||
public OAuthServerExtension store(OAuthStore store) {
|
||||
this.store = store;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* The signing keys, as a JWK set whose first key is a private P-256 key — see {@link #generateSigningKeys()}.
|
||||
* Unset, a key is generated at boot, and every token dies with the process.
|
||||
*/
|
||||
public OAuthServerExtension signingKeys(String jwkSet) {
|
||||
this.keys = new SigningKeys(jwkSet);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whom an authorization names, and what its tokens carry — and who may authorize at all: throw to refuse a
|
||||
* caller. A credential narrower than its user, like an API key, must not become a token with all the user's
|
||||
* rights. Default: the principal's name, no claims, anyone signed in.
|
||||
*/
|
||||
public OAuthServerExtension subjects(Function<SecurityIdentity, OAuthSubject> subjects) {
|
||||
this.subjects = subjects;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** The scopes a client may be granted; any other it asks for is left out of the grant. Default: none. */
|
||||
public OAuthServerExtension scopes(String... scopes) {
|
||||
this.scopes = Set.of(scopes);
|
||||
return this;
|
||||
}
|
||||
|
||||
public OAuthServerExtension accessTokenLifetime(Duration lifetime) {
|
||||
this.accessTokenLifetime = lifetime;
|
||||
return this;
|
||||
}
|
||||
|
||||
public OAuthServerExtension refreshTokenLifetime(Duration lifetime) {
|
||||
this.refreshTokenLifetime = lifetime;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* The application's page asking a signed-in user to allow a client, sent the authorization request as its
|
||||
* query string. It reads {@code GET /oauth/authorize/request} with that same query, and posts it back to
|
||||
* {@code /oauth/authorize} with {@code consent=allow} or {@code consent=deny}. Default {@code /consent}.
|
||||
*/
|
||||
public OAuthServerExtension consentPage(String consentPage) {
|
||||
this.consentPage = consentPage;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Whether any client may register itself at {@link #REGISTER} (RFC 7591). Default {@code true}. */
|
||||
public OAuthServerExtension registration(boolean open) {
|
||||
this.registration = open;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Lets client metadata documents live on http and private addresses — for development, never production. */
|
||||
public OAuthServerExtension allowLocalClients() {
|
||||
this.localClients = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** A new JWK set for {@link #signingKeys(String)}, private key included: generate once, keep it secret. */
|
||||
public static String generateSigningKeys() {
|
||||
return SigningKeys.generate();
|
||||
}
|
||||
|
||||
// -- Clients --------------------------------------------------------------
|
||||
|
||||
/** A registered client, and its secret — shown this once, never stored. {@code null} for a public client. */
|
||||
public record Registration(OAuthClient client, String secret) {}
|
||||
|
||||
/**
|
||||
* Registers a client from its RFC 7591 metadata — what {@link #REGISTER} does for a client registering
|
||||
* itself, and the only way to a {@code client_credentials} client.
|
||||
*/
|
||||
public Registration register(Map<String, Object> metadata) {
|
||||
return register(metadata, true);
|
||||
}
|
||||
|
||||
private Registration register(Map<String, Object> metadata, boolean trusted) {
|
||||
Map<String, Object> valid = Clients.validate(metadata, trusted);
|
||||
String secret = "none".equals(valid.get("token_endpoint_auth_method")) ? null : random(32);
|
||||
OAuthClient client = new OAuthClient(random(16), secret == null ? null : s256(secret), valid);
|
||||
store.save(client);
|
||||
return new Registration(client, secret);
|
||||
}
|
||||
|
||||
// -- Extension ------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
if (keys == null) {
|
||||
keys = new SigningKeys(SigningKeys.generate());
|
||||
log.warn("[flash-ext-security-oauth-server] No signing keys configured: tokens will not survive a restart.");
|
||||
}
|
||||
ctx.provide(OAuthServerExtension.class, this);
|
||||
ctx.onReady(() -> {
|
||||
security = ctx.require(SecurityExtension.class).mechanism(this);
|
||||
CacheManager caches = ctx.require(CacheManager.class);
|
||||
clients = new Clients(store, localClients, caches.build("oauth-server.client-documents", spec -> spec.maxSize(1_000).ttl(Duration.ofMinutes(10))));
|
||||
// Keyed by issuer, which is the request's own origin when none is configured: bounded for that.
|
||||
metadata = caches.build("oauth-server.metadata", spec -> spec.maxSize(16));
|
||||
Middleware signedIn = security.enforce(SecurityPolicy.AUTHENTICATED);
|
||||
Middleware cors = Flash.commons.middlewares.cors(c -> c.methods("GET", "POST", "OPTIONS").headers("Authorization", "Content-Type"));
|
||||
app.get(AUTHORIZE, this::authorize, signedIn);
|
||||
app.post(AUTHORIZE, this::decide, signedIn);
|
||||
app.get(AUTHORIZE + "/request", this::request, signedIn);
|
||||
app.get(METADATA, (req, res) -> json(res, 200, metadata.get(issuer(req), this::describe)), cors);
|
||||
app.get(JWKS, (req, res) -> json(res, 200, keys.publicJwks()), cors);
|
||||
app.post(TOKEN, endpoint(this::token), cors);
|
||||
app.post(REVOKE, endpoint(this::revoke), cors);
|
||||
if (registration) app.post(REGISTER, endpoint(this::registerSelf), cors);
|
||||
for (String path : registration ? List.of(METADATA, JWKS, TOKEN, REVOKE, REGISTER) : List.of(METADATA, JWKS, TOKEN, REVOKE)) {
|
||||
app.options(path, (req, res) -> {
|
||||
res.status(204);
|
||||
return null;
|
||||
}, cors);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -- Bearer access tokens -------------------------------------------------
|
||||
|
||||
/**
|
||||
* Only tokens this server issued are this mechanism's: any other bearer is left to the rest of the chain.
|
||||
* A token is good only under the resource it was issued for (RFC 8707, RFC 9068 §4): one for {@code /mcp}
|
||||
* authenticates nothing outside it.
|
||||
*/
|
||||
@Override
|
||||
public Principal authenticate(Request req) {
|
||||
String header = req.header("Authorization");
|
||||
if (header == null || !header.startsWith("Bearer ")) return null;
|
||||
SignedJWT token;
|
||||
try {
|
||||
token = SignedJWT.parse(header.substring(7));
|
||||
if (!issuer(req).equals(token.getJWTClaimsSet().getIssuer())) return null;
|
||||
} catch (java.text.ParseException notAJwt) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Map<String, Object> claims = keys.verify(token);
|
||||
OAuthPrincipal principal = new OAuthPrincipal((String) claims.get("sub"), (String) claims.get("client_id"), claims);
|
||||
String resource = resourceAt(req.path());
|
||||
if (resource != null && principal.hasAudience(issuer(req) + resource)) return principal;
|
||||
} catch (Exception invalid) {
|
||||
// Refused below, like a token for another resource.
|
||||
}
|
||||
throw INVALID;
|
||||
}
|
||||
|
||||
/** The innermost of this server's resources {@code path} lies in, or {@code null}. */
|
||||
private String resourceAt(String path) {
|
||||
String found = null;
|
||||
for (String resource : resources) {
|
||||
boolean within = resource.equals("/") || path.equals(resource) || path.startsWith(resource + "/");
|
||||
if (within && (found == null || resource.length() > found.length())) found = resource;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Its issuer is {@code "/"}: this application, at whatever origin it is reached by. */
|
||||
@Override
|
||||
public List<SecurityScheme> schemes() {
|
||||
Map<String, Object> flow = Map.of("authorizationUrl", AUTHORIZE, "tokenUrl", TOKEN, "refreshUrl", TOKEN,
|
||||
"scopes", scopes.stream().collect(Collectors.toMap(scope -> scope, scope -> scope)));
|
||||
return List.of(new SecurityScheme("oauth", Map.of("type", "oauth2", "flows", Map.of("authorizationCode", flow)), "Bearer realm=\"oauth\"", "/"));
|
||||
}
|
||||
|
||||
// -- Authorization endpoint -----------------------------------------------
|
||||
|
||||
/** A validated authorization request. */
|
||||
private record Authorization(OAuthClient client, String redirectUri, String requestedRedirect, String state, String scope,
|
||||
String resource, String challenge) {}
|
||||
|
||||
/** An RFC 6749 / RFC 7591 error: its code, what to tell the client, and the status to answer with. */
|
||||
static final class OAuthError extends RuntimeException {
|
||||
final String error;
|
||||
final int status;
|
||||
|
||||
OAuthError(String error, String description) {
|
||||
this(400, error, description);
|
||||
}
|
||||
|
||||
OAuthError(int status, String error, String description) {
|
||||
super(description, null, false, false);
|
||||
this.error = error;
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/** A request whose client and redirect URI are good, and which is refused anyway: answered at the redirect URI. */
|
||||
private static final class Refused extends RuntimeException {
|
||||
final String error;
|
||||
final String redirectUri;
|
||||
final String state;
|
||||
|
||||
Refused(String error, String description, String redirectUri, String state) {
|
||||
super(description, null, false, false);
|
||||
this.error = error;
|
||||
this.redirectUri = redirectUri;
|
||||
this.state = state;
|
||||
}
|
||||
}
|
||||
|
||||
private Object authorize(Request req, Response res) {
|
||||
try {
|
||||
Authorization authorization = authorization(req::query, req);
|
||||
OAuthSubject subject = subjects.apply(SecurityIdentity.current());
|
||||
String consented = store.consent(subject.id(), authorization.client().id());
|
||||
if (consented != null && covers(consented, authorization.scope())) return back(req, res, 302, authorization, subject);
|
||||
res.redirect(consentPage + "?" + query(req));
|
||||
} catch (Refused refused) {
|
||||
refuse(req, res, 302, refused);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The consent page's answer. SameSite=Lax keeps another site's form from carrying the session here. */
|
||||
private Object decide(Request req, Response res) {
|
||||
String origin = req.header("Origin");
|
||||
if (origin != null && !origin.equals(issuer(req))) throw HttpException.forbidden();
|
||||
Map<String, String> form = form(req);
|
||||
try {
|
||||
Authorization authorization = authorization(form::get, req);
|
||||
if (!"allow".equals(form.get("consent"))) {
|
||||
throw new Refused("access_denied", "The user did not allow it", authorization.redirectUri(), authorization.state());
|
||||
}
|
||||
OAuthSubject subject = subjects.apply(SecurityIdentity.current());
|
||||
store.consent(subject.id(), authorization.client().id(), authorization.scope());
|
||||
return back(req, res, 303, authorization, subject);
|
||||
} catch (Refused refused) {
|
||||
refuse(req, res, 303, refused);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** What a consent page shows about the request it was sent. */
|
||||
private Object request(Request req, Response res) {
|
||||
try {
|
||||
Authorization authorization = authorization(req::query, req);
|
||||
Map<String, Object> view = new LinkedHashMap<>();
|
||||
view.put("client_id", authorization.client().id());
|
||||
view.put("client_name", authorization.client().name());
|
||||
for (String key : List.of("client_uri", "logo_uri")) {
|
||||
if (authorization.client().metadata().get(key) != null) view.put(key, authorization.client().metadata().get(key));
|
||||
}
|
||||
view.put("redirect_uri", authorization.redirectUri());
|
||||
view.put("scope", authorization.scope());
|
||||
view.put("resource", authorization.resource());
|
||||
return json(res, 200, JSONObjectUtils.toJSONString(view));
|
||||
} catch (Refused refused) {
|
||||
return json(res, 400, JSONObjectUtils.toJSONString(Map.of("error", refused.error, "error_description", refused.getMessage())));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An unknown client or an unregistered redirect URI is refused here and never redirected (OAuth 2.1
|
||||
* §4.1.2.1): redirecting would hand the response to whoever wrote the URI. Anything else goes back to it.
|
||||
*/
|
||||
private Authorization authorization(Function<String, String> param, Request req) {
|
||||
OAuthClient client = clients.find(param.apply("client_id"));
|
||||
if (client == null) throw HttpException.badRequest("Unknown client");
|
||||
String requested = param.apply("redirect_uri");
|
||||
List<String> registered = client.redirectUris();
|
||||
String redirectUri = requested == null ? registered.size() == 1 ? registered.getFirst() : null
|
||||
: registered.stream().anyMatch(uri -> Clients.matches(uri, requested)) ? requested : null;
|
||||
if (redirectUri == null) throw HttpException.badRequest("Unregistered redirect_uri");
|
||||
String state = param.apply("state");
|
||||
if (!"code".equals(param.apply("response_type"))) throw new Refused("unsupported_response_type", "Only code is supported", redirectUri, state);
|
||||
if (!client.grantTypes().contains("authorization_code")) throw new Refused("unauthorized_client", "Not registered for authorization_code", redirectUri, state);
|
||||
String challenge = param.apply("code_challenge");
|
||||
if (challenge == null || !"S256".equals(param.apply("code_challenge_method"))) {
|
||||
throw new Refused("invalid_request", "PKCE with S256 is required", redirectUri, state);
|
||||
}
|
||||
String resource = resource(param.apply("resource"), req);
|
||||
if (resource == null) throw new Refused("invalid_target", "Unknown resource", redirectUri, state);
|
||||
return new Authorization(client, redirectUri, requested, state, granted(param.apply("scope")), resource, challenge);
|
||||
}
|
||||
|
||||
/** A code for the authorization, delivered at the client's redirect URI. */
|
||||
private Object back(Request req, Response res, int status, Authorization authorization, OAuthSubject subject) {
|
||||
String code = random(32);
|
||||
store.save(new OAuthGrant(s256(code), OAuthGrant.Kind.CODE, random(16), authorization.client().id(), subject.id(), subject.claims(),
|
||||
authorization.scope(), authorization.resource(), authorization.requestedRedirect(), authorization.challenge(),
|
||||
Instant.now().plus(CODE_LIFETIME), null));
|
||||
res.status(status).header("Location", redirect(authorization.redirectUri(), "code", code, authorization.state(), issuer(req)));
|
||||
return null;
|
||||
}
|
||||
|
||||
private void refuse(Request req, Response res, int status, Refused refused) {
|
||||
res.status(status).header("Location", redirect(refused.redirectUri, "error", refused.error, refused.state, issuer(req)));
|
||||
}
|
||||
|
||||
/** RFC 9207: the issuer travels with the response, so a client talking to several servers cannot be mixed up. */
|
||||
private static String redirect(String uri, String key, String value, String state, String issuer) {
|
||||
return uri + (uri.indexOf('?') < 0 ? '?' : '&') + key + "=" + encode(value)
|
||||
+ (state == null ? "" : "&state=" + encode(state)) + "&iss=" + encode(issuer);
|
||||
}
|
||||
|
||||
// -- Token endpoint -------------------------------------------------------
|
||||
|
||||
private Object token(Request req, Response res) {
|
||||
Map<String, String> form = form(req);
|
||||
OAuthClient client = client(req, form);
|
||||
String grantType = form.get("grant_type");
|
||||
if (grantType == null) throw new OAuthError("invalid_request", "grant_type is required");
|
||||
if (!client.grantTypes().contains(grantType)) throw new OAuthError("unauthorized_client", "Not registered for " + grantType);
|
||||
return json(res, 200, switch (grantType) {
|
||||
case "authorization_code" -> exchange(req, client, form);
|
||||
case "refresh_token" -> refresh(req, client, form);
|
||||
case "client_credentials" -> {
|
||||
String resource = resource(form.get("resource"), req);
|
||||
if (resource == null) throw new OAuthError("invalid_target", "Unknown resource");
|
||||
yield issue(req, client, client.id(), Map.of(), granted(form.get("scope")), resource, null);
|
||||
}
|
||||
default -> throw new OAuthError("unsupported_grant_type", "Unsupported grant_type");
|
||||
});
|
||||
}
|
||||
|
||||
private String exchange(Request req, OAuthClient client, Map<String, String> form) {
|
||||
OAuthGrant code = use(form.get("code"), OAuthGrant.Kind.CODE, client);
|
||||
if (code.redirectUri() != null && !code.redirectUri().equals(form.get("redirect_uri"))) throw new OAuthError("invalid_grant", "redirect_uri does not match");
|
||||
String verifier = form.get("code_verifier");
|
||||
if (verifier == null || !MessageDigest.isEqual(s256(verifier).getBytes(StandardCharsets.US_ASCII), code.challenge().getBytes(StandardCharsets.US_ASCII))) {
|
||||
throw new OAuthError("invalid_grant", "code_verifier does not match");
|
||||
}
|
||||
if (form.get("resource") != null && !form.get("resource").equals(code.resource())) throw new OAuthError("invalid_target", "Not the resource authorized");
|
||||
return issue(req, client, code.subject(), code.claims(), code.scope(), code.resource(), code.family());
|
||||
}
|
||||
|
||||
/** Rotates: the token used is spent, and its successor carries the same authorization — or less of it. */
|
||||
private String refresh(Request req, OAuthClient client, Map<String, String> form) {
|
||||
OAuthGrant refresh = use(form.get("refresh_token"), OAuthGrant.Kind.REFRESH, client);
|
||||
String scope = form.get("scope") == null ? refresh.scope() : form.get("scope");
|
||||
if (!covers(refresh.scope(), scope)) throw new OAuthError("invalid_scope", "More than was authorized");
|
||||
if (form.get("resource") != null && !form.get("resource").equals(refresh.resource())) throw new OAuthError("invalid_target", "Not the resource authorized");
|
||||
return issue(req, client, refresh.subject(), refresh.claims(), scope, refresh.resource(), refresh.family());
|
||||
}
|
||||
|
||||
/** A code or refresh token spent now. One already spent revokes everything issued from the same authorization. */
|
||||
private OAuthGrant use(String value, OAuthGrant.Kind kind, OAuthClient client) {
|
||||
OAuthGrant grant = value == null ? null : store.use(s256(value));
|
||||
if (grant == null || grant.kind() != kind || !grant.clientId().equals(client.id())) throw new OAuthError("invalid_grant", "Unknown " + kind.name().toLowerCase());
|
||||
if (grant.usedAt() != null) {
|
||||
store.revoke(grant.family());
|
||||
throw new OAuthError("invalid_grant", "Already used: every token from this authorization is revoked");
|
||||
}
|
||||
if (grant.expiresAt().isBefore(Instant.now())) throw new OAuthError("invalid_grant", "Expired");
|
||||
return grant;
|
||||
}
|
||||
|
||||
/** @param family {@code null} for {@code client_credentials}, which gets no refresh token */
|
||||
private String issue(Request req, OAuthClient client, String subject, Map<String, Object> extra, String scope, String resource, String family) {
|
||||
Instant now = Instant.now();
|
||||
Map<String, Object> claims = new HashMap<>(extra);
|
||||
claims.putAll(Map.of("iss", issuer(req), "sub", subject, "aud", resource, "client_id", client.id(), "jti", random(16),
|
||||
"iat", Date.from(now), "exp", Date.from(now.plus(accessTokenLifetime))));
|
||||
if (!scope.isEmpty()) claims.put("scope", scope);
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("access_token", keys.sign(claims));
|
||||
response.put("token_type", "Bearer");
|
||||
response.put("expires_in", accessTokenLifetime.toSeconds());
|
||||
if (!scope.isEmpty()) response.put("scope", scope);
|
||||
if (family != null && client.grantTypes().contains("refresh_token")) {
|
||||
String refresh = random(32);
|
||||
store.save(new OAuthGrant(s256(refresh), OAuthGrant.Kind.REFRESH, family, client.id(), subject, extra, scope, resource,
|
||||
null, null, now.plus(refreshTokenLifetime), null));
|
||||
response.put("refresh_token", refresh);
|
||||
}
|
||||
return JSONObjectUtils.toJSONString(response);
|
||||
}
|
||||
|
||||
// -- Revocation and registration ------------------------------------------
|
||||
|
||||
/** RFC 7009: a refresh token takes its whole authorization with it. An access token simply expires; an unknown token is no error. */
|
||||
private Object revoke(Request req, Response res) {
|
||||
Map<String, String> form = form(req);
|
||||
OAuthClient client = client(req, form);
|
||||
OAuthGrant grant = form.get("token") == null ? null : store.find(s256(form.get("token")));
|
||||
if (grant != null && !grant.clientId().equals(client.id())) throw new OAuthError("unauthorized_client", "Not this client's token");
|
||||
if (grant != null) store.revoke(grant.family());
|
||||
res.status(200);
|
||||
return null;
|
||||
}
|
||||
|
||||
private Object registerSelf(Request req, Response res) {
|
||||
Map<String, Object> metadata;
|
||||
try {
|
||||
metadata = JSONObjectUtils.parse(new String(req.body().bytes(), StandardCharsets.UTF_8));
|
||||
} catch (java.text.ParseException malformed) {
|
||||
throw new OAuthError("invalid_client_metadata", "Not a JSON object");
|
||||
}
|
||||
Registration registration = register(metadata, false);
|
||||
Map<String, Object> response = new LinkedHashMap<>(registration.client().metadata());
|
||||
response.put("client_id", registration.client().id());
|
||||
response.put("client_id_issued_at", Instant.now().getEpochSecond());
|
||||
if (registration.secret() != null) {
|
||||
response.put("client_secret", registration.secret());
|
||||
response.put("client_secret_expires_at", 0);
|
||||
}
|
||||
return json(res, 201, JSONObjectUtils.toJSONString(response));
|
||||
}
|
||||
|
||||
// -- Metadata ---------------------------------------------------------------
|
||||
|
||||
/** RFC 8414, plus what the MCP authorization spec asks a server to say about client ID metadata documents. */
|
||||
private String describe(String issuer) {
|
||||
Map<String, Object> metadata = new LinkedHashMap<>();
|
||||
metadata.put("issuer", issuer);
|
||||
metadata.put("authorization_endpoint", issuer + AUTHORIZE);
|
||||
metadata.put("token_endpoint", issuer + TOKEN);
|
||||
if (registration) metadata.put("registration_endpoint", issuer + REGISTER);
|
||||
metadata.put("revocation_endpoint", issuer + REVOKE);
|
||||
metadata.put("jwks_uri", issuer + JWKS);
|
||||
if (!scopes.isEmpty()) metadata.put("scopes_supported", List.copyOf(scopes));
|
||||
metadata.put("response_types_supported", List.of("code"));
|
||||
metadata.put("response_modes_supported", List.of("query"));
|
||||
metadata.put("grant_types_supported", List.of("authorization_code", "refresh_token", "client_credentials"));
|
||||
metadata.put("code_challenge_methods_supported", List.of("S256"));
|
||||
metadata.put("token_endpoint_auth_methods_supported", Clients.AUTH_METHODS);
|
||||
metadata.put("revocation_endpoint_auth_methods_supported", Clients.AUTH_METHODS);
|
||||
metadata.put("authorization_response_iss_parameter_supported", true);
|
||||
metadata.put("client_id_metadata_document_supported", true);
|
||||
return JSONObjectUtils.toJSONString(metadata);
|
||||
}
|
||||
|
||||
// -- Helpers ----------------------------------------------------------------
|
||||
|
||||
/** RFC 6749 §5.2: every failure is a JSON error with its code; nothing here is ever cached. */
|
||||
private static SimpleHandler.FunctionalHandler endpoint(SimpleHandler.FunctionalHandler handler) {
|
||||
return (req, res) -> {
|
||||
res.header("Cache-Control", "no-store");
|
||||
try {
|
||||
return handler.handle(req, res);
|
||||
} catch (OAuthError invalid) {
|
||||
if (invalid.status == 401) res.header("WWW-Authenticate", "Basic realm=\"oauth\"");
|
||||
return json(res, invalid.status, JSONObjectUtils.toJSONString(Map.of("error", invalid.error, "error_description", invalid.getMessage())));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The client making a token or revocation request, authenticated as it registered: a secret in the
|
||||
* {@code Authorization} header, a secret in the form, or — a public client — its id alone.
|
||||
*/
|
||||
private OAuthClient client(Request req, Map<String, String> form) {
|
||||
String header = req.header("Authorization");
|
||||
String id = form.get("client_id");
|
||||
String secret = form.get("client_secret");
|
||||
String method = secret != null ? "client_secret_post" : "none";
|
||||
if (header != null && header.startsWith("Basic ")) {
|
||||
String[] pair = new String(Base64.getDecoder().decode(header.substring(6)), StandardCharsets.UTF_8).split(":", 2);
|
||||
if (pair.length != 2) throw new OAuthError(401, "invalid_client", "Malformed Basic credentials");
|
||||
id = URLDecoder.decode(pair[0], StandardCharsets.UTF_8);
|
||||
secret = URLDecoder.decode(pair[1], StandardCharsets.UTF_8);
|
||||
method = "client_secret_basic";
|
||||
}
|
||||
OAuthClient client = clients.find(id);
|
||||
boolean authenticated = client != null && client.authMethod().equals(method)
|
||||
&& (method.equals("none") || MessageDigest.isEqual(s256(secret).getBytes(StandardCharsets.US_ASCII), client.secretHash().getBytes(StandardCharsets.US_ASCII)));
|
||||
if (!authenticated) throw new OAuthError(401, "invalid_client", "Client authentication failed");
|
||||
return client;
|
||||
}
|
||||
|
||||
/** RFC 8707: one of this application's resources, or the first when the request names none. */
|
||||
private String resource(String requested, Request req) {
|
||||
String origin = issuer(req);
|
||||
if (requested == null) return origin + resources.getFirst();
|
||||
return resources.stream().map(path -> origin + path).filter(requested::equals).findFirst().orElse(null);
|
||||
}
|
||||
|
||||
/** What was asked for, less what this server does not grant. */
|
||||
private String granted(String requested) {
|
||||
if (requested == null) return "";
|
||||
return Arrays.stream(requested.split(" ")).filter(scopes::contains).distinct().collect(Collectors.joining(" "));
|
||||
}
|
||||
|
||||
private static boolean covers(String granted, String requested) {
|
||||
return List.of(granted.split(" ")).containsAll(List.of(requested.split(" ")));
|
||||
}
|
||||
|
||||
private String issuer(Request req) {
|
||||
return security.origin(req);
|
||||
}
|
||||
|
||||
private static Object json(Response res, int status, String body) {
|
||||
res.status(status).type(ContentType.JSON);
|
||||
return body;
|
||||
}
|
||||
|
||||
private static Map<String, String> form(Request req) {
|
||||
Map<String, String> fields = new HashMap<>();
|
||||
for (String pair : new String(req.body().bytes(), StandardCharsets.UTF_8).split("&")) {
|
||||
int eq = pair.indexOf('=');
|
||||
if (eq > 0) fields.putIfAbsent(URLDecoder.decode(pair.substring(0, eq), StandardCharsets.UTF_8), URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8));
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
private static String query(Request req) {
|
||||
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);
|
||||
return new String(raw, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
/**
|
||||
* PKCE's {@code S256}, and how codes, refresh tokens and client secrets are stored: they are 256 random
|
||||
* bits, so a slow KDF would protect nothing.
|
||||
*/
|
||||
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 random(int bytes) {
|
||||
byte[] value = new byte[bytes];
|
||||
RANDOM.nextBytes(value);
|
||||
return BASE64URL.encodeToString(value);
|
||||
}
|
||||
|
||||
private static String encode(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package dev.relism.flash.ext.security.oauthserver;
|
||||
|
||||
/** Where the authorization server keeps its clients, grants and consents. {@link InMemoryOAuthStore} unless one that survives a restart is configured. */
|
||||
public interface OAuthStore {
|
||||
|
||||
/** The client registered under {@code id}, or {@code null}. */
|
||||
OAuthClient client(String id);
|
||||
|
||||
void save(OAuthClient client);
|
||||
|
||||
void save(OAuthGrant grant);
|
||||
|
||||
/** The grant stored under {@code hash}, or {@code null}. */
|
||||
OAuthGrant find(String hash);
|
||||
|
||||
/**
|
||||
* Marks the grant used and returns it as it was before: a non-null {@link OAuthGrant#usedAt()} means it
|
||||
* had been used already. {@code null} for an unknown hash. Atomic — two concurrent uses must not both
|
||||
* see it unused.
|
||||
*/
|
||||
OAuthGrant use(String hash);
|
||||
|
||||
/** Every grant of {@code family} stops working. */
|
||||
void revoke(String family);
|
||||
|
||||
/** The scope {@code subject} granted {@code clientId} at its last consent, or {@code null} for none. */
|
||||
String consent(String subject, String clientId);
|
||||
|
||||
void consent(String subject, String clientId, String scope);
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package dev.relism.flash.ext.security.oauthserver;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Whom an authorization is for, as the application names them: {@code id} becomes the access token's
|
||||
* {@code sub}, {@code claims} travel in every token issued from it. Standard claims win over these.
|
||||
*/
|
||||
public record OAuthSubject(String id, Map<String, Object> claims) {
|
||||
|
||||
public OAuthSubject {
|
||||
claims = Map.copyOf(claims);
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package dev.relism.flash.ext.security.oauthserver;
|
||||
|
||||
import com.nimbusds.jose.JOSEObjectType;
|
||||
import com.nimbusds.jose.JWSAlgorithm;
|
||||
import com.nimbusds.jose.JWSHeader;
|
||||
import com.nimbusds.jose.crypto.ECDSASigner;
|
||||
import com.nimbusds.jose.jwk.Curve;
|
||||
import com.nimbusds.jose.jwk.ECKey;
|
||||
import com.nimbusds.jose.jwk.JWKSet;
|
||||
import com.nimbusds.jose.jwk.KeyUse;
|
||||
import com.nimbusds.jose.jwk.gen.ECKeyGenerator;
|
||||
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
|
||||
import com.nimbusds.jose.proc.DefaultJOSEObjectTypeVerifier;
|
||||
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
|
||||
import com.nimbusds.jose.proc.SecurityContext;
|
||||
import com.nimbusds.jwt.JWTClaimsSet;
|
||||
import com.nimbusds.jwt.SignedJWT;
|
||||
import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier;
|
||||
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* The server's ES256 keys: the first signs, all verify, so a key is rotated by putting its successor first
|
||||
* and dropping it once the tokens it signed have expired. Access tokens are RFC 9068 JWTs, {@code typ} {@code at+jwt}.
|
||||
*/
|
||||
final class SigningKeys {
|
||||
|
||||
private final ECKey signing;
|
||||
private final String publicJwks;
|
||||
private final DefaultJWTProcessor<SecurityContext> verifier = new DefaultJWTProcessor<>();
|
||||
|
||||
SigningKeys(String jwkSet) {
|
||||
try {
|
||||
JWKSet keys = JWKSet.parse(jwkSet);
|
||||
signing = keys.getKeys().getFirst().toECKey();
|
||||
if (!signing.isPrivate() || !Curve.P_256.equals(signing.getCurve())) throw new IllegalArgumentException("The first key must be a private P-256 key");
|
||||
publicJwks = keys.toPublicJWKSet().toString();
|
||||
verifier.setJWSTypeVerifier(new DefaultJOSEObjectTypeVerifier<>(new JOSEObjectType("at+jwt")));
|
||||
verifier.setJWSKeySelector(new JWSVerificationKeySelector<>(JWSAlgorithm.ES256, new ImmutableJWKSet<>(keys.toPublicJWKSet())));
|
||||
verifier.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(null, Set.of("iss", "sub", "aud", "exp", "client_id")));
|
||||
} catch (java.text.ParseException | ClassCastException invalid) {
|
||||
throw new IllegalArgumentException("Not a JWK set with a private EC key first", invalid);
|
||||
}
|
||||
}
|
||||
|
||||
/** A JWK set holding one new private P-256 key — what {@link OAuthServerExtension#signingKeys(String)} takes. */
|
||||
static String generate() {
|
||||
try {
|
||||
return new JWKSet(new ECKeyGenerator(Curve.P_256).keyUse(KeyUse.SIGNATURE).keyID(UUID.randomUUID().toString()).generate()).toString(false);
|
||||
} catch (com.nimbusds.jose.JOSEException impossible) {
|
||||
throw new IllegalStateException(impossible);
|
||||
}
|
||||
}
|
||||
|
||||
String sign(Map<String, Object> claims) {
|
||||
try {
|
||||
JWTClaimsSet.Builder set = new JWTClaimsSet.Builder();
|
||||
claims.forEach(set::claim);
|
||||
SignedJWT jwt = new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.ES256).type(new JOSEObjectType("at+jwt")).keyID(signing.getKeyID()).build(), set.build());
|
||||
jwt.sign(new ECDSASigner(signing));
|
||||
return jwt.serialize();
|
||||
} catch (com.nimbusds.jose.JOSEException impossible) {
|
||||
throw new IllegalStateException(impossible);
|
||||
}
|
||||
}
|
||||
|
||||
/** The token's claims, once its type, signature, expiry and required claims check out. */
|
||||
Map<String, Object> verify(SignedJWT token) throws Exception {
|
||||
return verifier.process(token, null).getClaims();
|
||||
}
|
||||
|
||||
String publicJwks() {
|
||||
return publicJwks;
|
||||
}
|
||||
}
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
package dev.relism.flash.ext.security.oauthserver;
|
||||
|
||||
import com.nimbusds.jose.util.JSONObjectUtils;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import dev.relism.flash.exceptions.HttpException;
|
||||
import dev.relism.flash.ext.cache.caffeine.CaffeineCacheExtension;
|
||||
import dev.relism.flash.ext.mcp.McpConfig;
|
||||
import dev.relism.flash.ext.mcp.McpExtension;
|
||||
import dev.relism.flash.ext.security.Principal;
|
||||
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.ext.security.test.OAuthTestClient;
|
||||
import dev.relism.flash.ext.security.test.TestSecurity;
|
||||
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;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class OAuthServerExtensionTest {
|
||||
|
||||
static final Principal ALICE = () -> "alice";
|
||||
static final SecurityExtension security = new SecurityExtension().loginPage("/login");
|
||||
static final OAuthServerExtension server = new OAuthServerExtension("/mcp", "/api").scopes("read", "write").allowLocalClients()
|
||||
.subjects(identity -> new OAuthSubject(identity.principal().name(), Map.of("tenant", "acme")));
|
||||
|
||||
/** /api only takes this server's tokens; /mcp is an OAuth-protected MCP endpoint trusting nothing else. */
|
||||
@RegisterExtension
|
||||
static final FlashTest app = FlashTest.of(flash -> flash
|
||||
.install(security).install(new CaffeineCacheExtension()).install(server).install(new TestSecurity())
|
||||
.install(new McpExtension(McpConfig.builder("test").toolsPackage("dev.relism.flash.ext.security.oauthserver.tools").mechanisms(server).build()))
|
||||
.get("/api/me", (req, res) -> {
|
||||
OAuthPrincipal principal = SecurityIdentity.current().principal(OAuthPrincipal.class);
|
||||
return principal.name() + " " + principal.claim("tenant") + " " + principal.claim("scope");
|
||||
}, security.enforce(SecurityPolicy.AUTHENTICATED, (req, res) -> { throw HttpException.unauthorized(); }, List.of(server))));
|
||||
|
||||
String origin() {
|
||||
return "http://127.0.0.1:" + app.port();
|
||||
}
|
||||
|
||||
@Test
|
||||
void theMetadataDescribesACompliantServer() {
|
||||
app.get("/.well-known/oauth-authorization-server").expectStatus(200)
|
||||
.expectBodyContains("\"issuer\":\"" + origin() + "\"")
|
||||
.expectBodyContains("\"code_challenge_methods_supported\":[\"S256\"]")
|
||||
.expectBodyContains("\"authorization_response_iss_parameter_supported\":true")
|
||||
.expectBodyContains("\"client_id_metadata_document_supported\":true")
|
||||
.expectHeader("Access-Control-Allow-Origin", "*");
|
||||
app.get("/oauth/jwks").expectStatus(200).expectBodyContains("\"crv\":\"P-256\"");
|
||||
assertTrue(!app.get("/oauth/jwks").body().contains("\"d\""), "the private key never leaves");
|
||||
}
|
||||
|
||||
/** What an MCP client does end to end: discovery from the resource, registration, consent, and a token for this endpoint only. */
|
||||
@Test
|
||||
void anMcpClientIsSentHereAndItsTokenWorksOnTheEndpoint() {
|
||||
app.get("/.well-known/oauth-protected-resource/mcp").expectStatus(200)
|
||||
.expectBody("{\"resource\":\"" + origin() + "/mcp\",\"authorization_servers\":[\"" + origin() + "\"]}");
|
||||
|
||||
OAuthTestClient.Tokens tokens = OAuthTestClient.register(app).authorize(ALICE);
|
||||
app.request().with(tokens.bearer()).header("Accept", "application/json")
|
||||
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"whoami\"}}")
|
||||
.post("/mcp").expectStatus(200).expectBodyContains("alice");
|
||||
// The default resource is /mcp: a token is good under the resource it was issued for and nowhere else.
|
||||
app.request().with(tokens.bearer()).get("/api/me").expectStatus(401);
|
||||
OAuthTestClient.Tokens api = OAuthTestClient.register(app).authorize(ALICE, Map.of("resource", origin() + "/api"));
|
||||
app.request().with(api.bearer()).get("/api/me").expectStatus(200);
|
||||
app.request().with(api.bearer()).header("Accept", "application/json")
|
||||
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}").post("/mcp").expectStatus(401);
|
||||
}
|
||||
|
||||
@Test
|
||||
void consentIsAskedOnceAndTheSubjectsClaimsTravel() {
|
||||
OAuthTestClient client = OAuthTestClient.register(app);
|
||||
OAuthTestClient.Tokens tokens = client.authorize(ALICE, Map.of("scope", "read admin", "resource", origin() + "/api"));
|
||||
assertEquals("read", tokens.scope(), "a scope this server does not grant is left out");
|
||||
app.request().with(tokens.bearer()).get("/api/me").expectStatus(200).expectBody("alice acme read");
|
||||
|
||||
String authorize = "/oauth/authorize?response_type=code&client_id=" + client.clientId() + "&code_challenge=x&code_challenge_method=S256&scope=read";
|
||||
assertTrue(app.request().with(TestSecurity.as(ALICE)).get(authorize).expectStatus(302).header("Location").startsWith(OAuthTestClient.REDIRECT_URI));
|
||||
assertTrue(app.request().with(TestSecurity.as(ALICE)).get(authorize + "%20write").expectStatus(302).header("Location").startsWith("/consent?"),
|
||||
"more than was consented to is asked again");
|
||||
app.request().with(TestSecurity.as(ALICE)).get("/oauth/authorize/request?" + authorize.substring(authorize.indexOf('?') + 1))
|
||||
.expectStatus(200).expectBodyContains("\"client_name\":\"Test client\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aBrowserWithoutASessionSignsInFirst() {
|
||||
app.request().header("Accept", "text/html").get("/oauth/authorize?client_id=x").expectStatus(302)
|
||||
.expectHeader("Location", "/login?redirect=%2Foauth%2Fauthorize%3Fclient_id%3Dx");
|
||||
}
|
||||
|
||||
/** OAuth 2.1 §4.1.2.1: an unknown client or redirect URI is never redirected to; anything else is reported there. */
|
||||
@Test
|
||||
void refusalsGoToTheRedirectUriOnlyOnceItIsTrusted() {
|
||||
String client = OAuthTestClient.register(app).clientId();
|
||||
app.request().with(TestSecurity.as(ALICE)).get("/oauth/authorize?response_type=code&client_id=nobody").expectStatus(400);
|
||||
app.request().with(TestSecurity.as(ALICE)).get("/oauth/authorize?response_type=code&client_id=" + client + "&redirect_uri=https%3A%2F%2Fevil.example%2F")
|
||||
.expectStatus(400);
|
||||
String plain = app.request().with(TestSecurity.as(ALICE))
|
||||
.get("/oauth/authorize?response_type=code&client_id=" + client + "&state=s&code_challenge=x&code_challenge_method=plain")
|
||||
.expectStatus(302).header("Location");
|
||||
assertEquals(OAuthTestClient.REDIRECT_URI + "?error=invalid_request&state=s&iss=" + encode(origin()), plain);
|
||||
String elsewhere = app.request().with(TestSecurity.as(ALICE))
|
||||
.get("/oauth/authorize?response_type=code&client_id=" + client + "&code_challenge=x&code_challenge_method=S256&resource=https%3A%2F%2Fother.example%2Fmcp")
|
||||
.expectStatus(302).header("Location");
|
||||
assertTrue(elsewhere.contains("error=invalid_target"), elsewhere);
|
||||
// RFC 8252: a loopback redirect may come back on any port.
|
||||
assertTrue(app.request().with(TestSecurity.as(ALICE))
|
||||
.get("/oauth/authorize?response_type=code&client_id=" + client + "&redirect_uri=http%3A%2F%2F127.0.0.1%3A53123%2Fcallback&code_challenge=x&code_challenge_method=S256")
|
||||
.expectStatus(302).header("Location").startsWith("/consent?"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aDeniedConsentAndAForeignFormAreRefused() {
|
||||
String client = OAuthTestClient.register(app).clientId();
|
||||
String query = "response_type=code&client_id=" + client + "&code_challenge=x&code_challenge_method=S256";
|
||||
app.request().with(TestSecurity.as(ALICE)).header("Content-Type", "application/x-www-form-urlencoded").body(query + "&consent=deny")
|
||||
.post("/oauth/authorize").expectStatus(303).expectHeader("Location", OAuthTestClient.REDIRECT_URI + "?error=access_denied&iss=" + encode(origin()));
|
||||
app.request().with(TestSecurity.as(ALICE)).header("Origin", "https://evil.example").header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.body(query + "&consent=allow").post("/oauth/authorize").expectStatus(403);
|
||||
}
|
||||
|
||||
/** A refresh token is spent by its use; spending it twice means it leaked, and the whole authorization goes. */
|
||||
@Test
|
||||
void refreshTokensRotateAndAReuseRevokesTheFamily() {
|
||||
OAuthTestClient client = OAuthTestClient.register(app);
|
||||
OAuthTestClient.Tokens first = client.authorize(ALICE);
|
||||
OAuthTestClient.Tokens second = client.refresh(first.refreshToken());
|
||||
assertNotEquals(first.refreshToken(), second.refreshToken());
|
||||
|
||||
token("grant_type=refresh_token&client_id=" + client.clientId() + "&refresh_token=" + first.refreshToken())
|
||||
.expectStatus(400).expectBodyContains("invalid_grant");
|
||||
token("grant_type=refresh_token&client_id=" + client.clientId() + "&refresh_token=" + second.refreshToken())
|
||||
.expectStatus(400).expectBodyContains("invalid_grant");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aRevokedRefreshTokenStopsWorking() {
|
||||
OAuthTestClient client = OAuthTestClient.register(app);
|
||||
OAuthTestClient.Tokens tokens = client.authorize(ALICE);
|
||||
app.request().header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.body("client_id=" + client.clientId() + "&token=" + tokens.refreshToken()).post("/oauth/revoke").expectStatus(200);
|
||||
token("grant_type=refresh_token&client_id=" + client.clientId() + "&refresh_token=" + tokens.refreshToken()).expectStatus(400);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aClientRegisteringItselfIsHeldToTheRules() {
|
||||
register("{\"redirect_uris\":[\"http://evil.example/cb\"]}").expectStatus(400).expectBodyContains("invalid_redirect_uri");
|
||||
register("{\"redirect_uris\":[\"https://app.example/cb#x\"]}").expectStatus(400).expectBodyContains("invalid_redirect_uri");
|
||||
register("{\"grant_types\":[\"client_credentials\"],\"token_endpoint_auth_method\":\"client_secret_basic\"}")
|
||||
.expectStatus(400).expectBodyContains("invalid_client_metadata");
|
||||
register("{\"redirect_uris\":[\"https://app.example/cb\"]}").expectStatus(201)
|
||||
.expectBodyContains("\"token_endpoint_auth_method\":\"client_secret_basic\"").expectBodyContains("\"client_secret\"");
|
||||
}
|
||||
|
||||
/** A machine the application registered itself: its secret in Basic, its own id as the subject, no refresh token. */
|
||||
@Test
|
||||
void aConfidentialClientGetsATokenForItself() throws Exception {
|
||||
OAuthServerExtension.Registration machine = server.register(Map.of("client_name", "Sync job", "grant_types", List.of("client_credentials"),
|
||||
"token_endpoint_auth_method", "client_secret_basic"));
|
||||
String basic = java.util.Base64.getEncoder().encodeToString((machine.client().id() + ":" + machine.secret()).getBytes(StandardCharsets.UTF_8));
|
||||
Map<String, Object> response = JSONObjectUtils.parse(app.request().header("Authorization", "Basic " + basic)
|
||||
.header("Content-Type", "application/x-www-form-urlencoded").body("grant_type=client_credentials&scope=write&resource=" + encode(origin() + "/api"))
|
||||
.post("/oauth/token").expectStatus(200).expectHeader("Cache-Control", "no-store").body());
|
||||
assertNull(response.get("refresh_token"));
|
||||
app.request().header("Authorization", "Bearer " + response.get("access_token")).get("/api/me").expectStatus(200)
|
||||
.expectBody(machine.client().id() + " null write");
|
||||
|
||||
String wrong = java.util.Base64.getEncoder().encodeToString((machine.client().id() + ":nope").getBytes(StandardCharsets.UTF_8));
|
||||
app.request().header("Authorization", "Basic " + wrong).header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.body("grant_type=client_credentials").post("/oauth/token").expectStatus(401).expectBodyContains("invalid_client");
|
||||
}
|
||||
|
||||
/** A code is good once, with its own verifier; a replay revokes what the first exchange issued. */
|
||||
@Test
|
||||
void aCodeNeedsItsVerifierAndCannotBeReplayed() {
|
||||
OAuthTestClient client = OAuthTestClient.register(app);
|
||||
client.authorize(ALICE);
|
||||
String authorize = "/oauth/authorize?response_type=code&client_id=" + client.clientId() + "&code_challenge=" + s256("right") + "&code_challenge_method=S256";
|
||||
String location = app.request().with(TestSecurity.as(ALICE)).get(authorize).expectStatus(302).header("Location");
|
||||
String code = location.replaceAll(".*code=([^&]+).*", "$1");
|
||||
String exchange = "grant_type=authorization_code&client_id=" + client.clientId() + "&code=" + code + "&code_verifier=";
|
||||
|
||||
token(exchange + "wrong").expectStatus(400).expectBodyContains("invalid_grant");
|
||||
String code2 = app.request().with(TestSecurity.as(ALICE)).get(authorize).expectStatus(302).header("Location").replaceAll(".*code=([^&]+).*", "$1");
|
||||
String issued = token("grant_type=authorization_code&client_id=" + client.clientId() + "&code=" + code2 + "&code_verifier=right").expectStatus(200).body();
|
||||
String refresh = issued.replaceAll(".*\"refresh_token\":\"([^\"]+)\".*", "$1");
|
||||
token("grant_type=authorization_code&client_id=" + client.clientId() + "&code=" + code2 + "&code_verifier=right").expectStatus(400);
|
||||
token("grant_type=refresh_token&client_id=" + client.clientId() + "&refresh_token=" + refresh).expectStatus(400);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aForgedTokenIsInvalidAndOtherBearersAreNotThisServers() {
|
||||
OAuthTestClient.Tokens tokens = OAuthTestClient.register(app).authorize(ALICE, Map.of("resource", origin() + "/api"));
|
||||
String forged = tokens.accessToken().substring(0, tokens.accessToken().length() - 4) + "AAAA";
|
||||
app.request().header("Authorization", "Bearer " + forged).get("/api/me").expectStatus(401);
|
||||
app.request().header("Authorization", "Bearer gk_not.a-jwt").get("/api/me").expectStatus(401);
|
||||
}
|
||||
|
||||
/** MCP's preferred registration: the client is the https URL of its own metadata document. */
|
||||
@Test
|
||||
void aClientMetadataDocumentIsAClient() throws Exception {
|
||||
HttpServer host = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
String url = "http://127.0.0.1:" + host.getAddress().getPort() + "/client.json";
|
||||
String liar = "http://127.0.0.1:" + host.getAddress().getPort() + "/liar.json";
|
||||
serve(host, "/client.json", "{\"client_id\":\"" + url + "\",\"client_name\":\"Documented\",\"redirect_uris\":[\"" + OAuthTestClient.REDIRECT_URI + "\"]}");
|
||||
serve(host, "/liar.json", "{\"client_id\":\"https://someone.else/client.json\",\"redirect_uris\":[\"" + OAuthTestClient.REDIRECT_URI + "\"]}");
|
||||
host.start();
|
||||
try {
|
||||
String query = "response_type=code&code_challenge=x&code_challenge_method=S256&client_id=";
|
||||
app.request().with(TestSecurity.as(ALICE)).get("/oauth/authorize/request?" + query + encode(url)).expectStatus(200)
|
||||
.expectBodyContains("\"client_name\":\"Documented\"");
|
||||
app.request().with(TestSecurity.as(ALICE)).get("/oauth/authorize?" + query + encode(liar)).expectStatus(400);
|
||||
// A document that fails is remembered as failing: naming it again fetches nothing.
|
||||
app.request().with(TestSecurity.as(ALICE)).get("/oauth/authorize?" + query + encode(liar)).expectStatus(400);
|
||||
assertEquals(1, fetched.get(liar));
|
||||
} finally {
|
||||
host.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
private static FlashResponse token(String form) {
|
||||
return app.request().header("Content-Type", "application/x-www-form-urlencoded").body(form).post("/oauth/token");
|
||||
}
|
||||
|
||||
private static FlashResponse register(String json) {
|
||||
return app.request().header("Content-Type", "application/json").body(json).post("/oauth/register");
|
||||
}
|
||||
|
||||
static final Map<String, Integer> fetched = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
|
||||
private static void serve(HttpServer host, String path, String body) {
|
||||
host.createContext(path, exchange -> {
|
||||
fetched.merge("http://127.0.0.1:" + host.getAddress().getPort() + path, 1, Integer::sum);
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(200, bytes.length);
|
||||
try (var out = exchange.getResponseBody()) {
|
||||
out.write(bytes);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static String s256(String verifier) {
|
||||
try {
|
||||
return java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(
|
||||
java.security.MessageDigest.getInstance("SHA-256").digest(verifier.getBytes(StandardCharsets.US_ASCII)));
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String encode(String value) {
|
||||
return java.net.URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package dev.relism.flash.ext.security.oauthserver.tools;
|
||||
|
||||
import dev.relism.flash.ext.mcp.McpTool;
|
||||
import dev.relism.flash.ext.mcp.TextContent;
|
||||
import dev.relism.flash.ext.mcp.Tool;
|
||||
import dev.relism.flash.ext.mcp.ToolArguments;
|
||||
import dev.relism.flash.ext.mcp.ToolResponse;
|
||||
import dev.relism.flash.ext.security.SecurityIdentity;
|
||||
|
||||
@Tool(name = "whoami", description = "The caller's name")
|
||||
public class WhoAmITool extends McpTool {
|
||||
@Override
|
||||
public ToolResponse call(ToolArguments args) {
|
||||
return ToolResponse.success(new TextContent(SecurityIdentity.current().principal().name()));
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,13 @@ Discovery runs at boot, so an unreachable provider fails the start rather than t
|
||||
## Bearer tokens
|
||||
|
||||
`Authorization: Bearer <jwt>` is matched to its provider by `iss`, then verified against that
|
||||
provider's keys (RS/PS/ES algorithms, `typ` `JWT` or `at+jwt`, `iss`, `sub`, `exp`). One parse, one map
|
||||
lookup, however many providers are configured. A token from an unconfigured issuer is left to other
|
||||
mechanisms; a token from a configured one that fails verification is `401 invalid_token`.
|
||||
provider's keys (RS/PS/ES algorithms, `iss`, `sub`, `exp`). One parse, one map lookup, however many
|
||||
providers are configured. A token from an unconfigured issuer is left to other mechanisms; a token from
|
||||
a configured one that fails verification is `401 invalid_token`.
|
||||
|
||||
Only an access token is a bearer credential, and RFC 9068 is how one says so: its `typ` is `at+jwt`.
|
||||
An ID token — `typ` `JWT` — is the client's proof of sign-in and never passes. Keycloak emits `at+jwt`
|
||||
once the client's `access.token.header.type.rfc9068` attribute is `true`.
|
||||
|
||||
## Sign-in
|
||||
|
||||
@@ -30,8 +34,8 @@ The PKCE verifier, nonce and state travel in a short-lived `HttpOnly` cookie sco
|
||||
so sign-in needs no server-side state and works across instances. The session's principal is renewed
|
||||
with the refresh token when its access token expires; `POST /auth/logout` ends it and continues to the
|
||||
provider's `end_session_endpoint`. The client authenticates with `client_secret_basic`. Register
|
||||
`{origin}/auth/oidc/{id}/callback` as a redirect URI and `{origin}/` as a post-logout redirect URI;
|
||||
behind a proxy, forward `X-Forwarded-Proto` and `X-Forwarded-Host`.
|
||||
`{origin}/auth/oidc/{id}/callback` as a redirect URI and `{origin}/` as a post-logout redirect URI,
|
||||
where `{origin}` is `SecurityExtension.origin(...)`.
|
||||
|
||||
Each provider is listed at `/auth/methods` (`"kind":"redirect"`) and published to OpenAPI as an
|
||||
`openIdConnect` scheme.
|
||||
@@ -45,7 +49,7 @@ oidc.unregister("acme");
|
||||
```
|
||||
|
||||
Discovery runs inside `register`, which refuses a provider — or any endpoint its discovery names — that
|
||||
is not https on a public address: registration makes the server fetch URLs someone else chose.
|
||||
is not https on a public address (`PublicUrl`): registration makes the server fetch URLs someone else chose.
|
||||
`allowLocalProviders()` lifts that for development. Registered providers serve bearer tokens and
|
||||
`/auth/oidc/{id}/login` immediately, but are not listed at `/auth/methods` or in OpenAPI: which provider
|
||||
a given user signs in with is the application's decision — typically an `AuthenticationEntryPoint` that
|
||||
|
||||
+14
-7
@@ -19,8 +19,10 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@@ -84,7 +86,7 @@ public final class OidcExtension implements FlashExtension, AuthenticationMechan
|
||||
* Checks a provider before trusting it: discovery, the client ID and secret, and the redirect URI a
|
||||
* sign-in from {@code origin} will send. Registers nothing, and holds {@link #register}'s address rules.
|
||||
*
|
||||
* @param origin where users will sign in from, as {@link Request#origin()} gives it
|
||||
* @param origin where users will sign in from, as {@link SecurityExtension#origin(Request)} gives it
|
||||
* @throws IllegalArgumentException naming what the provider refused
|
||||
* @throws IllegalStateException the provider could not be reached
|
||||
*/
|
||||
@@ -111,12 +113,17 @@ public final class OidcExtension implements FlashExtension, AuthenticationMechan
|
||||
ctx.onReady(() -> {
|
||||
security = ctx.require(SecurityExtension.class).mechanism(this).refresher(OidcPrincipal.class, this::refresh);
|
||||
for (OidcProvider config : configured) {
|
||||
security.scheme(SecurityScheme.openIdConnect(config.id(), byId.get(config.id()).issuer))
|
||||
.loginMethod(new LoginMethod(config.id(), config.name(), "/auth/oidc/" + config.id() + "/login", LoginMethod.Kind.REDIRECT));
|
||||
security.loginMethod(new LoginMethod(config.id(), config.name(), "/auth/oidc/" + config.id() + "/login", LoginMethod.Kind.REDIRECT));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** One per configured provider; one registered at runtime is the application's to name, never listed. */
|
||||
@Override
|
||||
public List<SecurityScheme> schemes() {
|
||||
return Arrays.stream(configured).map(config -> SecurityScheme.openIdConnect(config.id(), byId.get(config.id()).issuer)).toList();
|
||||
}
|
||||
|
||||
// -- Bearer access tokens -------------------------------------------------
|
||||
|
||||
@Override
|
||||
@@ -150,9 +157,9 @@ public final class OidcExtension implements FlashExtension, AuthenticationMechan
|
||||
String nonce = random(16);
|
||||
res.header("Set-Cookie", FLOW + "=" + state + "." + verifier + "." + nonce + "."
|
||||
+ BASE64URL.encodeToString(localPath(req.query("redirect")).getBytes(StandardCharsets.UTF_8))
|
||||
+ "; Path=/auth/oidc; Max-Age=600; HttpOnly; SameSite=Lax" + (req.origin().startsWith("https") ? "; Secure" : ""));
|
||||
+ "; Path=/auth/oidc; Max-Age=600; HttpOnly; SameSite=Lax" + (security.origin(req).startsWith("https") ? "; Secure" : ""));
|
||||
String challenge = BASE64URL.encodeToString(MessageDigest.getInstance("SHA-256").digest(verifier.getBytes(StandardCharsets.US_ASCII)));
|
||||
res.redirect(provider.authorizeUrl(callbackUri(req.origin(), provider.config.id()), state, nonce, challenge));
|
||||
res.redirect(provider.authorizeUrl(callbackUri(security.origin(req), provider.config.id()), state, nonce, challenge));
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -169,13 +176,13 @@ public final class OidcExtension implements FlashExtension, AuthenticationMechan
|
||||
if (req.query("error") != null) throw HttpException.badRequest("The identity provider refused sign-in: " + req.query("error"));
|
||||
try {
|
||||
Map<String, Object> tokens = provider.token("grant_type=authorization_code&code=" + Provider.encode(req.query("code"))
|
||||
+ "&redirect_uri=" + Provider.encode(callbackUri(req.origin(), provider.config.id())) + "&code_verifier=" + flow[1]);
|
||||
+ "&redirect_uri=" + Provider.encode(callbackUri(security.origin(req), provider.config.id())) + "&code_verifier=" + flow[1]);
|
||||
String idToken = (String) tokens.get("id_token");
|
||||
Map<String, Object> claims = sessionClaims(provider.verifyIdToken(idToken), tokens);
|
||||
if (!flow[2].equals(claims.get("nonce"))) throw new IllegalStateException("nonce mismatch");
|
||||
String logout = provider.endSessionEndpoint == null ? null : provider.endSessionEndpoint
|
||||
+ (provider.endSessionEndpoint.indexOf('?') < 0 ? '?' : '&') + "client_id=" + Provider.encode(provider.config.clientId())
|
||||
+ "&id_token_hint=" + idToken + "&post_logout_redirect_uri=" + Provider.encode(req.origin() + "/");
|
||||
+ "&id_token_hint=" + idToken + "&post_logout_redirect_uri=" + Provider.encode(security.origin(req) + "/");
|
||||
OidcPrincipal principal = new OidcPrincipal(provider.config.id(), (String) claims.get("sub"), claims,
|
||||
(String) tokens.get("access_token"), (String) tokens.get("refresh_token"), logout);
|
||||
security.signIn(req, res, principal, expiry(tokens));
|
||||
|
||||
+7
-20
@@ -12,9 +12,8 @@ import com.nimbusds.jwt.JWTClaimsSet;
|
||||
import com.nimbusds.jwt.SignedJWT;
|
||||
import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier;
|
||||
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
|
||||
import dev.relism.flash.ext.security.PublicUrl;
|
||||
|
||||
import java.net.Inet6Address;
|
||||
import java.net.InetAddress;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
@@ -48,17 +47,17 @@ final class Provider {
|
||||
Provider(OidcProvider config, boolean guarded) {
|
||||
this.config = config;
|
||||
try {
|
||||
if (guarded) requirePublic(config.discoveryUrl());
|
||||
if (guarded) PublicUrl.require(config.discoveryUrl());
|
||||
Map<String, Object> discovery = JSONObjectUtils.parse(HTTP.send(HttpRequest.newBuilder(URI.create(config.discoveryUrl())).build(),
|
||||
HttpResponse.BodyHandlers.ofString()).body());
|
||||
if (guarded) for (String key : new String[]{"authorization_endpoint", "token_endpoint", "jwks_uri"}) requirePublic((String) discovery.get(key));
|
||||
if (guarded) for (String key : new String[]{"authorization_endpoint", "token_endpoint", "jwks_uri"}) PublicUrl.require((String) discovery.get(key));
|
||||
issuer = (String) discovery.get("issuer");
|
||||
authorizationEndpoint = (String) discovery.get("authorization_endpoint");
|
||||
tokenEndpoint = (String) discovery.get("token_endpoint");
|
||||
endSessionEndpoint = (String) discovery.get("end_session_endpoint");
|
||||
JWKSource<SecurityContext> keys = JWKSourceBuilder.create(URI.create((String) discovery.get("jwks_uri")).toURL()).retrying(true).build();
|
||||
accessTokens = processor(keys, null, new JOSEObjectType("at+jwt"));
|
||||
idTokens = processor(keys, config.clientId(), null);
|
||||
accessTokens = processor(keys, null, new DefaultJOSEObjectTypeVerifier<>(new JOSEObjectType("at+jwt"), new JOSEObjectType("application/at+jwt")));
|
||||
idTokens = processor(keys, config.clientId(), DefaultJOSEObjectTypeVerifier.JWT);
|
||||
} catch (Exception e) {
|
||||
if (e instanceof IllegalArgumentException rejected) throw rejected;
|
||||
throw new IllegalStateException("OIDC discovery failed for " + config.discoveryUrl(), e);
|
||||
@@ -136,29 +135,17 @@ final class Provider {
|
||||
}
|
||||
}
|
||||
|
||||
/** ponytail: resolved once here and again by the HTTP client, so DNS rebinding between the two is not covered. */
|
||||
private static void requirePublic(String url) throws Exception {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static String encode(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private DefaultJWTProcessor<SecurityContext> processor(JWKSource<SecurityContext> keys, String audience, JOSEObjectType type) {
|
||||
private DefaultJWTProcessor<SecurityContext> processor(JWKSource<SecurityContext> keys, String audience, DefaultJOSEObjectTypeVerifier<SecurityContext> type) {
|
||||
Set<JWSAlgorithm> algorithms = new HashSet<>(JWSAlgorithm.Family.RSA);
|
||||
algorithms.addAll(JWSAlgorithm.Family.EC);
|
||||
DefaultJWTProcessor<SecurityContext> processor = new DefaultJWTProcessor<>();
|
||||
processor.setJWSKeySelector(new JWSVerificationKeySelector<>(algorithms, keys));
|
||||
processor.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(audience, new JWTClaimsSet.Builder().issuer(issuer).build(), Set.of("sub", "exp")));
|
||||
if (type != null) processor.setJWSTypeVerifier(new DefaultJOSEObjectTypeVerifier<>(JOSEObjectType.JWT, type, null));
|
||||
processor.setJWSTypeVerifier(type);
|
||||
return processor;
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -64,6 +64,13 @@ class OidcExtensionTest {
|
||||
.expectStatus(401).expectHeader("WWW-Authenticate", "Bearer error=\"invalid_token\"");
|
||||
}
|
||||
|
||||
/** RFC 9068: an access token says so in its typ. An ID token is the client's, never a bearer credential. */
|
||||
@Test
|
||||
void anIdTokenIsNotAnAccessToken() {
|
||||
app.request().header("Authorization", "Bearer " + provider.idToken("bob", Map.of("aud", "app"))).get("/me")
|
||||
.expectStatus(401).expectHeader("WWW-Authenticate", "Bearer error=\"invalid_token\"");
|
||||
}
|
||||
|
||||
/** Not rejected as invalid: an issuer this application does not know is some other mechanism's business. */
|
||||
@Test
|
||||
void aTokenFromAnUnknownIssuerIsNotThisMechanisms() {
|
||||
|
||||
@@ -9,6 +9,15 @@ app.request().with(TestSecurity.as(() -> "alice")).get("/me"); /
|
||||
app.request().with(TestSecurity.as(new OidcPrincipal(...))).get("/projects"); // a mechanism's own type
|
||||
```
|
||||
|
||||
`OAuthTestClient` drives an application's own authorization server
|
||||
([`flash-ext-security-oauth-server`](../../flash-ext-security-oauth-server/docs/README.md)) the way an MCP
|
||||
client does — discovery, registration, PKCE, consent, exchange — signing in as any principal:
|
||||
|
||||
```java
|
||||
OAuthTestClient.Tokens tokens = OAuthTestClient.register(app).authorize(() -> "alice");
|
||||
app.request().with(tokens.bearer()).post("/mcp");
|
||||
```
|
||||
|
||||
`TestSecurity.as(principal)` hands the principal to the application by reference — `FlashTest`
|
||||
serves it in the same JVM — so tests exercise the real `UserResolver`, `RoleResolver` and policies
|
||||
with no identity provider running. Tests of the flows themselves use the mechanism's own kit:
|
||||
|
||||
+13
-3
@@ -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) {
|
||||
|
||||
+138
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
<module>flash-ext-security-oidc</module>
|
||||
<module>flash-ext-security-apikey</module>
|
||||
<module>flash-ext-security-form</module>
|
||||
<module>flash-ext-security-oauth-server</module>
|
||||
<module>flash-ext-security-test</module>
|
||||
<module>flash-ext-routeviewer</module>
|
||||
<module>flash-ext-view-core</module>
|
||||
|
||||
@@ -195,14 +195,12 @@ public class Request {
|
||||
public List<String> headers() { checkActive(); return requestLine.getHeaders().all(); }
|
||||
|
||||
/**
|
||||
* The scheme and authority the client addressed, e.g. {@code https://example.com}: from
|
||||
* {@code X-Forwarded-Proto}/{@code X-Forwarded-Host} when present, otherwise the connection and
|
||||
* the {@code Host} header. Only meaningful behind a proxy that sets or strips those headers.
|
||||
* The scheme and authority the client addressed, e.g. {@code https://example.com}: the connection's
|
||||
* scheme and the {@code Host} header. The client chooses both, so nothing that must not be misled
|
||||
* reads this alone; it reads an origin the application configured, falling back to this one.
|
||||
*/
|
||||
public String origin() {
|
||||
String proto = header("X-Forwarded-Proto");
|
||||
String host = header("X-Forwarded-Host");
|
||||
return (proto != null ? proto : isSecure() ? "https" : "http") + "://" + (host != null ? host : header("Host"));
|
||||
return (isSecure() ? "https" : "http") + "://" + header("Host");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user