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