feat(ext-mcp): let applications put middleware on the MCP route, and document the auth split

McpExtension built its middleware chain entirely internally, so a consumer had no
way to add rate limiting, audit logging or tracing to /mcp — routine on every other
Flash route. McpConfig.middleware(...) appends to the chain after the transport
guards and after whatever McpSecurity resolved to, so it composes with OAuth2
protection instead of replacing it, and never satisfies REQUIRED.

Docs: flash-ext-auth-core and flash-ext-auth-oidc both get a docs/ directory —
oidc had none at all, and its module README documented types that no longer exist.
Includes a migration table from flash-ext-oidc.
This commit is contained in:
Zakaria El Orche
2026-09-10 19:12:44 +00:00
parent 9d39e24ccb
commit 829b9bf348
11 changed files with 590 additions and 39 deletions
@@ -0,0 +1,88 @@
# Interop
## flash-ext-auth-core
A hard dependency, and the reason this extension is as small as it is. The division:
| Here | `flash-ext-auth-core` |
|---|---|
| Discovery, JWKS, PKCE, token endpoint | `@Authenticated`, `@RolesAllowed`, `@ScopesAllowed` |
| `/login`, `/callback`, `/logout` | `ClaimsHolder`, `Claims` |
| Bearer and cookie resolution, refresh | Role and scope matching |
| RFC 6750 `WWW-Authenticate` challenges | `Session`, `SessionStore` |
`OidcExtension` builds an `OidcCredentialSource`, hands it to `AuthMiddleware.install(...)`, and
that publishes the middleware and registers the annotation processor. Everything a handler
annotation does is core's code running against claims this extension produced.
Consequence worth knowing: `@RolesAllowed` is not OIDC-specific and never was. An app that swaps
this extension for another credential source keeps every annotation it had.
## flash-ext-openapi
Optional, and resolved lazily so this extension runs standalone when openapi is not on the
classpath. When it is, an `OpenApiContributor` is registered that emits an `oauth2` security scheme
with the `authorizationCode` flow, filled in from the discovery document:
```json
"securitySchemes": {
"myrealm": {
"type": "oauth2",
"flows": { "authorizationCode": { "authorizationUrl": "…", "tokenUrl": "…", "scopes": {} } }
}
}
```
Per-operation security comes from the same annotations the middleware reads, so the spec and the
enforcement cannot drift: both call `AuthPolicy.compileFromAnnotations`.
The scheme name is `schemeName`, derived from the last path segment of the issuer (a Keycloak realm
name, usually) unless set explicitly.
## flash-ext-mcp
`McpSecurity` asks whether **this** extension is installed — `ctx.find(OidcCredentialSource.class)`
— and not merely whether something authenticates:
| Policy | this extension installed | absent |
|---|---|---|
| `REQUIRED` | protected | **boot fails** |
| `AUTO` | protected | unprotected, warning logged |
| `NONE` | never protected | unprotected |
That distinction is deliberate. `REQUIRED` means "a real OAuth2 authorization server is protecting
this endpoint", because everything it turns on — RFC 9728 Protected Resource Metadata, RFC 8707
audience binding, `WWW-Authenticate` challenges carrying `resource_metadata` — is meaningless
without an issuer. An app that authenticates some other way must not satisfy it by accident.
When it is installed, `McpOidcIntegration` derives the whole resource-server configuration from the
source with no extra `McpConfig` calls:
- the MCP route is wrapped with `authMw.withSource(source.withResourceMetadata(path)).protect()`
the same validation every other route uses, plus the `resource_metadata` challenge parameter;
- an audience guard runs after it and rejects any token whose `aud` does not include this
endpoint's resource identifier;
- the resource identifier is resolved per request from `X-Forwarded-Host`/`-Proto`, or the `Host`
header and `selfScheme`.
An app that does **not** use OAuth2 can still guard `/mcp`: set `McpSecurity.NONE` and pass its own
guard to `McpConfig.middleware(...)`.
## Migrating from flash-ext-oidc
The module was renamed and its generic half moved. Mechanically:
| Was | Now |
|---|---|
| `flash-ext-oidc` (artifact) | `flash-ext-auth-oidc` |
| `dev.relism.flash.ext.oidc.Authenticated` (and `RolesAllowed`, `ScopesAllowed`) | `dev.relism.flash.ext.auth.…` |
| `OidcMiddleware` | `AuthMiddleware` (`dev.relism.flash.ext.auth`) |
| `ctx.find(OidcMiddleware.class)` | `ctx.find(AuthMiddleware.class)` |
| `OidcUser` | `Claims` |
| `ClaimsHolder.user()` | `ClaimsHolder.current()` |
| `ClaimsHolder.get()` | `ClaimsHolder.map()` |
| `OidcSession`, `OidcSessionStore`, `InMemoryOidcSessionStore` | `Session`, `SessionStore`, `InMemorySessionStore` |
| `session.isAccessTokenExpired()` | `session.isExpired()` |
| `session.idToken()` | `session.attributeAsString(OidcCredentialSource.ID_TOKEN)` |
`OidcConfig`, `OidcExtension` and every setting on them are unchanged.