refactor(ext-auth): generic sessions, shared annotation wiring, rename to flash-ext-auth-oidc

AuthMiddleware.install(ctx, config, source) now owns the annotation processor and
the flash.auth.policy key, so a second credential source gets annotation-driven
authorization without copying the wiring. The key is public: an extension that
contributes middleware can order itself around authentication.

OidcSession becomes Session in auth-core, carrying claims, an expiry and an opaque
attribute map. OpenID Connect keeps its access, id and refresh tokens in that map
under its own keys, so renewal stays its business and core has no OAuth2 vocabulary
in it. isAccessTokenExpired() becomes isExpired(), with the 30s eager-renewal
window it always had and now a test for it.

flash-ext-oidc is renamed flash-ext-auth-oidc, matching cache-core/cache-caffeine
and data-core/data-hibernate.
This commit is contained in:
Zakaria El Orche
2026-09-10 19:06:15 +00:00
parent c5be6ac7b8
commit 9d39e24ccb
45 changed files with 271 additions and 166 deletions
@@ -5,6 +5,8 @@ import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.Response;
import dev.relism.flash.routing.Middleware;
import dev.relism.flash.routing.MiddlewareKey;
import dev.relism.flash.routing.MiddlewareNode;
import java.util.ArrayList;
import java.util.List;
@@ -28,6 +30,13 @@ import java.util.Map;
*/
public class AuthMiddleware {
/**
* Boot-time identity of the node annotation-driven authorization mounts under. Public so an
* extension that contributes its own middleware can order itself around authentication —
* {@code MiddlewareNode.of(...).afterIfPresent(AuthMiddleware.POLICY)}.
*/
public static final MiddlewareKey POLICY = MiddlewareKey.of("flash.auth.policy");
private final AuthConfig config;
private final CredentialSource source;
private final String[] roleClaimPathParts;
@@ -40,6 +49,26 @@ public class AuthMiddleware {
this.scopeClaimPathParts = splitClaimPaths(config.scopeClaimPaths());
}
/**
* Builds the middleware for {@code source}, publishes it in the context and registers the
* annotation processor that mounts {@link Authenticated}, {@link RolesAllowed} and
* {@link ScopesAllowed} on scanned handlers.
*
* <p>Every extension that contributes a {@link CredentialSource} calls this rather than
* repeating the wiring — the processor and the {@link #POLICY} key belong to one place.
*/
public static AuthMiddleware install(FlashContext ctx, AuthConfig config, CredentialSource source) {
AuthMiddleware middleware = new AuthMiddleware(config, source);
ctx.provide(AuthMiddleware.class, middleware);
ctx.addAnnotationProcessor(handlerClass -> {
AuthPolicy policy = AuthPolicy.compileFromAnnotations(handlerClass);
return policy != null
? List.of(MiddlewareNode.of(POLICY, middleware.authorize(policy)))
: List.of();
});
return middleware;
}
// -- Public API -----------------------------------------------------------
/** The single configured claim path used by every transport for role checks. */
@@ -0,0 +1,20 @@
package dev.relism.flash.ext.auth;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/**
* Thread-safe in-memory {@link SessionStore}.
*
* <p>Sessions are lost on restart and not shared across instances. For
* production deployments with multiple nodes or restart-persistence requirements,
* supply another implementation to whichever {@link CredentialSource} owns the session.
*/
public final class InMemorySessionStore implements SessionStore {
private final ConcurrentHashMap<String, Session> store = new ConcurrentHashMap<>();
@Override public void save(Session s) { store.put(s.id(), s); }
@Override public Optional<Session> find(String id) { return Optional.ofNullable(store.get(id)); }
@Override public void delete(String id) { store.remove(id); }
}
@@ -0,0 +1,53 @@
package dev.relism.flash.ext.auth;
import java.time.Instant;
import java.util.Map;
/**
* A signed-in caller's server-side session — saved in a {@link SessionStore} and looked up by a
* cookie on every request.
*
* <p>Immutable: renewing one produces a new instance that replaces the old under the same
* {@link #id()}.
*
* <p>{@link #attributes()} is whatever the {@link CredentialSource} needs to keep alongside the
* claims and nothing this module interprets — OpenID Connect stores its access, id and refresh
* tokens there so that renewal is its business rather than core's.
*/
public final class Session {
/** Renew this far before the real expiry, so a session cannot lapse mid-request. */
private static final long EAGER_RENEWAL_SECONDS = 30;
private final String id;
private final Map<String, Object> claims;
private final Instant expiresAt;
private final Map<String, Object> attributes;
public Session(String id, Map<String, Object> claims, Instant expiresAt,
Map<String, Object> attributes) {
this.id = id;
this.claims = Map.copyOf(claims);
this.expiresAt = expiresAt;
this.attributes = attributes == null ? Map.of() : Map.copyOf(attributes);
}
/** True once the session is within {@value #EAGER_RENEWAL_SECONDS} seconds of expiring. */
public boolean isExpired() {
return Instant.now().isAfter(expiresAt.minusSeconds(EAGER_RENEWAL_SECONDS));
}
/** One attribute, or {@code null} when the source never stored it. */
public Object attribute(String key) { return attributes.get(key); }
/** One attribute as a String, or {@code null}. */
public String attributeAsString(String key) {
Object v = attributes.get(key);
return v != null ? v.toString() : null;
}
public String id() { return id; }
public Map<String, Object> claims() { return claims; }
public Instant expiresAt() { return expiresAt; }
public Map<String, Object> attributes() { return attributes; }
}
@@ -0,0 +1,13 @@
package dev.relism.flash.ext.auth;
import java.util.Optional;
/**
* Where {@link Session}s live between requests. {@link InMemorySessionStore} is the default;
* supply another for Redis, JDBC, or anything that survives a restart or spans instances.
*/
public interface SessionStore {
void save(Session session);
Optional<Session> find(String sessionId);
void delete(String sessionId);
}
@@ -0,0 +1,58 @@
package dev.relism.flash.ext.auth;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* The two things a session has to get right: it reports itself expired early enough that it
* cannot lapse midway through a request, and it hands back what a credential source stored on it
* without interpreting any of it.
*/
class SessionTest {
private static Session at(Instant expiry, Map<String, Object> attributes) {
return new Session("s1", Map.of("sub", "u1"), expiry, attributes);
}
@Test
void aSessionIsExpiredWellBeforeItsDeadline() {
// The eager window is what stops a session from lapsing between the check and the handler.
assertFalse(at(Instant.now().plusSeconds(120), Map.of()).isExpired());
assertTrue(at(Instant.now().plusSeconds(10), Map.of()).isExpired());
assertTrue(at(Instant.now().minusSeconds(1), Map.of()).isExpired());
}
@Test
void attributesAreReturnedUninterpreted() {
Session session = at(Instant.now().plusSeconds(60), Map.of("oidc.id_token", "abc", "n", 7));
assertEquals("abc", session.attributeAsString("oidc.id_token"));
assertEquals("7", session.attributeAsString("n"));
assertEquals(7, session.attribute("n"));
assertNull(session.attributeAsString("absent"));
}
@Test
void aSessionWithoutAttributesIsUsableRatherThanNull() {
assertNull(at(Instant.now().plusSeconds(60), null).attributeAsString("anything"));
}
@Test
void claimsAndAttributesAreCopiedAndImmutable() {
Map<String, Object> mutable = new HashMap<>(Map.of("k", "v"));
Session session = at(Instant.now().plusSeconds(60), mutable);
mutable.put("k", "changed");
assertEquals("v", session.attributeAsString("k"));
assertThrows(UnsupportedOperationException.class, () -> session.attributes().put("x", "y"));
assertThrows(UnsupportedOperationException.class, () -> session.claims().put("x", "y"));
}
}
@@ -1,4 +1,4 @@
# flash-ext-oidc
# flash-ext-auth-oidc
Full OIDC Authorization Code + PKCE flow for the Flash HTTP server.
Supports Keycloak, Authelia, Auth0, Google, and any RFC 8414-compliant provider.
@@ -25,7 +25,7 @@ hot-path model (middleware compiled at mount time, no heavy runtime work).
```xml
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-oidc</artifactId>
<artifactId>flash-ext-auth-oidc</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
```
@@ -10,7 +10,7 @@
<version>2.1.0-SNAPSHOT</version>
</parent>
<artifactId>flash-ext-oidc</artifactId>
<artifactId>flash-ext-auth-oidc</artifactId>
<dependencies>
<dependency>
@@ -1,5 +1,8 @@
package dev.relism.flash.ext.oidc;
import dev.relism.flash.ext.auth.InMemorySessionStore;
import dev.relism.flash.ext.auth.SessionStore;
/**
* Full OIDC client configuration. Build via
* {@link #builder(String, String, String, String)} or {@link #fromEnv()}.
@@ -50,7 +53,7 @@ public final class OidcConfig {
private final String scopeClaimPaths;
private final String algorithm;
private final String postLogoutRedirectUri;
private final OidcSessionStore sessionStore;
private final SessionStore sessionStore;
private final boolean insecureTls;
private final ClientAuthMethod clientAuthMethod;
private final String schemeName;
@@ -68,7 +71,7 @@ public final class OidcConfig {
this.algorithm = b.algorithm;
this.postLogoutRedirectUri = b.postLogoutRedirectUri;
this.sessionStore = b.sessionStore != null ? b.sessionStore
: new InMemoryOidcSessionStore();
: new InMemorySessionStore();
this.insecureTls = b.insecureTls;
this.clientAuthMethod = b.clientAuthMethod;
this.schemeName = b.schemeName != null ? b.schemeName : deriveScheme(this.issuer);
@@ -88,7 +91,7 @@ public final class OidcConfig {
public String scopeClaimPaths() { return scopeClaimPaths; }
public String algorithm() { return algorithm; }
public String postLogoutRedirectUri() { return postLogoutRedirectUri; }
public OidcSessionStore sessionStore() { return sessionStore; }
public SessionStore sessionStore() { return sessionStore; }
/** If {@code true}, TLS certificate validation is skipped. <b>Never use in production.</b> */
public boolean insecureTls() { return insecureTls; }
public ClientAuthMethod clientAuthMethod() { return clientAuthMethod; }
@@ -190,7 +193,7 @@ public final class OidcConfig {
private String scopeClaimPaths = "scope,scp";
private String algorithm = "RS256";
private String postLogoutRedirectUri = "/";
private OidcSessionStore sessionStore;
private SessionStore sessionStore;
private boolean insecureTls = false;
private ClientAuthMethod clientAuthMethod = ClientAuthMethod.POST;
private String schemeName = null;
@@ -218,8 +221,8 @@ public final class OidcConfig {
public Builder algorithm(String algorithm) { this.algorithm = algorithm; return this; }
/** Where to redirect after logout (default: {@code /}). */
public Builder postLogoutRedirectUri(String uri) { this.postLogoutRedirectUri = uri; return this; }
/** Custom session store (default: {@link InMemoryOidcSessionStore}). */
public Builder sessionStore(OidcSessionStore store) { this.sessionStore = store; return this; }
/** Custom session store (default: {@link InMemorySessionStore}). */
public Builder sessionStore(SessionStore store) { this.sessionStore = store; return this; }
/**
* Disables TLS certificate verification for all HTTP calls made by this extension.
* <b>Only use in development with self-signed certificates never in production.</b>
@@ -2,6 +2,7 @@ package dev.relism.flash.ext.oidc;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.ext.auth.CredentialSource;
import dev.relism.flash.ext.auth.Session;
import dev.relism.flash.models.Response;
import dev.relism.flash.models.Request;
@@ -20,7 +21,7 @@ import java.util.Optional;
* <p>Resolution order on each request:
* <ol>
* <li>{@code Authorization: Bearer ...} header validated via JWKS ({@link JwtValidator}).</li>
* <li>{@code oidc_session} cookie looked up in {@link OidcSessionStore}; transparently
* <li>{@code oidc_session} cookie looked up in {@link dev.relism.flash.ext.auth.SessionStore}; transparently
* refreshed if the access token is expired.</li>
* <li>Browser clients (no {@code Accept: application/json}) redirect to
* {@code {routePrefix}/login?redirect={path}}.</li>
@@ -31,6 +32,24 @@ public final class OidcCredentialSource implements CredentialSource {
private static final String BEARER = "Bearer";
/**
* Keys this source stores its OAuth2 tokens under in {@link Session#attributes()}. Core keeps
* the session; the tokens inside it are nobody else's business.
*/
static final String ACCESS_TOKEN = "oidc.access_token";
static final String ID_TOKEN = "oidc.id_token";
static final String REFRESH_TOKEN = "oidc.refresh_token";
/** The one place an OIDC session is built, so its attribute keys stay in one place too. */
static Session newSession(String id, String accessToken, String idToken, String refreshToken,
Instant expiresAt, Map<String, Object> claims) {
Map<String, Object> attributes = new HashMap<>(3);
if (accessToken != null) attributes.put(ACCESS_TOKEN, accessToken);
if (idToken != null) attributes.put(ID_TOKEN, idToken);
if (refreshToken != null) attributes.put(REFRESH_TOKEN, refreshToken);
return new Session(id, claims, expiresAt, attributes);
}
private final JwtValidator validator;
private final OidcConfig config;
private final OidcProviderMetadata meta;
@@ -108,14 +127,14 @@ public final class OidcCredentialSource implements CredentialSource {
String sessionId = cookieValue(req, "oidc_session");
if (sessionId != null) {
Optional<OidcSession> found = config.sessionStore().find(sessionId);
Optional<Session> found = config.sessionStore().find(sessionId);
if (found.isPresent()) {
OidcSession session = found.get();
if (!session.isAccessTokenExpired())
Session session = found.get();
if (!session.isExpired())
return session.claims();
if (session.refreshToken() != null) {
if (session.attributeAsString(REFRESH_TOKEN) != null) {
try {
OidcSession refreshed = doRefresh(session);
Session refreshed = doRefresh(session);
config.sessionStore().save(refreshed);
return refreshed.claims();
} catch (Exception ignored) { }
@@ -149,17 +168,17 @@ public final class OidcCredentialSource implements CredentialSource {
// 2. Session cookie
String sessionId = cookieValue(req, "oidc_session");
if (sessionId != null) {
Optional<OidcSession> found = config.sessionStore().find(sessionId);
Optional<Session> found = config.sessionStore().find(sessionId);
if (found.isPresent()) {
OidcSession session = found.get();
Session session = found.get();
if (!session.isAccessTokenExpired())
if (!session.isExpired())
return session.claims();
// Access token expired try silent refresh
if (session.refreshToken() != null) {
if (session.attributeAsString(REFRESH_TOKEN) != null) {
try {
OidcSession refreshed = doRefresh(session);
Session refreshed = doRefresh(session);
config.sessionStore().save(refreshed);
return refreshed.claims();
} catch (Exception ignored) {
@@ -184,19 +203,17 @@ public final class OidcCredentialSource implements CredentialSource {
return null;
}
private OidcSession doRefresh(OidcSession old) throws Exception {
private Session doRefresh(Session old) throws Exception {
OidcTokenResponse tokens = tokenClient.refresh(
meta.tokenEndpoint(), old.refreshToken());
meta.tokenEndpoint(), old.attributeAsString(REFRESH_TOKEN));
Map<String, Object> claims = mergeRefreshedClaims(tokens, old);
return new OidcSession(
return newSession(
old.id(),
tokens.accessToken(),
tokens.idToken() != null ? tokens.idToken() : old.idToken(),
tokens.refreshToken() != null ? tokens.refreshToken() : old.refreshToken(),
tokens.idToken() != null ? tokens.idToken() : old.attributeAsString(ID_TOKEN),
tokens.refreshToken() != null ? tokens.refreshToken() : old.attributeAsString(REFRESH_TOKEN),
Instant.now().plusSeconds(tokens.expiresIn()),
claims
mergeRefreshedClaims(tokens, old)
);
}
@@ -280,7 +297,7 @@ public final class OidcCredentialSource implements CredentialSource {
return out.toString();
}
private static Map<String, Object> mergeRefreshedClaims(OidcTokenResponse tokens, OidcSession old) {
private static Map<String, Object> mergeRefreshedClaims(OidcTokenResponse tokens, Session old) {
Map<String, Object> merged = new HashMap<>();
// Fall back to old claims first, then overlay fresh token claims
merged.putAll(old.claims());
@@ -5,6 +5,7 @@ import dev.relism.flash.ext.openapi.OpenApiContributorRegistry;
import dev.relism.flash.ext.openapi.OpenApiOperationContribution;
import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
import dev.relism.flash.ext.auth.AuthConfig;
import dev.relism.flash.ext.auth.Session;
import dev.relism.flash.ext.auth.AuthMiddleware;
import dev.relism.flash.ext.auth.AuthPolicy;
import dev.relism.flash.ext.auth.Authenticated;
@@ -13,8 +14,6 @@ import dev.relism.flash.ext.auth.ScopesAllowed;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.routing.MiddlewareKey;
import dev.relism.flash.routing.MiddlewareNode;
import dev.relism.flash.models.Request;
import javax.net.ssl.SSLContext;
@@ -63,7 +62,6 @@ import java.util.*;
* }</pre>
*/
public class OidcExtension implements FlashExtension {
private static final MiddlewareKey POLICY = MiddlewareKey.of("flash.auth.policy");
private final OidcConfig config;
@@ -96,19 +94,14 @@ public class OidcExtension implements FlashExtension {
stateStore = new OidcStateStore();
tokenClient = new TokenClient(http, config);
source = new OidcCredentialSource(validator, config, meta, tokenClient);
authMw = new AuthMiddleware(AuthConfig.builder()
authMw = AuthMiddleware.install(ctx, AuthConfig.builder()
.rolesClaimPath(config.rolesClaimPath())
.scopeClaimPaths(config.scopeClaimPaths())
.build(), source);
ctx.provide(AuthMiddleware.class, authMw);
ctx.provide(OidcCredentialSource.class, source);
ctx.provide(JwtValidator.class, validator);
ctx.addAnnotationProcessor(handlerClass -> {
AuthPolicy policy = AuthPolicy.compileFromAnnotations(handlerClass);
return policy != null ? List.of(MiddlewareNode.of(POLICY, authMw.authorize(policy))) : List.of();
});
ctx.onReady(() -> registerRoutes(app, ctx));
}
@@ -177,7 +170,7 @@ public class OidcExtension implements FlashExtension {
}
Map<String, Object> claims = mergeClaims(tokens);
OidcSession session = new OidcSession(
Session session = OidcCredentialSource.newSession(
UUID.randomUUID().toString(),
tokens.accessToken(), tokens.idToken(), tokens.refreshToken(),
Instant.now().plusSeconds(tokens.expiresIn()), claims);
@@ -195,8 +188,8 @@ public class OidcExtension implements FlashExtension {
String idTokenHint = null;
if (sessionId != null) {
OidcSession session = config.sessionStore().find(sessionId).orElse(null);
if (session != null) idTokenHint = session.idToken();
Session session = config.sessionStore().find(sessionId).orElse(null);
if (session != null) idTokenHint = session.attributeAsString(OidcCredentialSource.ID_TOKEN);
config.sessionStore().delete(sessionId);
}
@@ -3,7 +3,7 @@
`flash-ext-mcp` turns a Flash5 app into an [MCP](https://modelcontextprotocol.io) (Model Context
Protocol) server: JSON-RPC 2.0 over the Streamable HTTP transport, tools/resources/prompts
declared as plain classes and discovered at boot, optional OAuth2 protection built on
`flash-ext-oidc`.
`flash-ext-auth-oidc`.
## Quick Start
@@ -44,7 +44,7 @@ public class GetWeatherTool extends McpTool {
`tools-resources-prompts.md`.
- **Transport**: Streamable HTTP, `POST`-only, stateless in this revision — see `transport.md`
for exactly what that means and why.
- **Security**: optional, policy-driven OAuth2 via `flash-ext-oidc` — see `security.md`.
- **Security**: optional, policy-driven OAuth2 via `flash-ext-auth-oidc` — see `security.md`.
- **JSON**: this extension owns its JSON handling independently of `flash-ext-jackson` — see
`jackson-interop.md` for why, and how a future opt-in reuse could work.
@@ -24,7 +24,7 @@ see `tools-resources-prompts.md` — and the fixed `TextContent`/`TextResourceCo
as a `JsonNode` tree, not as a databound class, for the same reason — a JSON-RPC tool call's
arguments aren't a DTO with getters/setters, they're a dynamic, per-tool-defined bag of values.
This mirrors how `flash-ext-oidc` already handles its own internal JSON needs (`json-smart` for
This mirrors how `flash-ext-auth-oidc` already handles its own internal JSON needs (`json-smart` for
token-endpoint responses) independently of `flash-ext-jackson` — extensions with protocol-level
JSON needs that are shaped by a spec, not by user code, own that JSON handling themselves rather
than routing it through the app's general-purpose JSON extension.
@@ -1,7 +1,7 @@
# Keycloak cookbook
`security.md` covers the OAuth2 mechanics `McpOidcIntegration` implements against any
`flash-ext-oidc`-compatible provider. This is the Keycloak-specific setup: the exact Admin
`flash-ext-auth-oidc`-compatible provider. This is the Keycloak-specific setup: the exact Admin
Console configuration for a working MCP OAuth2 flow with open Dynamic Client Registration
(DCR) — no pre-registered clients, any MCP client self-registers on first connect.
+10 -10
View File
@@ -7,10 +7,10 @@ Allowed Client Scopes configuration `scopes_supported` needs to actually work.
## `McpSecurity`
`McpConfig.security(...)` controls how the MCP endpoint reacts to `flash-ext-oidc` being
`McpConfig.security(...)` controls how the MCP endpoint reacts to `flash-ext-auth-oidc` being
installed (`ctx.find(OidcMiddleware.class)`), resolved once at boot in `McpExtension.routes()`:
| Policy | `flash-ext-oidc` installed | `flash-ext-oidc` absent |
| Policy | `flash-ext-auth-oidc` installed | `flash-ext-auth-oidc` absent |
|---|---|---|
| `REQUIRED` | protected | **boot fails** (`IllegalStateException`) |
| `AUTO` (default) | protected | runs unprotected, logs a warning |
@@ -21,18 +21,18 @@ turns "someone forgot to wire up OAuth2" into a startup crash instead of a silen
endpoint. `AUTO` is meant for local development, where spinning up a real identity provider is
friction you don't want yet.
## Why `flash-ext-oidc` is an *optional* Maven dependency, concretely
## Why `flash-ext-auth-oidc` is an *optional* Maven dependency, concretely
Maven's `<optional>true</optional>` only affects **transitive** propagation: consumers of
`flash-ext-mcp` don't get `flash-ext-oidc` pulled in automatically unless they add it themselves.
Within `flash-ext-mcp` itself, `flash-ext-oidc`'s classes are on the compile/test classpath as
`flash-ext-mcp` don't get `flash-ext-auth-oidc` pulled in automatically unless they add it themselves.
Within `flash-ext-mcp` itself, `flash-ext-auth-oidc`'s classes are on the compile/test classpath as
normal — this extension can (and does) reference `OidcMiddleware`/`ClaimsHolder` directly in
source.
That reference is isolated in its own class, `McpOidcIntegration`, invoked only from inside a
`catch (NoClassDefFoundError)` block. A bare class-literal like `OidcMiddleware.class` (which
`ctx.find(OidcMiddleware.class)` needs) forces the JVM to resolve that type the moment it's
evaluated — if `flash-ext-oidc` is not on the *runtime* classpath at all (a genuinely
evaluated — if `flash-ext-auth-oidc` is not on the *runtime* classpath at all (a genuinely
MCP-only install, no OAuth2 anywhere in the app), the first such reference throws
`NoClassDefFoundError`. Keeping that reference inside a separate, lazily-loaded class means
`McpExtension` itself loads and works fine standalone; only the attempt to actually use OIDC
@@ -45,7 +45,7 @@ When oidc is available and `security() != NONE`, `McpOidcIntegration` (an isolat
lazily-loaded bridge — see its javadoc) derives everything an MCP OAuth2 resource server needs
straight from the installed `OidcMiddleware`, with no additional `McpConfig` calls required:
1. The MCP route is wrapped with `flash-ext-oidc`'s own `OidcMiddleware.protect(resourceMetadataPath)`
1. The MCP route is wrapped with `flash-ext-auth-oidc`'s own `OidcMiddleware.protect(resourceMetadataPath)`
— the same Bearer-token/JWKS validation path used everywhere else in Flash5, plus a
`resource_metadata` challenge parameter (see below). No JWT parsing or JWKS handling is
reimplemented here.
@@ -114,7 +114,7 @@ unaffected — this parameter is additive and MCP-specific.
## Per-tool `@RolesAllowed`/`@ScopesAllowed`
`McpTool` subclasses can carry `flash-ext-oidc`'s `@RolesAllowed`/`@ScopesAllowed`:
`McpTool` subclasses can carry `flash-ext-auth-oidc`'s `@RolesAllowed`/`@ScopesAllowed`:
```java
@Tool(name = "delete_route", description = "Delete a route")
@@ -124,7 +124,7 @@ public class DeleteRouteTool extends McpTool {
}
```
This does **not** reuse `flash-ext-oidc`'s per-route middleware mechanism (`ctx.addAnnotationProcessor`,
This does **not** reuse `flash-ext-auth-oidc`'s per-route middleware mechanism (`ctx.addAnnotationProcessor`,
the thing that makes these annotations work on a `RequestHandler`) — it can't: every tool shares
one HTTP route (`POST {rootPath}`), already wrapped by whatever `McpSecurity` resolved above, so
there is no per-tool route to attach a different middleware chain to. Instead,
@@ -156,7 +156,7 @@ at `app.start()`.
## The `HttpException` safety net
`flash-ext-oidc`'s middleware throws `HttpException.unauthorized()`/`forbidden()` on auth
`flash-ext-auth-oidc`'s middleware throws `HttpException.unauthorized()`/`forbidden()` on auth
failure. Flash5's core does **not** special-case `HttpException` in the default exception
handler — the out-of-the-box `AbstractRouter` default always returns a generic `500`, regardless
of the thrown exception's embedded status code; only an app that explicitly calls
+1 -1
View File
@@ -19,7 +19,7 @@
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-oidc</artifactId>
<artifactId>flash-ext-auth-oidc</artifactId>
<optional>true</optional>
</dependency>
<dependency>
@@ -10,7 +10,7 @@ import java.util.function.Supplier;
*
* <p>{@code check} is a closure, not a raw role/scope list — this is what lets this record (and
* its only caller, {@link McpDispatcher}) stay free of any compile-time reference to a {@code
* flash-ext-oidc} type, preserving the same classload isolation {@link McpOidcIntegration}'s
* flash-ext-auth-oidc} type, preserving the same classload isolation {@link McpOidcIntegration}'s
* javadoc describes for the rest of the OIDC bridge. Only the plain-JDK {@link Supplier}
* signature crosses the boundary; the closure itself, built once inside {@code
* McpOidcIntegration}, is the only place that ever touches {@code OidcUser}/{@code ClaimsHolder}.
@@ -91,7 +91,7 @@ public final class McpConfig {
/**
* Canonical URI of this MCP endpoint, used for RFC 8707 audience binding: tokens whose
* {@code aud} claim does not include this value are rejected. Optional — when
* {@code flash-ext-oidc} is installed, this is auto-derived per request from the
* {@code flash-ext-auth-oidc} is installed, this is auto-derived per request from the
* forwarded/{@code Host} headers (same resolution {@code OidcExtension} uses for its own
* redirect URIs) and audience binding is enforced unconditionally. Set this explicitly
* only to override that guess — a reverse proxy that forwards neither
@@ -102,7 +102,7 @@ public final class McpConfig {
/**
* Authorization server issuer URL, published in the RFC 9728 Protected Resource
* Metadata document at {@code /.well-known/oauth-protected-resource{rootPath}}. Optional
* — when {@code flash-ext-oidc} is installed, this is auto-derived from its configured
* — when {@code flash-ext-auth-oidc} is installed, this is auto-derived from its configured
* issuer. Set this explicitly only to override that (e.g. publishing a different issuer
* than the one actually validating tokens).
*/
@@ -25,7 +25,7 @@ import java.util.List;
* .build()))
* .start();
*
* // With flash-ext-oidc as the OAuth2 resource server — zero extra config: issuer, canonical
* // With flash-ext-auth-oidc as the OAuth2 resource server — zero extra config: issuer, canonical
* // resource identifier, RFC 8707 audience binding and RFC 9728 metadata are all derived from
* // the installed OidcExtension.
* FlashApp.create(8080)
@@ -83,20 +83,20 @@ public class McpExtension implements FlashExtension {
try {
resolved = McpOidcIntegration.resolve(ctx, config);
} catch (NoClassDefFoundError e) {
resolved = null; // flash-ext-oidc not on the classpath at all
resolved = null; // flash-ext-auth-oidc not on the classpath at all
}
if (resolved != null) return resolved;
if (config.security() == McpSecurity.REQUIRED) {
throw new IllegalStateException(
"McpSecurity.REQUIRED but flash-ext-oidc is not installed for MCP server \"" + config.name() +
"McpSecurity.REQUIRED but flash-ext-auth-oidc is not installed for MCP server \"" + config.name() +
"\" — install an OidcExtension before this McpExtension, or relax security to " +
"McpSecurity.AUTO/NONE if this server is meant to be public.");
}
log.warn("[flash-ext-mcp] MCP server \"{}\" is running WITHOUT OAuth2 protection — " +
"flash-ext-oidc is not installed and McpSecurity.AUTO degrades to unprotected. " +
"Install flash-ext-oidc or set McpSecurity.REQUIRED to make this a hard failure instead.",
"flash-ext-auth-oidc is not installed and McpSecurity.AUTO degrades to unprotected. " +
"Install flash-ext-auth-oidc or set McpSecurity.REQUIRED to make this a hard failure instead.",
config.name());
return null;
}
@@ -19,7 +19,7 @@ import java.nio.charset.StandardCharsets;
*
* <p>Not wired to {@code flash-ext-jackson} on purpose: the MCP JSON-RPC envelope is internal
* protocol plumbing, not a user-facing serialization concern, so this extension owns its
* mapper independently — same reasoning {@code flash-ext-oidc} applies to its own JSON needs
* mapper independently — same reasoning {@code flash-ext-auth-oidc} applies to its own JSON needs
* (see {@code json-smart} there). See {@code docs/jackson-interop.md} for the full rationale
* and how a future opt-in reuse of a shared {@code ObjectMapper} could work.
*/
@@ -20,19 +20,19 @@ import java.util.function.Function;
import java.util.function.Supplier;
/**
* Lazy, isolated bridge to {@code flash-ext-oidc} and {@code flash-ext-auth-core}.
* Lazy, isolated bridge to {@code flash-ext-auth-oidc} and {@code flash-ext-auth-core}.
*
* <p>References to OIDC types only ever resolve when {@link #resolve}/{@link #compileToolPolicy}
* are actually invoked — never at {@link McpExtension} class-load time — because they live in
* this separate nested class. The caller wraps the invocation in {@code catch
* (NoClassDefFoundError)}, exactly like {@code OidcExtension}'s own lazy bridge to {@code
* flash-ext-openapi}. This is what lets {@code flash-ext-mcp} run standalone (MCP-only, no
* OAuth2) when {@code flash-ext-oidc} is not even on the classpath. {@link Resolved}/{@link
* OAuth2) when {@code flash-ext-auth-oidc} is not even on the classpath. {@link Resolved}/{@link
* McpAuthPolicy} carry only oidc-free types back out ({@link Middleware}, {@link String}, a
* {@link Function}, a {@link Supplier}) so no other class in this package ever has to reference
* an OIDC type.
*
* <p>Zero-config by design: when {@code flash-ext-oidc} is installed, everything an MCP OAuth2
* <p>Zero-config by design: when {@code flash-ext-auth-oidc} is installed, everything an MCP OAuth2
* resource server needs — issuer, canonical resource identifier, RFC 8707 audience binding, and
* a spec-compliant {@code WWW-Authenticate} challenge (RFC 9728 §5.1) — is derived straight from
* the installed {@link OidcCredentialSource}, with no additional {@link McpConfig} calls.
@@ -123,7 +123,7 @@ final class McpOidcIntegration {
if (!oidcActive) {
throw new IllegalStateException(
"MCP tool \"" + toolClass.getSimpleName() + "\" declares @Authenticated/@RolesAllowed/" +
"@ScopesAllowed, but this MCP server has no active OAuth2 protection — flash-ext-oidc " +
"@ScopesAllowed, but this MCP server has no active OAuth2 protection — flash-ext-auth-oidc " +
"is not installed for it, or McpSecurity is NONE. These annotations require " +
"McpSecurity.AUTO/REQUIRED with an OidcExtension installed; install one, or remove the " +
"annotation from " + toolClass.getSimpleName() + ".");
@@ -175,7 +175,7 @@ final class McpRegistry {
/**
* Isolated the same way {@link McpOidcIntegration#resolve} is — {@code
* NoClassDefFoundError} here means {@code flash-ext-oidc} genuinely isn't on the runtime
* NoClassDefFoundError} here means {@code flash-ext-auth-oidc} genuinely isn't on the runtime
* classpath, in which case a tool couldn't have been compiled against
* {@code @RolesAllowed}/{@code @ScopesAllowed} in the first place, so there's nothing to
* check (and nothing lost: {@code oidcActive} is only ever {@code true} once {@link
@@ -2,16 +2,16 @@ package dev.relism.flash.ext.mcp;
/**
* OAuth2 requirement policy for the MCP endpoint, resolved against whether
* {@code flash-ext-oidc} is installed ({@code ctx.find(OidcMiddleware.class)}).
* {@code flash-ext-auth-oidc} is installed ({@code ctx.find(OidcMiddleware.class)}).
*/
public enum McpSecurity {
/** Fail fast at boot if {@code flash-ext-oidc} is not installed — never expose an unprotected MCP endpoint. */
/** Fail fast at boot if {@code flash-ext-auth-oidc} is not installed — never expose an unprotected MCP endpoint. */
REQUIRED,
/** Protect the endpoint if {@code flash-ext-oidc} is installed; otherwise run unprotected and log a warning. */
/** Protect the endpoint if {@code flash-ext-auth-oidc} is installed; otherwise run unprotected and log a warning. */
AUTO,
/** Never protect the endpoint, even if {@code flash-ext-oidc} is installed elsewhere in the app. */
/** Never protect the endpoint, even if {@code flash-ext-auth-oidc} is installed elsewhere in the app. */
NONE
}
@@ -38,7 +38,7 @@ final class McpTransportGuards {
/**
* Safety net around the whole MCP route: translates {@link HttpException} (thrown by
* {@link #originGuard} or by {@code flash-ext-oidc}'s middleware) into a proper HTTP status
* {@link #originGuard} or by {@code flash-ext-auth-oidc}'s middleware) into a proper HTTP status
* directly, instead of relying on the app's global exception handler — which defaults to a
* generic 500 for every exception type unless the app owner overrides it (see
* {@code AbstractRouter}'s default {@code exceptionHandler}). Keeps the MCP endpoint
@@ -26,7 +26,7 @@ import java.util.UUID;
/**
* Minimal, self-contained fake OIDC provider for tests: real discovery document, real JWKS
* endpoint, real RS256-signed tokens — no network dependency beyond localhost, no mocking
* framework. Exercises {@code flash-ext-oidc}'s actual discovery + JWKS + JWT validation path.
* framework. Exercises {@code flash-ext-auth-oidc}'s actual discovery + JWKS + JWT validation path.
*/
final class FakeOidcProvider implements AutoCloseable {
@@ -16,7 +16,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Exercises the actual OAuth2 resolution rules against a real {@code flash-ext-oidc}
* Exercises the actual OAuth2 resolution rules against a real {@code flash-ext-auth-oidc}
* installation backed by {@link FakeOidcProvider} — real discovery, real JWKS, real RS256
* tokens — plus the fail-fast/degrade behavior when oidc is absent.
*
@@ -1,20 +0,0 @@
package dev.relism.flash.ext.oidc;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/**
* Thread-safe in-memory {@link OidcSessionStore}.
*
* <p>Sessions are lost on restart and not shared across instances. For
* production deployments with multiple nodes or restart-persistence requirements,
* supply a custom implementation via {@link OidcConfig.Builder#sessionStore}.
*/
public final class InMemoryOidcSessionStore implements OidcSessionStore {
private final ConcurrentHashMap<String, OidcSession> store = new ConcurrentHashMap<>();
@Override public void save(OidcSession s) { store.put(s.id(), s); }
@Override public Optional<OidcSession> find(String id) { return Optional.ofNullable(store.get(id)); }
@Override public void delete(String id) { store.remove(id); }
}
@@ -1,47 +0,0 @@
package dev.relism.flash.ext.oidc;
import java.time.Instant;
import java.util.Map;
/**
* An authenticated user's OIDC session — persisted in {@link OidcSessionStore} and
* looked up via the {@code oidc_session} cookie on every request.
*
* <p>Sessions are immutable; a refreshed access token produces a new instance
* that replaces the old one in the store (same {@link #id()}).
*/
public final class OidcSession {
private final String id;
private final String accessToken;
private final String idToken;
private final String refreshToken; // may be null
private final Instant accessTokenExpiresAt;
private final Map<String, Object> claims; // decoded from id_token
public OidcSession(String id, String accessToken, String idToken,
String refreshToken, Instant accessTokenExpiresAt,
Map<String, Object> claims) {
this.id = id;
this.accessToken = accessToken;
this.idToken = idToken;
this.refreshToken = refreshToken;
this.accessTokenExpiresAt = accessTokenExpiresAt;
this.claims = Map.copyOf(claims);
}
/**
* Returns {@code true} if the access token has expired or will expire within
* the next 30 seconds (eager refresh to avoid mid-request expiry).
*/
public boolean isAccessTokenExpired() {
return Instant.now().isAfter(accessTokenExpiresAt.minusSeconds(30));
}
public String id() { return id; }
public String accessToken() { return accessToken; }
public String idToken() { return idToken; }
public String refreshToken() { return refreshToken; }
public Instant accessTokenExpiresAt() { return accessTokenExpiresAt; }
public Map<String, Object> claims() { return claims; }
}
@@ -1,14 +0,0 @@
package dev.relism.flash.ext.oidc;
import java.util.Optional;
/**
* Backing store for {@link OidcSession} objects. The default implementation is
* {@link InMemoryOidcSessionStore}; supply a custom one via
* {@link OidcConfig.Builder#sessionStore(OidcSessionStore)} for Redis, JDBC, etc.
*/
public interface OidcSessionStore {
void save(OidcSession session);
Optional<OidcSession> find(String sessionId);
void delete(String sessionId);
}
+1 -1
View File
@@ -123,7 +123,7 @@ Merge policy:
## OIDC interop
When `flash-ext-oidc` is installed, OpenAPI integrates automatically:
When `flash-ext-auth-oidc` is installed, OpenAPI integrates automatically:
- security scheme under `components.securitySchemes`
- per-operation `security`
+1 -1
View File
@@ -17,7 +17,7 @@
<module>flash-ext-jackson</module>
<module>flash-ext-openapi</module>
<module>flash-ext-auth-core</module>
<module>flash-ext-oidc</module>
<module>flash-ext-auth-oidc</module>
<module>flash-ext-routeviewer</module>
<module>flash-ext-view-core</module>
<module>flash-ext-view-jte</module>