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>
flash-ext-security-oauth-server
An OAuth 2.1 authorization server for flash-ext-security-core:
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.
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.
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 registers,
authorizes as any principal, consents and exchanges, discovering every endpoint from the metadata.