diff --git a/README.md b/README.md index bb94243..6c1c367 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/flash-extensions/flash-ext-mcp/docs/security.md b/flash-extensions/flash-ext-mcp/docs/security.md index 917fc94..1e0f8ea 100644 --- a/flash-extensions/flash-ext-mcp/docs/security.md +++ b/flash-extensions/flash-ext-mcp/docs/security.md @@ -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 diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpConfig.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpConfig.java index d8268a7..47290fd 100644 --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpConfig.java +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpConfig.java @@ -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 allowedOrigins; private final List scopesSupported; private final List middleware; + private final List 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 allowedOrigins() { return allowedOrigins; } List scopesSupported() { return scopesSupported; } List middleware() { return middleware; } + List mechanisms() { return mechanisms; } public static Builder builder(String name) { return new Builder(name); } @@ -68,6 +72,7 @@ public final class McpConfig { private final List allowedOrigins = new ArrayList<>(); private final List scopesSupported = new ArrayList<>(); private final List middleware = new ArrayList<>(); + private final List 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)); diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpExtension.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpExtension.java index 82a1292..9f9952e 100644 --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpExtension.java +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpExtension.java @@ -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 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 chain) { String metadataPath = "/.well-known/oauth-protected-resource" + config.rootPath(); - chain.add(security.enforce(SecurityPolicy.AUTHENTICATED, (req, res) -> { - List 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 only = config.mechanisms(); + AuthenticationEntryPoint anonymous = (req, res) -> { + List 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 issuers = issuers(security); + List 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 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 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 issuers(List schemes, String origin) { + return schemes.stream().map(SecurityScheme::issuer).filter(Objects::nonNull).map(issuer -> issuer.equals("/") ? origin : issuer).toList(); } } diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpSecurityTest.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpSecurityTest.java index a107b1b..4fe74c9 100644 --- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpSecurityTest.java +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpSecurityTest.java @@ -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 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 key = request -> request.header("Authorization", "Bearer " + KEY.token()); + Consumer other = request -> request.header("Authorization", "Other"); + for (Consumer 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() { diff --git a/flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/ApiKeyExtension.java b/flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/ApiKeyExtension.java index 87cf053..826f9a0 100644 --- a/flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/ApiKeyExtension.java +++ b/flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/ApiKeyExtension.java @@ -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 _.}. The prefix makes a key @@ -63,8 +64,8 @@ public final class ApiKeyExtension implements FlashExtension, AuthenticationM } @Override - public SecurityScheme scheme() { - return SecurityScheme.bearer("apiKey", prefix + "_."); + public List schemes() { + return List.of(SecurityScheme.bearer("apiKey", prefix + "_.")); } @Override diff --git a/flash-extensions/flash-ext-security-core/docs/README.md b/flash-extensions/flash-ext-security-core/docs/README.md index c5390ca..53cdd7b 100644 --- a/flash-extensions/flash-ext-security-core/docs/README.md +++ b/flash-extensions/flash-ext-security-core/docs/README.md @@ -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 schemes() { return List.of(SecurityScheme.bearer("key", "opaque")); } }); ``` diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/AuthenticationMechanism.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/AuthenticationMechanism.java index 263fe37..1a3f188 100644 --- a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/AuthenticationMechanism.java +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/AuthenticationMechanism.java @@ -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 schemes() { return List.of(); } } diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/PublicUrl.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/PublicUrl.java new file mode 100644 index 0000000..f8905c4 --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/PublicUrl.java @@ -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); + } + } + } +} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SecurityExtension.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SecurityExtension.java index 6713fef..fa8c102 100644 --- a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SecurityExtension.java +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SecurityExtension.java @@ -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 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); diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SecurityScheme.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SecurityScheme.java index 36c48b0..a09447d 100644 --- a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SecurityScheme.java +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SecurityScheme.java @@ -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 definition, String challenge, String issuer) { diff --git a/flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/PublicUrlTest.java b/flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/PublicUrlTest.java new file mode 100644 index 0000000..09f0267 --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/PublicUrlTest.java @@ -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); + } + } +} diff --git a/flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/SecurityExtensionTest.java b/flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/SecurityExtensionTest.java index c84ffd5..534a7f5 100644 --- a/flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/SecurityExtensionTest.java +++ b/flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/SecurityExtensionTest.java @@ -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 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\"}]"); diff --git a/flash-extensions/flash-ext-security-oauth-server/docs/README.md b/flash-extensions/flash-ext-security-oauth-server/docs/README.md new file mode 100644 index 0000000..0aedd82 --- /dev/null +++ b/flash-extensions/flash-ext-security-oauth-server/docs/README.md @@ -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?` — +`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. diff --git a/flash-extensions/flash-ext-security-oauth-server/pom.xml b/flash-extensions/flash-ext-security-oauth-server/pom.xml new file mode 100644 index 0000000..b7c936a --- /dev/null +++ b/flash-extensions/flash-ext-security-oauth-server/pom.xml @@ -0,0 +1,56 @@ + + + 4.0.0 + + + dev.relism + flash-extensions + 2.1.0-SNAPSHOT + + + flash-ext-security-oauth-server + + + + dev.relism + flash-ext-security-core + + + dev.relism + flash-ext-cache-core + + + com.nimbusds + nimbus-jose-jwt + + + org.projectlombok + lombok + + + org.slf4j + slf4j-api + + + org.junit.jupiter + junit-jupiter + + + dev.relism + flash-ext-security-test + + + dev.relism + flash-ext-cache-caffeine + test + + + dev.relism + flash-ext-mcp + ${project.version} + test + + + diff --git a/flash-extensions/flash-ext-security-oauth-server/src/main/java/dev/relism/flash/ext/security/oauthserver/Clients.java b/flash-extensions/flash-ext-security-oauth-server/src/main/java/dev/relism/flash/ext/security/oauthserver/Clients.java new file mode 100644 index 0000000..aedb318 --- /dev/null +++ b/flash-extensions/flash-ext-security-oauth-server/src/main/java/dev/relism/flash/ext/security/oauthserver/Clients.java @@ -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 AUTH_METHODS = List.of("none", "client_secret_basic", "client_secret_post"); + private static final Set 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> documents; + + /** @param documents bounded and expiring: the keys are URLs whoever calls the server chose */ + Clients(OAuthStore store, boolean localDocuments, Cache> 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 document(String url) { + try { + if (!localDocuments) PublicUrl.require(url); + HttpResponse 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 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 validate(Map given, boolean trusted) { + Map 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 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; + } +} diff --git a/flash-extensions/flash-ext-security-oauth-server/src/main/java/dev/relism/flash/ext/security/oauthserver/InMemoryOAuthStore.java b/flash-extensions/flash-ext-security-oauth-server/src/main/java/dev/relism/flash/ext/security/oauthserver/InMemoryOAuthStore.java new file mode 100644 index 0000000..b7e94fb --- /dev/null +++ b/flash-extensions/flash-ext-security-oauth-server/src/main/java/dev/relism/flash/ext/security/oauthserver/InMemoryOAuthStore.java @@ -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 clients = new ConcurrentHashMap<>(); + private final Map grants = new ConcurrentHashMap<>(); + private final Map 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); + } +} diff --git a/flash-extensions/flash-ext-security-oauth-server/src/main/java/dev/relism/flash/ext/security/oauthserver/OAuthClient.java b/flash-extensions/flash-ext-security-oauth-server/src/main/java/dev/relism/flash/ext/security/oauthserver/OAuthClient.java new file mode 100644 index 0000000..5d4f2d0 --- /dev/null +++ b/flash-extensions/flash-ext-security-oauth-server/src/main/java/dev/relism/flash/ext/security/oauthserver/OAuthClient.java @@ -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 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 redirectUris() { + return (List) metadata.getOrDefault("redirect_uris", List.of()); + } + + @SuppressWarnings("unchecked") + public Set grantTypes() { + return Set.copyOf((List) 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); + } +} diff --git a/flash-extensions/flash-ext-security-oauth-server/src/main/java/dev/relism/flash/ext/security/oauthserver/OAuthGrant.java b/flash-extensions/flash-ext-security-oauth-server/src/main/java/dev/relism/flash/ext/security/oauthserver/OAuthGrant.java new file mode 100644 index 0000000..daaf9a9 --- /dev/null +++ b/flash-extensions/flash-ext-security-oauth-server/src/main/java/dev/relism/flash/ext/security/oauthserver/OAuthGrant.java @@ -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 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); + } +} diff --git a/flash-extensions/flash-ext-security-oauth-server/src/main/java/dev/relism/flash/ext/security/oauthserver/OAuthPrincipal.java b/flash-extensions/flash-ext-security-oauth-server/src/main/java/dev/relism/flash/ext/security/oauthserver/OAuthPrincipal.java new file mode 100644 index 0000000..d254ccc --- /dev/null +++ b/flash-extensions/flash-ext-security-oauth-server/src/main/java/dev/relism/flash/ext/security/oauthserver/OAuthPrincipal.java @@ -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 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); + } +} diff --git a/flash-extensions/flash-ext-security-oauth-server/src/main/java/dev/relism/flash/ext/security/oauthserver/OAuthServerExtension.java b/flash-extensions/flash-ext-security-oauth-server/src/main/java/dev/relism/flash/ext/security/oauthserver/OAuthServerExtension.java new file mode 100644 index 0000000..5d3b0ea --- /dev/null +++ b/flash-extensions/flash-ext-security-oauth-server/src/main/java/dev/relism/flash/ext/security/oauthserver/OAuthServerExtension.java @@ -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. + * + *
{@code
+ * app.install(new SecurityExtension().origin("https://app.example").loginPage("/login"))
+ *    .install(new OAuthServerExtension("/mcp").store(store).signingKeys(keys));
+ * }
+ * + *

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 resources; + private OAuthStore store = new InMemoryOAuthStore(); + private SigningKeys keys; + private Function subjects = identity -> new OAuthSubject(identity.principal().name(), Map.of()); + private Set 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 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 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 metadata) { + return register(metadata, true); + } + + private Registration register(Map metadata, boolean trusted) { + Map 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 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 schemes() { + Map 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 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 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 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 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 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 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 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 extra, String scope, String resource, String family) { + Instant now = Instant.now(); + Map 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 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 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 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 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 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 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 form(Request req) { + Map 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); + } +} diff --git a/flash-extensions/flash-ext-security-oauth-server/src/main/java/dev/relism/flash/ext/security/oauthserver/OAuthStore.java b/flash-extensions/flash-ext-security-oauth-server/src/main/java/dev/relism/flash/ext/security/oauthserver/OAuthStore.java new file mode 100644 index 0000000..f4a3c5c --- /dev/null +++ b/flash-extensions/flash-ext-security-oauth-server/src/main/java/dev/relism/flash/ext/security/oauthserver/OAuthStore.java @@ -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); +} diff --git a/flash-extensions/flash-ext-security-oauth-server/src/main/java/dev/relism/flash/ext/security/oauthserver/OAuthSubject.java b/flash-extensions/flash-ext-security-oauth-server/src/main/java/dev/relism/flash/ext/security/oauthserver/OAuthSubject.java new file mode 100644 index 0000000..ddac021 --- /dev/null +++ b/flash-extensions/flash-ext-security-oauth-server/src/main/java/dev/relism/flash/ext/security/oauthserver/OAuthSubject.java @@ -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 claims) { + + public OAuthSubject { + claims = Map.copyOf(claims); + } +} diff --git a/flash-extensions/flash-ext-security-oauth-server/src/main/java/dev/relism/flash/ext/security/oauthserver/SigningKeys.java b/flash-extensions/flash-ext-security-oauth-server/src/main/java/dev/relism/flash/ext/security/oauthserver/SigningKeys.java new file mode 100644 index 0000000..f28149c --- /dev/null +++ b/flash-extensions/flash-ext-security-oauth-server/src/main/java/dev/relism/flash/ext/security/oauthserver/SigningKeys.java @@ -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 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 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 verify(SignedJWT token) throws Exception { + return verifier.process(token, null).getClaims(); + } + + String publicJwks() { + return publicJwks; + } +} diff --git a/flash-extensions/flash-ext-security-oauth-server/src/test/java/dev/relism/flash/ext/security/oauthserver/OAuthServerExtensionTest.java b/flash-extensions/flash-ext-security-oauth-server/src/test/java/dev/relism/flash/ext/security/oauthserver/OAuthServerExtensionTest.java new file mode 100644 index 0000000..9a036c2 --- /dev/null +++ b/flash-extensions/flash-ext-security-oauth-server/src/test/java/dev/relism/flash/ext/security/oauthserver/OAuthServerExtensionTest.java @@ -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 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 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); + } +} diff --git a/flash-extensions/flash-ext-security-oauth-server/src/test/java/dev/relism/flash/ext/security/oauthserver/tools/WhoAmITool.java b/flash-extensions/flash-ext-security-oauth-server/src/test/java/dev/relism/flash/ext/security/oauthserver/tools/WhoAmITool.java new file mode 100644 index 0000000..96d59e9 --- /dev/null +++ b/flash-extensions/flash-ext-security-oauth-server/src/test/java/dev/relism/flash/ext/security/oauthserver/tools/WhoAmITool.java @@ -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())); + } +} diff --git a/flash-extensions/flash-ext-security-oidc/docs/README.md b/flash-extensions/flash-ext-security-oidc/docs/README.md index d186ba6..7edc59e 100644 --- a/flash-extensions/flash-ext-security-oidc/docs/README.md +++ b/flash-extensions/flash-ext-security-oidc/docs/README.md @@ -15,9 +15,13 @@ Discovery runs at boot, so an unreachable provider fails the start rather than t ## Bearer tokens `Authorization: Bearer ` 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 diff --git a/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/OidcExtension.java b/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/OidcExtension.java index 5bda112..d7ee289 100644 --- a/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/OidcExtension.java +++ b/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/OidcExtension.java @@ -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 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 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 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)); diff --git a/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/Provider.java b/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/Provider.java index de98265..4a98f49 100644 --- a/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/Provider.java +++ b/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/Provider.java @@ -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 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 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 processor(JWKSource keys, String audience, JOSEObjectType type) { + private DefaultJWTProcessor processor(JWKSource keys, String audience, DefaultJOSEObjectTypeVerifier type) { Set algorithms = new HashSet<>(JWSAlgorithm.Family.RSA); algorithms.addAll(JWSAlgorithm.Family.EC); DefaultJWTProcessor 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; } } diff --git a/flash-extensions/flash-ext-security-oidc/src/test/java/dev/relism/flash/ext/security/oidc/OidcExtensionTest.java b/flash-extensions/flash-ext-security-oidc/src/test/java/dev/relism/flash/ext/security/oidc/OidcExtensionTest.java index 47c3b62..6ae6d27 100644 --- a/flash-extensions/flash-ext-security-oidc/src/test/java/dev/relism/flash/ext/security/oidc/OidcExtensionTest.java +++ b/flash-extensions/flash-ext-security-oidc/src/test/java/dev/relism/flash/ext/security/oidc/OidcExtensionTest.java @@ -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() { diff --git a/flash-extensions/flash-ext-security-test/docs/README.md b/flash-extensions/flash-ext-security-test/docs/README.md index 706ca1f..6b4efe2 100644 --- a/flash-extensions/flash-ext-security-test/docs/README.md +++ b/flash-extensions/flash-ext-security-test/docs/README.md @@ -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: diff --git a/flash-extensions/flash-ext-security-test/src/main/java/dev/relism/flash/ext/security/test/FakeOidcProvider.java b/flash-extensions/flash-ext-security-test/src/main/java/dev/relism/flash/ext/security/test/FakeOidcProvider.java index c90d03b..7e2a93b 100644 --- a/flash-extensions/flash-ext-security-test/src/main/java/dev/relism/flash/ext/security/test/FakeOidcProvider.java +++ b/flash-extensions/flash-ext-security-test/src/main/java/dev/relism/flash/ext/security/test/FakeOidcProvider.java @@ -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 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 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 claims) { + return sign(JOSEObjectType.JWT, subject, claims); + } + + private String sign(JOSEObjectType type, String subject, Map 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) { diff --git a/flash-extensions/flash-ext-security-test/src/main/java/dev/relism/flash/ext/security/test/OAuthTestClient.java b/flash-extensions/flash-ext-security-test/src/main/java/dev/relism/flash/ext/security/test/OAuthTestClient.java new file mode 100644 index 0000000..d0a9b7a --- /dev/null +++ b/flash-extensions/flash-ext-security-test/src/main/java/dev/relism/flash/ext/security/test/OAuthTestClient.java @@ -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}. + * + *

{@code
+ * OAuthTestClient.Tokens tokens = OAuthTestClient.register(app).authorize(() -> "alice");
+ * app.request().with(tokens.bearer()).post("/mcp");
+ * }
+ */ +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 metadata; + private final String clientId; + + /** What the exchange returned. */ + public record Tokens(String accessToken, String refreshToken, String scope) { + + public Consumer bearer() { + return request -> request.header("Authorization", "Bearer " + accessToken); + } + } + + private OAuthTestClient(FlashTest app, Map 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 metadata = json(app.get("/.well-known/oauth-authorization-server").expectStatus(200)); + Map 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 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 response) { + return new Tokens((String) response.get("access_token"), (String) response.get("refresh_token"), (String) response.get("scope")); + } + + private static String path(Map 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 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); + } +} diff --git a/flash-extensions/pom.xml b/flash-extensions/pom.xml index 62ed536..bbbf68b 100644 --- a/flash-extensions/pom.xml +++ b/flash-extensions/pom.xml @@ -20,6 +20,7 @@ flash-ext-security-oidc flash-ext-security-apikey flash-ext-security-form + flash-ext-security-oauth-server flash-ext-security-test flash-ext-routeviewer flash-ext-view-core diff --git a/flash/src/main/java/dev/relism/flash/models/Request.java b/flash/src/main/java/dev/relism/flash/models/Request.java index 9c45280..d7597dd 100644 --- a/flash/src/main/java/dev/relism/flash/models/Request.java +++ b/flash/src/main/java/dev/relism/flash/models/Request.java @@ -195,14 +195,12 @@ public class Request { public List 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"); } /**