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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Zakaria El Orche
2026-09-22 11:39:09 +00:00
co-authored by Claude Opus 5
parent 7f225e0faf
commit f28fc43150
35 changed files with 1958 additions and 80 deletions
@@ -0,0 +1,124 @@
package dev.relism.flash.ext.security.oauthserver;
import com.nimbusds.jose.util.JSONObjectUtils;
import dev.relism.flash.ext.cache.Cache;
import dev.relism.flash.ext.security.PublicUrl;
import dev.relism.flash.ext.security.oauthserver.OAuthServerExtension.OAuthError;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
/**
* Finds a client — registered, or described by the metadata document its https {@code client_id} points
* at — and validates what a client says about itself.
*/
final class Clients {
static final List<String> AUTH_METHODS = List.of("none", "client_secret_basic", "client_secret_post");
private static final Set<String> LOOPBACK = Set.of("127.0.0.1", "[::1]", "localhost");
private static final HttpClient HTTP = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
private final OAuthStore store;
private final boolean localDocuments;
private final Cache<String, Optional<OAuthClient>> documents;
/** @param documents bounded and expiring: the keys are URLs whoever calls the server chose */
Clients(OAuthStore store, boolean localDocuments, Cache<String, Optional<OAuthClient>> documents) {
this.store = store;
this.localDocuments = localDocuments;
this.documents = documents;
}
/** The client, or {@code null} when there is none by that id. */
OAuthClient find(String id) {
if (id == null) return null;
if (!id.startsWith("https://") && !(localDocuments && id.startsWith("http://"))) return store.client(id);
return documents.get(id, this::document).orElse(null);
}
/**
* A client ID Metadata Document: fetched from where its id points, under {@link PublicUrl}'s rules,
* and trusted only if it names that same URL. Such a client holds no secret, so it is public. A URL
* that fails is remembered as failing, so naming it again does not fetch it again.
*/
private Optional<OAuthClient> document(String url) {
try {
if (!localDocuments) PublicUrl.require(url);
HttpResponse<String> response = HTTP.send(HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(5))
.header("Accept", "application/json").build(), HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200 || response.body().length() > 16_384) return Optional.empty();
Map<String, Object> metadata = new HashMap<>(JSONObjectUtils.parse(response.body()));
if (!url.equals(metadata.remove("client_id"))) return Optional.empty();
metadata.putIfAbsent("token_endpoint_auth_method", "none");
if (!"none".equals(metadata.get("token_endpoint_auth_method"))) return Optional.empty();
return Optional.of(new OAuthClient(url, null, validate(metadata, false)));
} catch (Exception unusable) {
return Optional.empty();
}
}
/**
* RFC 7591 metadata with its defaults filled in: {@code authorization_code}, {@code code}, and
* {@code client_secret_basic}. Only an application registering a client itself may grant it
* {@code client_credentials}: a client registering itself would otherwise mint its own tokens.
*
* @throws OAuthError naming the RFC 7591 error
*/
static Map<String, Object> validate(Map<String, Object> given, boolean trusted) {
Map<String, Object> metadata = new HashMap<>(given);
metadata.putIfAbsent("grant_types", List.of("authorization_code"));
metadata.putIfAbsent("response_types", List.of("code"));
metadata.putIfAbsent("token_endpoint_auth_method", "client_secret_basic");
List<?> grants = list(metadata.get("grant_types"));
Set<String> allowed = trusted ? Set.of("authorization_code", "refresh_token", "client_credentials") : Set.of("authorization_code", "refresh_token");
if (grants == null || grants.isEmpty() || !allowed.containsAll(grants)) throw new OAuthError("invalid_client_metadata", "Unsupported grant_types");
if (!List.of("code").equals(metadata.get("response_types")) && grants.contains("authorization_code")) {
throw new OAuthError("invalid_client_metadata", "response_types must be [\"code\"]");
}
if (!AUTH_METHODS.contains(metadata.get("token_endpoint_auth_method"))) throw new OAuthError("invalid_client_metadata", "Unsupported token_endpoint_auth_method");
if (grants.contains("client_credentials") && "none".equals(metadata.get("token_endpoint_auth_method"))) {
throw new OAuthError("invalid_client_metadata", "client_credentials needs a confidential client");
}
List<?> uris = list(metadata.getOrDefault("redirect_uris", List.of()));
if (uris == null || grants.contains("authorization_code") && uris.isEmpty()) throw new OAuthError("invalid_redirect_uri", "redirect_uris is required");
for (Object uri : uris) {
if (!(uri instanceof String text) || !redirectable(text)) throw new OAuthError("invalid_redirect_uri", "Not an https or loopback URI: " + uri);
}
return metadata;
}
/** OAuth 2.1 §2.3.1: exactly as registered, except that a loopback redirect may use any port (RFC 8252 §7.3). */
static boolean matches(String registered, String requested) {
if (registered.equals(requested)) return true;
try {
URI a = URI.create(registered), b = URI.create(requested);
return "http".equals(a.getScheme()) && "http".equals(b.getScheme()) && LOOPBACK.contains(a.getHost()) && a.getHost().equals(b.getHost())
&& a.getRawPath().equals(b.getRawPath()) && Objects.equals(a.getRawQuery(), b.getRawQuery()) && b.getRawFragment() == null;
} catch (IllegalArgumentException malformed) {
return false;
}
}
private static boolean redirectable(String uri) {
try {
URI parsed = URI.create(uri);
return parsed.getRawFragment() == null && parsed.getHost() != null
&& ("https".equals(parsed.getScheme()) || "http".equals(parsed.getScheme()) && LOOPBACK.contains(parsed.getHost()));
} catch (IllegalArgumentException malformed) {
return false;
}
}
private static List<?> list(Object value) {
return value instanceof List<?> list ? list : null;
}
}
@@ -0,0 +1,60 @@
package dev.relism.flash.ext.security.oauthserver;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/** One instance's clients, grants and consents, lost on restart. Expired grants are swept whenever one is saved. */
public final class InMemoryOAuthStore implements OAuthStore {
private final Map<String, OAuthClient> clients = new ConcurrentHashMap<>();
private final Map<String, OAuthGrant> grants = new ConcurrentHashMap<>();
private final Map<String, String> consents = new ConcurrentHashMap<>();
@Override
public OAuthClient client(String id) {
return clients.get(id);
}
@Override
public void save(OAuthClient client) {
clients.put(client.id(), client);
}
@Override
public void save(OAuthGrant grant) {
Instant now = Instant.now();
grants.values().removeIf(g -> g.expiresAt().isBefore(now));
grants.put(grant.hash(), grant);
}
@Override
public OAuthGrant find(String hash) {
return grants.get(hash);
}
@Override
public OAuthGrant use(String hash) {
OAuthGrant[] before = new OAuthGrant[1];
grants.computeIfPresent(hash, (key, grant) -> {
before[0] = grant;
return grant.usedAt() == null ? grant.used(Instant.now()) : grant;
});
return before[0];
}
@Override
public void revoke(String family) {
grants.values().removeIf(grant -> grant.family().equals(family));
}
@Override
public String consent(String subject, String clientId) {
return consents.get(subject + " " + clientId);
}
@Override
public void consent(String subject, String clientId, String scope) {
consents.put(subject + " " + clientId, scope);
}
}
@@ -0,0 +1,38 @@
package dev.relism.flash.ext.security.oauthserver;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* A client of the authorization server, described by its RFC 7591 metadata exactly as it was registered
* (defaults filled in). A confidential client's secret is kept only as a hash; a public one has none.
*
* @param id either issued at registration, or the https URL of the client's metadata document
*/
public record OAuthClient(String id, String secretHash, Map<String, Object> metadata) {
public OAuthClient {
metadata = Map.copyOf(metadata);
}
/** {@code none}, {@code client_secret_basic} or {@code client_secret_post}. */
public String authMethod() {
return (String) metadata.get("token_endpoint_auth_method");
}
@SuppressWarnings("unchecked")
public List<String> redirectUris() {
return (List<String>) metadata.getOrDefault("redirect_uris", List.of());
}
@SuppressWarnings("unchecked")
public Set<String> grantTypes() {
return Set.copyOf((List<String>) metadata.get("grant_types"));
}
/** What a consent page calls it: its {@code client_name}, or its id when it gave none. */
public String name() {
return (String) metadata.getOrDefault("client_name", id);
}
}
@@ -0,0 +1,29 @@
package dev.relism.flash.ext.security.oauthserver;
import java.time.Instant;
import java.util.Map;
/**
* An authorization code or a refresh token, stored under the hash of its value. Every grant descending
* from one authorization shares a {@code family}, so a replayed code or a reused refresh token revokes
* the whole of it (OAuth 2.1 §4.1.2, §4.3.1).
*
* @param subject who authorized it, as {@link OAuthSubject#id()}
* @param claims carried into every access token issued from it
* @param redirectUri the one the authorization request named, which the code exchange must repeat; {@code null} otherwise
* @param challenge the PKCE {@code S256} challenge; codes only
* @param usedAt when it was exchanged, {@code null} while it is unused
*/
public record OAuthGrant(String hash, Kind kind, String family, String clientId, String subject, Map<String, Object> claims,
String scope, String resource, String redirectUri, String challenge, Instant expiresAt, Instant usedAt) {
public enum Kind { CODE, REFRESH }
public OAuthGrant {
claims = Map.copyOf(claims);
}
public OAuthGrant used(Instant at) {
return new OAuthGrant(hash, kind, family, clientId, subject, claims, scope, resource, redirectUri, challenge, expiresAt, at);
}
}
@@ -0,0 +1,31 @@
package dev.relism.flash.ext.security.oauthserver;
import dev.relism.flash.ext.security.Principal;
import java.util.Collection;
import java.util.List;
import java.util.Map;
/**
* A caller holding an access token this application issued. {@link #name()} is {@code sub}: the
* {@link OAuthSubject#id()} the user authorized as, or the client's own id for {@code client_credentials}.
*
* @param claims the token's verified claims
*/
public record OAuthPrincipal(String name, String clientId, Map<String, Object> claims) implements Principal {
public Object claim(String name) {
return claims.get(name);
}
@Override
public boolean hasScope(String scope) {
return claims.get("scope") instanceof String granted && List.of(granted.split(" ")).contains(scope);
}
@Override
public boolean hasAudience(String audience) {
Object aud = claims.get("aud");
return aud instanceof Collection<?> list ? list.contains(audience) : audience.equals(aud);
}
}
@@ -0,0 +1,633 @@
package dev.relism.flash.ext.security.oauthserver;
import com.nimbusds.jose.util.JSONObjectUtils;
import com.nimbusds.jwt.SignedJWT;
import dev.relism.flash.Flash;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.ext.cache.Cache;
import dev.relism.flash.ext.cache.CacheManager;
import dev.relism.flash.ext.security.AuthenticationFailedException;
import dev.relism.flash.ext.security.AuthenticationMechanism;
import dev.relism.flash.ext.security.Principal;
import dev.relism.flash.ext.security.SecurityExtension;
import dev.relism.flash.ext.security.SecurityIdentity;
import dev.relism.flash.ext.security.SecurityPolicy;
import dev.relism.flash.ext.security.SecurityScheme;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.Response;
import dev.relism.flash.models.SimpleHandler;
import dev.relism.flash.routing.Middleware;
import dev.relism.fpr.core.ByteView;
import lombok.extern.slf4j.Slf4j;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.Instant;
import java.util.Arrays;
import java.util.Base64;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* An OAuth 2.1 authorization server for the application's own users and resources. A user signs in the
* way the application's security chain signs anyone in, consents on the application's page, and the
* client receives an RFC 9068 access token for one of the application's resources — which this extension,
* as an {@link AuthenticationMechanism}, then authenticates.
*
* <pre>{@code
* app.install(new SecurityExtension().origin("https://app.example").loginPage("/login"))
* .install(new OAuthServerExtension("/mcp").store(store).signingKeys(keys));
* }</pre>
*
* <p>Authorization code with PKCE {@code S256} (the only grant a user takes part in), refresh tokens that
* rotate and revoke their family on reuse, {@code client_credentials} for clients the application registers
* itself; RFC 8414 metadata, RFC 7591 registration, client ID metadata documents, RFC 8707 resource
* indicators, RFC 7009 revocation, RFC 9207 {@code iss} in the authorization response. The issuer is the
* application's {@link SecurityExtension#origin(Request)}.
*/
@Slf4j
public final class OAuthServerExtension implements FlashExtension, AuthenticationMechanism {
public static final String AUTHORIZE = "/oauth/authorize";
public static final String TOKEN = "/oauth/token";
public static final String REGISTER = "/oauth/register";
public static final String REVOKE = "/oauth/revoke";
public static final String JWKS = "/oauth/jwks";
public static final String METADATA = "/.well-known/oauth-authorization-server";
private static final AuthenticationFailedException INVALID = new AuthenticationFailedException("Bearer error=\"invalid_token\"");
private static final SecureRandom RANDOM = new SecureRandom();
private static final Base64.Encoder BASE64URL = Base64.getUrlEncoder().withoutPadding();
private static final Duration CODE_LIFETIME = Duration.ofMinutes(1);
private final List<String> resources;
private OAuthStore store = new InMemoryOAuthStore();
private SigningKeys keys;
private Function<SecurityIdentity, OAuthSubject> subjects = identity -> new OAuthSubject(identity.principal().name(), Map.of());
private Set<String> scopes = Set.of();
private Duration accessTokenLifetime = Duration.ofMinutes(10);
private Duration refreshTokenLifetime = Duration.ofDays(30);
private String consentPage = "/consent";
private boolean registration = true;
private boolean localClients;
private Clients clients;
private Cache<String, String> metadata;
private SecurityExtension security;
/**
* @param resources the paths tokens may be issued for, as RFC 8707 resources on the application's origin;
* the first is the audience of a request that names none
*/
public OAuthServerExtension(String... resources) {
if (resources.length == 0) throw new IllegalArgumentException("Name at least one resource path, e.g. /mcp");
for (String resource : resources) {
if (!resource.startsWith("/")) throw new IllegalArgumentException("A resource is a path on this application: " + resource);
}
this.resources = List.of(resources);
}
// -- Configuration --------------------------------------------------------
public OAuthServerExtension store(OAuthStore store) {
this.store = store;
return this;
}
/**
* The signing keys, as a JWK set whose first key is a private P-256 key — see {@link #generateSigningKeys()}.
* Unset, a key is generated at boot, and every token dies with the process.
*/
public OAuthServerExtension signingKeys(String jwkSet) {
this.keys = new SigningKeys(jwkSet);
return this;
}
/**
* Whom an authorization names, and what its tokens carry — and who may authorize at all: throw to refuse a
* caller. A credential narrower than its user, like an API key, must not become a token with all the user's
* rights. Default: the principal's name, no claims, anyone signed in.
*/
public OAuthServerExtension subjects(Function<SecurityIdentity, OAuthSubject> subjects) {
this.subjects = subjects;
return this;
}
/** The scopes a client may be granted; any other it asks for is left out of the grant. Default: none. */
public OAuthServerExtension scopes(String... scopes) {
this.scopes = Set.of(scopes);
return this;
}
public OAuthServerExtension accessTokenLifetime(Duration lifetime) {
this.accessTokenLifetime = lifetime;
return this;
}
public OAuthServerExtension refreshTokenLifetime(Duration lifetime) {
this.refreshTokenLifetime = lifetime;
return this;
}
/**
* The application's page asking a signed-in user to allow a client, sent the authorization request as its
* query string. It reads {@code GET /oauth/authorize/request} with that same query, and posts it back to
* {@code /oauth/authorize} with {@code consent=allow} or {@code consent=deny}. Default {@code /consent}.
*/
public OAuthServerExtension consentPage(String consentPage) {
this.consentPage = consentPage;
return this;
}
/** Whether any client may register itself at {@link #REGISTER} (RFC 7591). Default {@code true}. */
public OAuthServerExtension registration(boolean open) {
this.registration = open;
return this;
}
/** Lets client metadata documents live on http and private addresses — for development, never production. */
public OAuthServerExtension allowLocalClients() {
this.localClients = true;
return this;
}
/** A new JWK set for {@link #signingKeys(String)}, private key included: generate once, keep it secret. */
public static String generateSigningKeys() {
return SigningKeys.generate();
}
// -- Clients --------------------------------------------------------------
/** A registered client, and its secret — shown this once, never stored. {@code null} for a public client. */
public record Registration(OAuthClient client, String secret) {}
/**
* Registers a client from its RFC 7591 metadata — what {@link #REGISTER} does for a client registering
* itself, and the only way to a {@code client_credentials} client.
*/
public Registration register(Map<String, Object> metadata) {
return register(metadata, true);
}
private Registration register(Map<String, Object> metadata, boolean trusted) {
Map<String, Object> valid = Clients.validate(metadata, trusted);
String secret = "none".equals(valid.get("token_endpoint_auth_method")) ? null : random(32);
OAuthClient client = new OAuthClient(random(16), secret == null ? null : s256(secret), valid);
store.save(client);
return new Registration(client, secret);
}
// -- Extension ------------------------------------------------------------
@Override
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
if (keys == null) {
keys = new SigningKeys(SigningKeys.generate());
log.warn("[flash-ext-security-oauth-server] No signing keys configured: tokens will not survive a restart.");
}
ctx.provide(OAuthServerExtension.class, this);
ctx.onReady(() -> {
security = ctx.require(SecurityExtension.class).mechanism(this);
CacheManager caches = ctx.require(CacheManager.class);
clients = new Clients(store, localClients, caches.build("oauth-server.client-documents", spec -> spec.maxSize(1_000).ttl(Duration.ofMinutes(10))));
// Keyed by issuer, which is the request's own origin when none is configured: bounded for that.
metadata = caches.build("oauth-server.metadata", spec -> spec.maxSize(16));
Middleware signedIn = security.enforce(SecurityPolicy.AUTHENTICATED);
Middleware cors = Flash.commons.middlewares.cors(c -> c.methods("GET", "POST", "OPTIONS").headers("Authorization", "Content-Type"));
app.get(AUTHORIZE, this::authorize, signedIn);
app.post(AUTHORIZE, this::decide, signedIn);
app.get(AUTHORIZE + "/request", this::request, signedIn);
app.get(METADATA, (req, res) -> json(res, 200, metadata.get(issuer(req), this::describe)), cors);
app.get(JWKS, (req, res) -> json(res, 200, keys.publicJwks()), cors);
app.post(TOKEN, endpoint(this::token), cors);
app.post(REVOKE, endpoint(this::revoke), cors);
if (registration) app.post(REGISTER, endpoint(this::registerSelf), cors);
for (String path : registration ? List.of(METADATA, JWKS, TOKEN, REVOKE, REGISTER) : List.of(METADATA, JWKS, TOKEN, REVOKE)) {
app.options(path, (req, res) -> {
res.status(204);
return null;
}, cors);
}
});
}
// -- Bearer access tokens -------------------------------------------------
/**
* Only tokens this server issued are this mechanism's: any other bearer is left to the rest of the chain.
* A token is good only under the resource it was issued for (RFC 8707, RFC 9068 §4): one for {@code /mcp}
* authenticates nothing outside it.
*/
@Override
public Principal authenticate(Request req) {
String header = req.header("Authorization");
if (header == null || !header.startsWith("Bearer ")) return null;
SignedJWT token;
try {
token = SignedJWT.parse(header.substring(7));
if (!issuer(req).equals(token.getJWTClaimsSet().getIssuer())) return null;
} catch (java.text.ParseException notAJwt) {
return null;
}
try {
Map<String, Object> claims = keys.verify(token);
OAuthPrincipal principal = new OAuthPrincipal((String) claims.get("sub"), (String) claims.get("client_id"), claims);
String resource = resourceAt(req.path());
if (resource != null && principal.hasAudience(issuer(req) + resource)) return principal;
} catch (Exception invalid) {
// Refused below, like a token for another resource.
}
throw INVALID;
}
/** The innermost of this server's resources {@code path} lies in, or {@code null}. */
private String resourceAt(String path) {
String found = null;
for (String resource : resources) {
boolean within = resource.equals("/") || path.equals(resource) || path.startsWith(resource + "/");
if (within && (found == null || resource.length() > found.length())) found = resource;
}
return found;
}
/** Its issuer is {@code "/"}: this application, at whatever origin it is reached by. */
@Override
public List<SecurityScheme> schemes() {
Map<String, Object> flow = Map.of("authorizationUrl", AUTHORIZE, "tokenUrl", TOKEN, "refreshUrl", TOKEN,
"scopes", scopes.stream().collect(Collectors.toMap(scope -> scope, scope -> scope)));
return List.of(new SecurityScheme("oauth", Map.of("type", "oauth2", "flows", Map.of("authorizationCode", flow)), "Bearer realm=\"oauth\"", "/"));
}
// -- Authorization endpoint -----------------------------------------------
/** A validated authorization request. */
private record Authorization(OAuthClient client, String redirectUri, String requestedRedirect, String state, String scope,
String resource, String challenge) {}
/** An RFC 6749 / RFC 7591 error: its code, what to tell the client, and the status to answer with. */
static final class OAuthError extends RuntimeException {
final String error;
final int status;
OAuthError(String error, String description) {
this(400, error, description);
}
OAuthError(int status, String error, String description) {
super(description, null, false, false);
this.error = error;
this.status = status;
}
}
/** A request whose client and redirect URI are good, and which is refused anyway: answered at the redirect URI. */
private static final class Refused extends RuntimeException {
final String error;
final String redirectUri;
final String state;
Refused(String error, String description, String redirectUri, String state) {
super(description, null, false, false);
this.error = error;
this.redirectUri = redirectUri;
this.state = state;
}
}
private Object authorize(Request req, Response res) {
try {
Authorization authorization = authorization(req::query, req);
OAuthSubject subject = subjects.apply(SecurityIdentity.current());
String consented = store.consent(subject.id(), authorization.client().id());
if (consented != null && covers(consented, authorization.scope())) return back(req, res, 302, authorization, subject);
res.redirect(consentPage + "?" + query(req));
} catch (Refused refused) {
refuse(req, res, 302, refused);
}
return null;
}
/** The consent page's answer. SameSite=Lax keeps another site's form from carrying the session here. */
private Object decide(Request req, Response res) {
String origin = req.header("Origin");
if (origin != null && !origin.equals(issuer(req))) throw HttpException.forbidden();
Map<String, String> form = form(req);
try {
Authorization authorization = authorization(form::get, req);
if (!"allow".equals(form.get("consent"))) {
throw new Refused("access_denied", "The user did not allow it", authorization.redirectUri(), authorization.state());
}
OAuthSubject subject = subjects.apply(SecurityIdentity.current());
store.consent(subject.id(), authorization.client().id(), authorization.scope());
return back(req, res, 303, authorization, subject);
} catch (Refused refused) {
refuse(req, res, 303, refused);
return null;
}
}
/** What a consent page shows about the request it was sent. */
private Object request(Request req, Response res) {
try {
Authorization authorization = authorization(req::query, req);
Map<String, Object> view = new LinkedHashMap<>();
view.put("client_id", authorization.client().id());
view.put("client_name", authorization.client().name());
for (String key : List.of("client_uri", "logo_uri")) {
if (authorization.client().metadata().get(key) != null) view.put(key, authorization.client().metadata().get(key));
}
view.put("redirect_uri", authorization.redirectUri());
view.put("scope", authorization.scope());
view.put("resource", authorization.resource());
return json(res, 200, JSONObjectUtils.toJSONString(view));
} catch (Refused refused) {
return json(res, 400, JSONObjectUtils.toJSONString(Map.of("error", refused.error, "error_description", refused.getMessage())));
}
}
/**
* An unknown client or an unregistered redirect URI is refused here and never redirected (OAuth 2.1
* §4.1.2.1): redirecting would hand the response to whoever wrote the URI. Anything else goes back to it.
*/
private Authorization authorization(Function<String, String> param, Request req) {
OAuthClient client = clients.find(param.apply("client_id"));
if (client == null) throw HttpException.badRequest("Unknown client");
String requested = param.apply("redirect_uri");
List<String> registered = client.redirectUris();
String redirectUri = requested == null ? registered.size() == 1 ? registered.getFirst() : null
: registered.stream().anyMatch(uri -> Clients.matches(uri, requested)) ? requested : null;
if (redirectUri == null) throw HttpException.badRequest("Unregistered redirect_uri");
String state = param.apply("state");
if (!"code".equals(param.apply("response_type"))) throw new Refused("unsupported_response_type", "Only code is supported", redirectUri, state);
if (!client.grantTypes().contains("authorization_code")) throw new Refused("unauthorized_client", "Not registered for authorization_code", redirectUri, state);
String challenge = param.apply("code_challenge");
if (challenge == null || !"S256".equals(param.apply("code_challenge_method"))) {
throw new Refused("invalid_request", "PKCE with S256 is required", redirectUri, state);
}
String resource = resource(param.apply("resource"), req);
if (resource == null) throw new Refused("invalid_target", "Unknown resource", redirectUri, state);
return new Authorization(client, redirectUri, requested, state, granted(param.apply("scope")), resource, challenge);
}
/** A code for the authorization, delivered at the client's redirect URI. */
private Object back(Request req, Response res, int status, Authorization authorization, OAuthSubject subject) {
String code = random(32);
store.save(new OAuthGrant(s256(code), OAuthGrant.Kind.CODE, random(16), authorization.client().id(), subject.id(), subject.claims(),
authorization.scope(), authorization.resource(), authorization.requestedRedirect(), authorization.challenge(),
Instant.now().plus(CODE_LIFETIME), null));
res.status(status).header("Location", redirect(authorization.redirectUri(), "code", code, authorization.state(), issuer(req)));
return null;
}
private void refuse(Request req, Response res, int status, Refused refused) {
res.status(status).header("Location", redirect(refused.redirectUri, "error", refused.error, refused.state, issuer(req)));
}
/** RFC 9207: the issuer travels with the response, so a client talking to several servers cannot be mixed up. */
private static String redirect(String uri, String key, String value, String state, String issuer) {
return uri + (uri.indexOf('?') < 0 ? '?' : '&') + key + "=" + encode(value)
+ (state == null ? "" : "&state=" + encode(state)) + "&iss=" + encode(issuer);
}
// -- Token endpoint -------------------------------------------------------
private Object token(Request req, Response res) {
Map<String, String> form = form(req);
OAuthClient client = client(req, form);
String grantType = form.get("grant_type");
if (grantType == null) throw new OAuthError("invalid_request", "grant_type is required");
if (!client.grantTypes().contains(grantType)) throw new OAuthError("unauthorized_client", "Not registered for " + grantType);
return json(res, 200, switch (grantType) {
case "authorization_code" -> exchange(req, client, form);
case "refresh_token" -> refresh(req, client, form);
case "client_credentials" -> {
String resource = resource(form.get("resource"), req);
if (resource == null) throw new OAuthError("invalid_target", "Unknown resource");
yield issue(req, client, client.id(), Map.of(), granted(form.get("scope")), resource, null);
}
default -> throw new OAuthError("unsupported_grant_type", "Unsupported grant_type");
});
}
private String exchange(Request req, OAuthClient client, Map<String, String> form) {
OAuthGrant code = use(form.get("code"), OAuthGrant.Kind.CODE, client);
if (code.redirectUri() != null && !code.redirectUri().equals(form.get("redirect_uri"))) throw new OAuthError("invalid_grant", "redirect_uri does not match");
String verifier = form.get("code_verifier");
if (verifier == null || !MessageDigest.isEqual(s256(verifier).getBytes(StandardCharsets.US_ASCII), code.challenge().getBytes(StandardCharsets.US_ASCII))) {
throw new OAuthError("invalid_grant", "code_verifier does not match");
}
if (form.get("resource") != null && !form.get("resource").equals(code.resource())) throw new OAuthError("invalid_target", "Not the resource authorized");
return issue(req, client, code.subject(), code.claims(), code.scope(), code.resource(), code.family());
}
/** Rotates: the token used is spent, and its successor carries the same authorization — or less of it. */
private String refresh(Request req, OAuthClient client, Map<String, String> form) {
OAuthGrant refresh = use(form.get("refresh_token"), OAuthGrant.Kind.REFRESH, client);
String scope = form.get("scope") == null ? refresh.scope() : form.get("scope");
if (!covers(refresh.scope(), scope)) throw new OAuthError("invalid_scope", "More than was authorized");
if (form.get("resource") != null && !form.get("resource").equals(refresh.resource())) throw new OAuthError("invalid_target", "Not the resource authorized");
return issue(req, client, refresh.subject(), refresh.claims(), scope, refresh.resource(), refresh.family());
}
/** A code or refresh token spent now. One already spent revokes everything issued from the same authorization. */
private OAuthGrant use(String value, OAuthGrant.Kind kind, OAuthClient client) {
OAuthGrant grant = value == null ? null : store.use(s256(value));
if (grant == null || grant.kind() != kind || !grant.clientId().equals(client.id())) throw new OAuthError("invalid_grant", "Unknown " + kind.name().toLowerCase());
if (grant.usedAt() != null) {
store.revoke(grant.family());
throw new OAuthError("invalid_grant", "Already used: every token from this authorization is revoked");
}
if (grant.expiresAt().isBefore(Instant.now())) throw new OAuthError("invalid_grant", "Expired");
return grant;
}
/** @param family {@code null} for {@code client_credentials}, which gets no refresh token */
private String issue(Request req, OAuthClient client, String subject, Map<String, Object> extra, String scope, String resource, String family) {
Instant now = Instant.now();
Map<String, Object> claims = new HashMap<>(extra);
claims.putAll(Map.of("iss", issuer(req), "sub", subject, "aud", resource, "client_id", client.id(), "jti", random(16),
"iat", Date.from(now), "exp", Date.from(now.plus(accessTokenLifetime))));
if (!scope.isEmpty()) claims.put("scope", scope);
Map<String, Object> response = new LinkedHashMap<>();
response.put("access_token", keys.sign(claims));
response.put("token_type", "Bearer");
response.put("expires_in", accessTokenLifetime.toSeconds());
if (!scope.isEmpty()) response.put("scope", scope);
if (family != null && client.grantTypes().contains("refresh_token")) {
String refresh = random(32);
store.save(new OAuthGrant(s256(refresh), OAuthGrant.Kind.REFRESH, family, client.id(), subject, extra, scope, resource,
null, null, now.plus(refreshTokenLifetime), null));
response.put("refresh_token", refresh);
}
return JSONObjectUtils.toJSONString(response);
}
// -- Revocation and registration ------------------------------------------
/** RFC 7009: a refresh token takes its whole authorization with it. An access token simply expires; an unknown token is no error. */
private Object revoke(Request req, Response res) {
Map<String, String> form = form(req);
OAuthClient client = client(req, form);
OAuthGrant grant = form.get("token") == null ? null : store.find(s256(form.get("token")));
if (grant != null && !grant.clientId().equals(client.id())) throw new OAuthError("unauthorized_client", "Not this client's token");
if (grant != null) store.revoke(grant.family());
res.status(200);
return null;
}
private Object registerSelf(Request req, Response res) {
Map<String, Object> metadata;
try {
metadata = JSONObjectUtils.parse(new String(req.body().bytes(), StandardCharsets.UTF_8));
} catch (java.text.ParseException malformed) {
throw new OAuthError("invalid_client_metadata", "Not a JSON object");
}
Registration registration = register(metadata, false);
Map<String, Object> response = new LinkedHashMap<>(registration.client().metadata());
response.put("client_id", registration.client().id());
response.put("client_id_issued_at", Instant.now().getEpochSecond());
if (registration.secret() != null) {
response.put("client_secret", registration.secret());
response.put("client_secret_expires_at", 0);
}
return json(res, 201, JSONObjectUtils.toJSONString(response));
}
// -- Metadata ---------------------------------------------------------------
/** RFC 8414, plus what the MCP authorization spec asks a server to say about client ID metadata documents. */
private String describe(String issuer) {
Map<String, Object> metadata = new LinkedHashMap<>();
metadata.put("issuer", issuer);
metadata.put("authorization_endpoint", issuer + AUTHORIZE);
metadata.put("token_endpoint", issuer + TOKEN);
if (registration) metadata.put("registration_endpoint", issuer + REGISTER);
metadata.put("revocation_endpoint", issuer + REVOKE);
metadata.put("jwks_uri", issuer + JWKS);
if (!scopes.isEmpty()) metadata.put("scopes_supported", List.copyOf(scopes));
metadata.put("response_types_supported", List.of("code"));
metadata.put("response_modes_supported", List.of("query"));
metadata.put("grant_types_supported", List.of("authorization_code", "refresh_token", "client_credentials"));
metadata.put("code_challenge_methods_supported", List.of("S256"));
metadata.put("token_endpoint_auth_methods_supported", Clients.AUTH_METHODS);
metadata.put("revocation_endpoint_auth_methods_supported", Clients.AUTH_METHODS);
metadata.put("authorization_response_iss_parameter_supported", true);
metadata.put("client_id_metadata_document_supported", true);
return JSONObjectUtils.toJSONString(metadata);
}
// -- Helpers ----------------------------------------------------------------
/** RFC 6749 §5.2: every failure is a JSON error with its code; nothing here is ever cached. */
private static SimpleHandler.FunctionalHandler endpoint(SimpleHandler.FunctionalHandler handler) {
return (req, res) -> {
res.header("Cache-Control", "no-store");
try {
return handler.handle(req, res);
} catch (OAuthError invalid) {
if (invalid.status == 401) res.header("WWW-Authenticate", "Basic realm=\"oauth\"");
return json(res, invalid.status, JSONObjectUtils.toJSONString(Map.of("error", invalid.error, "error_description", invalid.getMessage())));
}
};
}
/**
* The client making a token or revocation request, authenticated as it registered: a secret in the
* {@code Authorization} header, a secret in the form, or — a public client — its id alone.
*/
private OAuthClient client(Request req, Map<String, String> form) {
String header = req.header("Authorization");
String id = form.get("client_id");
String secret = form.get("client_secret");
String method = secret != null ? "client_secret_post" : "none";
if (header != null && header.startsWith("Basic ")) {
String[] pair = new String(Base64.getDecoder().decode(header.substring(6)), StandardCharsets.UTF_8).split(":", 2);
if (pair.length != 2) throw new OAuthError(401, "invalid_client", "Malformed Basic credentials");
id = URLDecoder.decode(pair[0], StandardCharsets.UTF_8);
secret = URLDecoder.decode(pair[1], StandardCharsets.UTF_8);
method = "client_secret_basic";
}
OAuthClient client = clients.find(id);
boolean authenticated = client != null && client.authMethod().equals(method)
&& (method.equals("none") || MessageDigest.isEqual(s256(secret).getBytes(StandardCharsets.US_ASCII), client.secretHash().getBytes(StandardCharsets.US_ASCII)));
if (!authenticated) throw new OAuthError(401, "invalid_client", "Client authentication failed");
return client;
}
/** RFC 8707: one of this application's resources, or the first when the request names none. */
private String resource(String requested, Request req) {
String origin = issuer(req);
if (requested == null) return origin + resources.getFirst();
return resources.stream().map(path -> origin + path).filter(requested::equals).findFirst().orElse(null);
}
/** What was asked for, less what this server does not grant. */
private String granted(String requested) {
if (requested == null) return "";
return Arrays.stream(requested.split(" ")).filter(scopes::contains).distinct().collect(Collectors.joining(" "));
}
private static boolean covers(String granted, String requested) {
return List.of(granted.split(" ")).containsAll(List.of(requested.split(" ")));
}
private String issuer(Request req) {
return security.origin(req);
}
private static Object json(Response res, int status, String body) {
res.status(status).type(ContentType.JSON);
return body;
}
private static Map<String, String> form(Request req) {
Map<String, String> fields = new HashMap<>();
for (String pair : new String(req.body().bytes(), StandardCharsets.UTF_8).split("&")) {
int eq = pair.indexOf('=');
if (eq > 0) fields.putIfAbsent(URLDecoder.decode(pair.substring(0, eq), StandardCharsets.UTF_8), URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8));
}
return fields;
}
private static String query(Request req) {
ByteView query = req.getRequestLine().getQuery();
byte[] raw = new byte[query == null ? 0 : query.length()];
for (int i = 0; i < raw.length; i++) raw[i] = query.byteAt(i);
return new String(raw, StandardCharsets.UTF_8);
}
/**
* PKCE's {@code S256}, and how codes, refresh tokens and client secrets are stored: they are 256 random
* bits, so a slow KDF would protect nothing.
*/
private static String s256(String verifier) {
try {
return BASE64URL.encodeToString(MessageDigest.getInstance("SHA-256").digest(verifier.getBytes(StandardCharsets.US_ASCII)));
} catch (java.security.NoSuchAlgorithmException impossible) {
throw new IllegalStateException(impossible);
}
}
private static String random(int bytes) {
byte[] value = new byte[bytes];
RANDOM.nextBytes(value);
return BASE64URL.encodeToString(value);
}
private static String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
}
@@ -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);
}
@@ -0,0 +1,14 @@
package dev.relism.flash.ext.security.oauthserver;
import java.util.Map;
/**
* Whom an authorization is for, as the application names them: {@code id} becomes the access token's
* {@code sub}, {@code claims} travel in every token issued from it. Standard claims win over these.
*/
public record OAuthSubject(String id, Map<String, Object> claims) {
public OAuthSubject {
claims = Map.copyOf(claims);
}
}
@@ -0,0 +1,78 @@
package dev.relism.flash.ext.security.oauthserver;
import com.nimbusds.jose.JOSEObjectType;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.crypto.ECDSASigner;
import com.nimbusds.jose.jwk.Curve;
import com.nimbusds.jose.jwk.ECKey;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.KeyUse;
import com.nimbusds.jose.jwk.gen.ECKeyGenerator;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.proc.DefaultJOSEObjectTypeVerifier;
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier;
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
/**
* The server's ES256 keys: the first signs, all verify, so a key is rotated by putting its successor first
* and dropping it once the tokens it signed have expired. Access tokens are RFC 9068 JWTs, {@code typ} {@code at+jwt}.
*/
final class SigningKeys {
private final ECKey signing;
private final String publicJwks;
private final DefaultJWTProcessor<SecurityContext> verifier = new DefaultJWTProcessor<>();
SigningKeys(String jwkSet) {
try {
JWKSet keys = JWKSet.parse(jwkSet);
signing = keys.getKeys().getFirst().toECKey();
if (!signing.isPrivate() || !Curve.P_256.equals(signing.getCurve())) throw new IllegalArgumentException("The first key must be a private P-256 key");
publicJwks = keys.toPublicJWKSet().toString();
verifier.setJWSTypeVerifier(new DefaultJOSEObjectTypeVerifier<>(new JOSEObjectType("at+jwt")));
verifier.setJWSKeySelector(new JWSVerificationKeySelector<>(JWSAlgorithm.ES256, new ImmutableJWKSet<>(keys.toPublicJWKSet())));
verifier.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(null, Set.of("iss", "sub", "aud", "exp", "client_id")));
} catch (java.text.ParseException | ClassCastException invalid) {
throw new IllegalArgumentException("Not a JWK set with a private EC key first", invalid);
}
}
/** A JWK set holding one new private P-256 key — what {@link OAuthServerExtension#signingKeys(String)} takes. */
static String generate() {
try {
return new JWKSet(new ECKeyGenerator(Curve.P_256).keyUse(KeyUse.SIGNATURE).keyID(UUID.randomUUID().toString()).generate()).toString(false);
} catch (com.nimbusds.jose.JOSEException impossible) {
throw new IllegalStateException(impossible);
}
}
String sign(Map<String, Object> claims) {
try {
JWTClaimsSet.Builder set = new JWTClaimsSet.Builder();
claims.forEach(set::claim);
SignedJWT jwt = new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.ES256).type(new JOSEObjectType("at+jwt")).keyID(signing.getKeyID()).build(), set.build());
jwt.sign(new ECDSASigner(signing));
return jwt.serialize();
} catch (com.nimbusds.jose.JOSEException impossible) {
throw new IllegalStateException(impossible);
}
}
/** The token's claims, once its type, signature, expiry and required claims check out. */
Map<String, Object> verify(SignedJWT token) throws Exception {
return verifier.process(token, null).getClaims();
}
String publicJwks() {
return publicJwks;
}
}
@@ -0,0 +1,265 @@
package dev.relism.flash.ext.security.oauthserver;
import com.nimbusds.jose.util.JSONObjectUtils;
import com.sun.net.httpserver.HttpServer;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.ext.cache.caffeine.CaffeineCacheExtension;
import dev.relism.flash.ext.mcp.McpConfig;
import dev.relism.flash.ext.mcp.McpExtension;
import dev.relism.flash.ext.security.Principal;
import dev.relism.flash.ext.security.SecurityExtension;
import dev.relism.flash.ext.security.SecurityIdentity;
import dev.relism.flash.ext.security.SecurityPolicy;
import dev.relism.flash.ext.security.test.OAuthTestClient;
import dev.relism.flash.ext.security.test.TestSecurity;
import dev.relism.flash.testing.FlashResponse;
import dev.relism.flash.testing.FlashTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class OAuthServerExtensionTest {
static final Principal ALICE = () -> "alice";
static final SecurityExtension security = new SecurityExtension().loginPage("/login");
static final OAuthServerExtension server = new OAuthServerExtension("/mcp", "/api").scopes("read", "write").allowLocalClients()
.subjects(identity -> new OAuthSubject(identity.principal().name(), Map.of("tenant", "acme")));
/** /api only takes this server's tokens; /mcp is an OAuth-protected MCP endpoint trusting nothing else. */
@RegisterExtension
static final FlashTest app = FlashTest.of(flash -> flash
.install(security).install(new CaffeineCacheExtension()).install(server).install(new TestSecurity())
.install(new McpExtension(McpConfig.builder("test").toolsPackage("dev.relism.flash.ext.security.oauthserver.tools").mechanisms(server).build()))
.get("/api/me", (req, res) -> {
OAuthPrincipal principal = SecurityIdentity.current().principal(OAuthPrincipal.class);
return principal.name() + " " + principal.claim("tenant") + " " + principal.claim("scope");
}, security.enforce(SecurityPolicy.AUTHENTICATED, (req, res) -> { throw HttpException.unauthorized(); }, List.of(server))));
String origin() {
return "http://127.0.0.1:" + app.port();
}
@Test
void theMetadataDescribesACompliantServer() {
app.get("/.well-known/oauth-authorization-server").expectStatus(200)
.expectBodyContains("\"issuer\":\"" + origin() + "\"")
.expectBodyContains("\"code_challenge_methods_supported\":[\"S256\"]")
.expectBodyContains("\"authorization_response_iss_parameter_supported\":true")
.expectBodyContains("\"client_id_metadata_document_supported\":true")
.expectHeader("Access-Control-Allow-Origin", "*");
app.get("/oauth/jwks").expectStatus(200).expectBodyContains("\"crv\":\"P-256\"");
assertTrue(!app.get("/oauth/jwks").body().contains("\"d\""), "the private key never leaves");
}
/** What an MCP client does end to end: discovery from the resource, registration, consent, and a token for this endpoint only. */
@Test
void anMcpClientIsSentHereAndItsTokenWorksOnTheEndpoint() {
app.get("/.well-known/oauth-protected-resource/mcp").expectStatus(200)
.expectBody("{\"resource\":\"" + origin() + "/mcp\",\"authorization_servers\":[\"" + origin() + "\"]}");
OAuthTestClient.Tokens tokens = OAuthTestClient.register(app).authorize(ALICE);
app.request().with(tokens.bearer()).header("Accept", "application/json")
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"whoami\"}}")
.post("/mcp").expectStatus(200).expectBodyContains("alice");
// The default resource is /mcp: a token is good under the resource it was issued for and nowhere else.
app.request().with(tokens.bearer()).get("/api/me").expectStatus(401);
OAuthTestClient.Tokens api = OAuthTestClient.register(app).authorize(ALICE, Map.of("resource", origin() + "/api"));
app.request().with(api.bearer()).get("/api/me").expectStatus(200);
app.request().with(api.bearer()).header("Accept", "application/json")
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}").post("/mcp").expectStatus(401);
}
@Test
void consentIsAskedOnceAndTheSubjectsClaimsTravel() {
OAuthTestClient client = OAuthTestClient.register(app);
OAuthTestClient.Tokens tokens = client.authorize(ALICE, Map.of("scope", "read admin", "resource", origin() + "/api"));
assertEquals("read", tokens.scope(), "a scope this server does not grant is left out");
app.request().with(tokens.bearer()).get("/api/me").expectStatus(200).expectBody("alice acme read");
String authorize = "/oauth/authorize?response_type=code&client_id=" + client.clientId() + "&code_challenge=x&code_challenge_method=S256&scope=read";
assertTrue(app.request().with(TestSecurity.as(ALICE)).get(authorize).expectStatus(302).header("Location").startsWith(OAuthTestClient.REDIRECT_URI));
assertTrue(app.request().with(TestSecurity.as(ALICE)).get(authorize + "%20write").expectStatus(302).header("Location").startsWith("/consent?"),
"more than was consented to is asked again");
app.request().with(TestSecurity.as(ALICE)).get("/oauth/authorize/request?" + authorize.substring(authorize.indexOf('?') + 1))
.expectStatus(200).expectBodyContains("\"client_name\":\"Test client\"");
}
@Test
void aBrowserWithoutASessionSignsInFirst() {
app.request().header("Accept", "text/html").get("/oauth/authorize?client_id=x").expectStatus(302)
.expectHeader("Location", "/login?redirect=%2Foauth%2Fauthorize%3Fclient_id%3Dx");
}
/** OAuth 2.1 §4.1.2.1: an unknown client or redirect URI is never redirected to; anything else is reported there. */
@Test
void refusalsGoToTheRedirectUriOnlyOnceItIsTrusted() {
String client = OAuthTestClient.register(app).clientId();
app.request().with(TestSecurity.as(ALICE)).get("/oauth/authorize?response_type=code&client_id=nobody").expectStatus(400);
app.request().with(TestSecurity.as(ALICE)).get("/oauth/authorize?response_type=code&client_id=" + client + "&redirect_uri=https%3A%2F%2Fevil.example%2F")
.expectStatus(400);
String plain = app.request().with(TestSecurity.as(ALICE))
.get("/oauth/authorize?response_type=code&client_id=" + client + "&state=s&code_challenge=x&code_challenge_method=plain")
.expectStatus(302).header("Location");
assertEquals(OAuthTestClient.REDIRECT_URI + "?error=invalid_request&state=s&iss=" + encode(origin()), plain);
String elsewhere = app.request().with(TestSecurity.as(ALICE))
.get("/oauth/authorize?response_type=code&client_id=" + client + "&code_challenge=x&code_challenge_method=S256&resource=https%3A%2F%2Fother.example%2Fmcp")
.expectStatus(302).header("Location");
assertTrue(elsewhere.contains("error=invalid_target"), elsewhere);
// RFC 8252: a loopback redirect may come back on any port.
assertTrue(app.request().with(TestSecurity.as(ALICE))
.get("/oauth/authorize?response_type=code&client_id=" + client + "&redirect_uri=http%3A%2F%2F127.0.0.1%3A53123%2Fcallback&code_challenge=x&code_challenge_method=S256")
.expectStatus(302).header("Location").startsWith("/consent?"));
}
@Test
void aDeniedConsentAndAForeignFormAreRefused() {
String client = OAuthTestClient.register(app).clientId();
String query = "response_type=code&client_id=" + client + "&code_challenge=x&code_challenge_method=S256";
app.request().with(TestSecurity.as(ALICE)).header("Content-Type", "application/x-www-form-urlencoded").body(query + "&consent=deny")
.post("/oauth/authorize").expectStatus(303).expectHeader("Location", OAuthTestClient.REDIRECT_URI + "?error=access_denied&iss=" + encode(origin()));
app.request().with(TestSecurity.as(ALICE)).header("Origin", "https://evil.example").header("Content-Type", "application/x-www-form-urlencoded")
.body(query + "&consent=allow").post("/oauth/authorize").expectStatus(403);
}
/** A refresh token is spent by its use; spending it twice means it leaked, and the whole authorization goes. */
@Test
void refreshTokensRotateAndAReuseRevokesTheFamily() {
OAuthTestClient client = OAuthTestClient.register(app);
OAuthTestClient.Tokens first = client.authorize(ALICE);
OAuthTestClient.Tokens second = client.refresh(first.refreshToken());
assertNotEquals(first.refreshToken(), second.refreshToken());
token("grant_type=refresh_token&client_id=" + client.clientId() + "&refresh_token=" + first.refreshToken())
.expectStatus(400).expectBodyContains("invalid_grant");
token("grant_type=refresh_token&client_id=" + client.clientId() + "&refresh_token=" + second.refreshToken())
.expectStatus(400).expectBodyContains("invalid_grant");
}
@Test
void aRevokedRefreshTokenStopsWorking() {
OAuthTestClient client = OAuthTestClient.register(app);
OAuthTestClient.Tokens tokens = client.authorize(ALICE);
app.request().header("Content-Type", "application/x-www-form-urlencoded")
.body("client_id=" + client.clientId() + "&token=" + tokens.refreshToken()).post("/oauth/revoke").expectStatus(200);
token("grant_type=refresh_token&client_id=" + client.clientId() + "&refresh_token=" + tokens.refreshToken()).expectStatus(400);
}
@Test
void aClientRegisteringItselfIsHeldToTheRules() {
register("{\"redirect_uris\":[\"http://evil.example/cb\"]}").expectStatus(400).expectBodyContains("invalid_redirect_uri");
register("{\"redirect_uris\":[\"https://app.example/cb#x\"]}").expectStatus(400).expectBodyContains("invalid_redirect_uri");
register("{\"grant_types\":[\"client_credentials\"],\"token_endpoint_auth_method\":\"client_secret_basic\"}")
.expectStatus(400).expectBodyContains("invalid_client_metadata");
register("{\"redirect_uris\":[\"https://app.example/cb\"]}").expectStatus(201)
.expectBodyContains("\"token_endpoint_auth_method\":\"client_secret_basic\"").expectBodyContains("\"client_secret\"");
}
/** A machine the application registered itself: its secret in Basic, its own id as the subject, no refresh token. */
@Test
void aConfidentialClientGetsATokenForItself() throws Exception {
OAuthServerExtension.Registration machine = server.register(Map.of("client_name", "Sync job", "grant_types", List.of("client_credentials"),
"token_endpoint_auth_method", "client_secret_basic"));
String basic = java.util.Base64.getEncoder().encodeToString((machine.client().id() + ":" + machine.secret()).getBytes(StandardCharsets.UTF_8));
Map<String, Object> response = JSONObjectUtils.parse(app.request().header("Authorization", "Basic " + basic)
.header("Content-Type", "application/x-www-form-urlencoded").body("grant_type=client_credentials&scope=write&resource=" + encode(origin() + "/api"))
.post("/oauth/token").expectStatus(200).expectHeader("Cache-Control", "no-store").body());
assertNull(response.get("refresh_token"));
app.request().header("Authorization", "Bearer " + response.get("access_token")).get("/api/me").expectStatus(200)
.expectBody(machine.client().id() + " null write");
String wrong = java.util.Base64.getEncoder().encodeToString((machine.client().id() + ":nope").getBytes(StandardCharsets.UTF_8));
app.request().header("Authorization", "Basic " + wrong).header("Content-Type", "application/x-www-form-urlencoded")
.body("grant_type=client_credentials").post("/oauth/token").expectStatus(401).expectBodyContains("invalid_client");
}
/** A code is good once, with its own verifier; a replay revokes what the first exchange issued. */
@Test
void aCodeNeedsItsVerifierAndCannotBeReplayed() {
OAuthTestClient client = OAuthTestClient.register(app);
client.authorize(ALICE);
String authorize = "/oauth/authorize?response_type=code&client_id=" + client.clientId() + "&code_challenge=" + s256("right") + "&code_challenge_method=S256";
String location = app.request().with(TestSecurity.as(ALICE)).get(authorize).expectStatus(302).header("Location");
String code = location.replaceAll(".*code=([^&]+).*", "$1");
String exchange = "grant_type=authorization_code&client_id=" + client.clientId() + "&code=" + code + "&code_verifier=";
token(exchange + "wrong").expectStatus(400).expectBodyContains("invalid_grant");
String code2 = app.request().with(TestSecurity.as(ALICE)).get(authorize).expectStatus(302).header("Location").replaceAll(".*code=([^&]+).*", "$1");
String issued = token("grant_type=authorization_code&client_id=" + client.clientId() + "&code=" + code2 + "&code_verifier=right").expectStatus(200).body();
String refresh = issued.replaceAll(".*\"refresh_token\":\"([^\"]+)\".*", "$1");
token("grant_type=authorization_code&client_id=" + client.clientId() + "&code=" + code2 + "&code_verifier=right").expectStatus(400);
token("grant_type=refresh_token&client_id=" + client.clientId() + "&refresh_token=" + refresh).expectStatus(400);
}
@Test
void aForgedTokenIsInvalidAndOtherBearersAreNotThisServers() {
OAuthTestClient.Tokens tokens = OAuthTestClient.register(app).authorize(ALICE, Map.of("resource", origin() + "/api"));
String forged = tokens.accessToken().substring(0, tokens.accessToken().length() - 4) + "AAAA";
app.request().header("Authorization", "Bearer " + forged).get("/api/me").expectStatus(401);
app.request().header("Authorization", "Bearer gk_not.a-jwt").get("/api/me").expectStatus(401);
}
/** MCP's preferred registration: the client is the https URL of its own metadata document. */
@Test
void aClientMetadataDocumentIsAClient() throws Exception {
HttpServer host = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
String url = "http://127.0.0.1:" + host.getAddress().getPort() + "/client.json";
String liar = "http://127.0.0.1:" + host.getAddress().getPort() + "/liar.json";
serve(host, "/client.json", "{\"client_id\":\"" + url + "\",\"client_name\":\"Documented\",\"redirect_uris\":[\"" + OAuthTestClient.REDIRECT_URI + "\"]}");
serve(host, "/liar.json", "{\"client_id\":\"https://someone.else/client.json\",\"redirect_uris\":[\"" + OAuthTestClient.REDIRECT_URI + "\"]}");
host.start();
try {
String query = "response_type=code&code_challenge=x&code_challenge_method=S256&client_id=";
app.request().with(TestSecurity.as(ALICE)).get("/oauth/authorize/request?" + query + encode(url)).expectStatus(200)
.expectBodyContains("\"client_name\":\"Documented\"");
app.request().with(TestSecurity.as(ALICE)).get("/oauth/authorize?" + query + encode(liar)).expectStatus(400);
// A document that fails is remembered as failing: naming it again fetches nothing.
app.request().with(TestSecurity.as(ALICE)).get("/oauth/authorize?" + query + encode(liar)).expectStatus(400);
assertEquals(1, fetched.get(liar));
} finally {
host.stop(0);
}
}
private static FlashResponse token(String form) {
return app.request().header("Content-Type", "application/x-www-form-urlencoded").body(form).post("/oauth/token");
}
private static FlashResponse register(String json) {
return app.request().header("Content-Type", "application/json").body(json).post("/oauth/register");
}
static final Map<String, Integer> fetched = new java.util.concurrent.ConcurrentHashMap<>();
private static void serve(HttpServer host, String path, String body) {
host.createContext(path, exchange -> {
fetched.merge("http://127.0.0.1:" + host.getAddress().getPort() + path, 1, Integer::sum);
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, bytes.length);
try (var out = exchange.getResponseBody()) {
out.write(bytes);
}
});
}
private static String s256(String verifier) {
try {
return java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(
java.security.MessageDigest.getInstance("SHA-256").digest(verifier.getBytes(StandardCharsets.US_ASCII)));
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
private static String encode(String value) {
return java.net.URLEncoder.encode(value, StandardCharsets.UTF_8);
}
}
@@ -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()));
}
}