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:
@@ -0,0 +1,98 @@
|
||||
# flash-ext-auth-oidc
|
||||
|
||||
OpenID Connect for Flash: the authorization-code flow with PKCE, JWKS-validated bearer tokens,
|
||||
server-side sessions with silent refresh, and single logout.
|
||||
|
||||
It is a **credential source** for [`flash-ext-auth-core`](../../flash-ext-auth-core/docs/README.md),
|
||||
which owns everything downstream of "who is this caller" — `@Authenticated`, `@RolesAllowed`,
|
||||
`@ScopesAllowed`, `ClaimsHolder`. Installing this extension installs that machinery too; you do not
|
||||
install `flash-ext-auth-core` yourself.
|
||||
|
||||
## Quick start
|
||||
|
||||
```java
|
||||
app.install(new OidcExtension(
|
||||
OidcConfig.builder(
|
||||
"https://keycloak.example.com/realms/myrealm",
|
||||
"my-app", "secret", "/auth/callback")
|
||||
.rolesClaimPath("realm_access.roles")
|
||||
.https()
|
||||
.build()));
|
||||
```
|
||||
|
||||
That is the whole integration. Discovery runs at boot and fails fast if the issuer is unreachable,
|
||||
so a misconfigured provider is a startup crash rather than a 500 on the first login.
|
||||
|
||||
`OidcConfig.fromEnv()` reads the same settings from `OIDC_*` environment variables, and
|
||||
`OidcConfig.keycloak(serverUrl, realm, ...)` builds the issuer URL for you.
|
||||
|
||||
## What it registers
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `GET {prefix}/login` | Builds the authorization URL with PKCE + state and redirects |
|
||||
| `GET {prefix}/callback` | Validates state and nonce, exchanges the code, creates the session |
|
||||
| `POST {prefix}/logout` | Ends the session and redirects to the provider's end-session endpoint |
|
||||
|
||||
`{prefix}` is `routePrefix` (default `/auth`). Logout is a `POST` on purpose — a `GET` logout is
|
||||
one `<img>` tag away from being triggered by any page the user visits.
|
||||
|
||||
In the context it provides `AuthMiddleware` (from auth-core), `OidcCredentialSource` and
|
||||
`JwtValidator`.
|
||||
|
||||
## How a request is resolved
|
||||
|
||||
1. `Authorization: Bearer …` — validated against the issuer's JWKS.
|
||||
2. `oidc_session` cookie — looked up in the `SessionStore`; if the access token has expired and a
|
||||
refresh token is present, refreshed transparently and the session replaced.
|
||||
3. Neither, and the client sent `Accept: application/json` → `401` with a
|
||||
`WWW-Authenticate: Bearer` challenge.
|
||||
4. Neither, and it looks like a browser → redirect to `{prefix}/login?redirect={path}`.
|
||||
|
||||
Points 3 and 4 are why the source distinguishes "no credential" from "bad credential": an API
|
||||
client must not be redirected into an HTML sign-in page, and a browser must not be left staring at
|
||||
a bare 401.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Setting | Default | Notes |
|
||||
|---|---|---|
|
||||
| `issuer`, `clientId`, `clientSecret`, `redirectUri` | — | required |
|
||||
| `scopes` | `openid profile email` | |
|
||||
| `routePrefix` | `/auth` | |
|
||||
| `selfScheme` | `http` | `https()` behind TLS; only used when no `X-Forwarded-Proto` |
|
||||
| `rolesClaimPath` | `realm_access.roles` | Keycloak's spelling; `groups` for Authelia |
|
||||
| `scopeClaimPaths` | `scope,scp` | comma-separated, tried in order |
|
||||
| `algorithm` | `RS256` | |
|
||||
| `postLogoutRedirectUri` | `/` | |
|
||||
| `sessionStore` | `InMemorySessionStore` | swap for Redis/JDBC across instances |
|
||||
| `clientAuthMethod` | `POST` | token endpoint client authentication |
|
||||
| `insecureTls()` | off | dev only, skips certificate validation |
|
||||
| `schemeName` | derived from the issuer | OpenAPI security scheme name |
|
||||
|
||||
A relative `redirectUri` (starting with `/`) is resolved per request against the incoming `Host`,
|
||||
or `X-Forwarded-Host`/`-Proto` when behind a proxy — so one build works in dev and behind TLS
|
||||
without a second configuration.
|
||||
|
||||
## Sessions
|
||||
|
||||
A session holds the claims plus the access, id and refresh tokens, the last three in
|
||||
`Session.attributes()` under this extension's own keys. Core never reads them; renewal happens
|
||||
here. See [`../../flash-ext-auth-core/docs/sessions.md`](../../flash-ext-auth-core/docs/sessions.md).
|
||||
|
||||
## Multiple providers
|
||||
|
||||
Two issuers on one server, each with its own route prefix:
|
||||
|
||||
```java
|
||||
app.install(new OidcExtension(tenantAConfig)) // routePrefix("/tenantA/auth")
|
||||
.install(new OidcExtension(tenantBConfig)); // routePrefix("/tenantB/auth")
|
||||
```
|
||||
|
||||
Both are known at boot. Registering an issuer at runtime — a customer connecting their own IdP from
|
||||
a settings page — is not supported.
|
||||
|
||||
## Interop
|
||||
|
||||
See [`interop.md`](interop.md) for how this extension fits with `flash-ext-auth-core`,
|
||||
`flash-ext-openapi` and `flash-ext-mcp`.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user