diff --git a/.idea/encodings.xml b/.idea/encodings.xml index aa0097f..cf88791 100644 --- a/.idea/encodings.xml +++ b/.idea/encodings.xml @@ -17,8 +17,8 @@ - - + + diff --git a/README.md b/README.md index 10ac1e4..89fc701 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ a zero-allocation FSM router, bounded protocol state, and one shared request/res | `flash-testing` | JUnit 5 harness — boot an app on an ephemeral port, fake its services, assert on responses | | `flash-extensions/flash-ext-jackson` | Jackson JSON integration | | `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI | -| `flash-extensions/flash-ext-oidc` | OIDC Authorization Code + PKCE flow | -| `flash-extensions/flash-ext-mcp` | MCP (Model Context Protocol) server — Streamable HTTP, optional OAuth2 via flash-ext-oidc | +| `flash-extensions/flash-ext-auth-oidc` | OIDC Authorization Code + PKCE flow | +| `flash-extensions/flash-ext-mcp` | MCP (Model Context Protocol) server — Streamable HTTP, optional OAuth2 via flash-ext-auth-oidc | | `flash-extensions/flash-ext-view-core` | Minimal shared SSR runtime primitives | | `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension | | `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension | @@ -146,7 +146,7 @@ FlashApp.create(8080) See extension-specific READMEs for full details: - [`flash-ext-jackson`](flash-extensions/flash-ext-jackson/README.md) - [`flash-ext-openapi`](flash-extensions/flash-ext-openapi/README.md) -- [`flash-ext-oidc`](flash-extensions/flash-ext-oidc/README.md) +- [`flash-ext-auth-oidc`](flash-extensions/flash-ext-auth-oidc/README.md) - [`flash-ext-mcp`](flash-extensions/flash-ext-mcp/docs/README.md) - [`flash-ext-view-jte`](flash-extensions/flash-ext-view-jte/README.md) - [`flash-ext-view-thymeleaf`](flash-extensions/flash-ext-view-thymeleaf/README.md) diff --git a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthMiddleware.java b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthMiddleware.java index 30c7a4d..2c4a104 100644 --- a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthMiddleware.java +++ b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthMiddleware.java @@ -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. + * + *

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. */ diff --git a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/InMemorySessionStore.java b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/InMemorySessionStore.java new file mode 100644 index 0000000..eff0fa1 --- /dev/null +++ b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/InMemorySessionStore.java @@ -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}. + * + *

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 store = new ConcurrentHashMap<>(); + + @Override public void save(Session s) { store.put(s.id(), s); } + @Override public Optional find(String id) { return Optional.ofNullable(store.get(id)); } + @Override public void delete(String id) { store.remove(id); } +} diff --git a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/Session.java b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/Session.java new file mode 100644 index 0000000..0a77824 --- /dev/null +++ b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/Session.java @@ -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. + * + *

Immutable: renewing one produces a new instance that replaces the old under the same + * {@link #id()}. + * + *

{@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 claims; + private final Instant expiresAt; + private final Map attributes; + + public Session(String id, Map claims, Instant expiresAt, + Map 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 claims() { return claims; } + public Instant expiresAt() { return expiresAt; } + public Map attributes() { return attributes; } +} diff --git a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/SessionStore.java b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/SessionStore.java new file mode 100644 index 0000000..2e0a058 --- /dev/null +++ b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/SessionStore.java @@ -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 find(String sessionId); + void delete(String sessionId); +} diff --git a/flash-extensions/flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/SessionTest.java b/flash-extensions/flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/SessionTest.java new file mode 100644 index 0000000..6aef288 --- /dev/null +++ b/flash-extensions/flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/SessionTest.java @@ -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 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 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")); + } +} diff --git a/flash-extensions/flash-ext-oidc/README.md b/flash-extensions/flash-ext-auth-oidc/README.md similarity index 99% rename from flash-extensions/flash-ext-oidc/README.md rename to flash-extensions/flash-ext-auth-oidc/README.md index 8382dce..5fccedf 100644 --- a/flash-extensions/flash-ext-oidc/README.md +++ b/flash-extensions/flash-ext-auth-oidc/README.md @@ -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 dev.relism - flash-ext-oidc + flash-ext-auth-oidc 1.0-SNAPSHOT ``` diff --git a/flash-extensions/flash-ext-oidc/pom.xml b/flash-extensions/flash-ext-auth-oidc/pom.xml similarity index 96% rename from flash-extensions/flash-ext-oidc/pom.xml rename to flash-extensions/flash-ext-auth-oidc/pom.xml index 9967473..43aa29a 100644 --- a/flash-extensions/flash-ext-oidc/pom.xml +++ b/flash-extensions/flash-ext-auth-oidc/pom.xml @@ -10,7 +10,7 @@ 2.1.0-SNAPSHOT - flash-ext-oidc + flash-ext-auth-oidc diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ClientAuthMethod.java b/flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/ClientAuthMethod.java similarity index 100% rename from flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ClientAuthMethod.java rename to flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/ClientAuthMethod.java diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/DiscoveryClient.java b/flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/DiscoveryClient.java similarity index 100% rename from flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/DiscoveryClient.java rename to flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/DiscoveryClient.java diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/JwtUtils.java b/flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/JwtUtils.java similarity index 100% rename from flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/JwtUtils.java rename to flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/JwtUtils.java diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/JwtValidator.java b/flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/JwtValidator.java similarity index 100% rename from flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/JwtValidator.java rename to flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/JwtValidator.java diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcConfig.java b/flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcConfig.java similarity index 96% rename from flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcConfig.java rename to flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcConfig.java index 837af03..997b71a 100644 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcConfig.java +++ b/flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcConfig.java @@ -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. Never use in production. */ 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. * Only use in development with self-signed certificates — never in production. diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcCredentialSource.java b/flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcCredentialSource.java similarity index 85% rename from flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcCredentialSource.java rename to flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcCredentialSource.java index 36f6adf..cd95e63 100644 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcCredentialSource.java +++ b/flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcCredentialSource.java @@ -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; *

Resolution order on each request: *

    *
  1. {@code Authorization: Bearer ...} header — validated via JWKS ({@link JwtValidator}).
  2. - *
  3. {@code oidc_session} cookie — looked up in {@link OidcSessionStore}; transparently + *
  4. {@code oidc_session} cookie — looked up in {@link dev.relism.flash.ext.auth.SessionStore}; transparently * refreshed if the access token is expired.
  5. *
  6. Browser clients (no {@code Accept: application/json}) → redirect to * {@code {routePrefix}/login?redirect={path}}.
  7. @@ -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 claims) { + Map 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 found = config.sessionStore().find(sessionId); + Optional 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 found = config.sessionStore().find(sessionId); + Optional 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 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 mergeRefreshedClaims(OidcTokenResponse tokens, OidcSession old) { + private static Map mergeRefreshedClaims(OidcTokenResponse tokens, Session old) { Map merged = new HashMap<>(); // Fall back to old claims first, then overlay fresh token claims merged.putAll(old.claims()); diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcExtension.java b/flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcExtension.java similarity index 95% rename from flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcExtension.java rename to flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcExtension.java index 133c152..65605f0 100644 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcExtension.java +++ b/flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcExtension.java @@ -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.*; * } */ 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 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); } diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcProviderMetadata.java b/flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcProviderMetadata.java similarity index 100% rename from flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcProviderMetadata.java rename to flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcProviderMetadata.java diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcStateStore.java b/flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcStateStore.java similarity index 100% rename from flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcStateStore.java rename to flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcStateStore.java diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcTokenResponse.java b/flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcTokenResponse.java similarity index 100% rename from flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcTokenResponse.java rename to flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcTokenResponse.java diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcValidationException.java b/flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcValidationException.java similarity index 100% rename from flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcValidationException.java rename to flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcValidationException.java diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/PkceUtils.java b/flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/PkceUtils.java similarity index 100% rename from flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/PkceUtils.java rename to flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/PkceUtils.java diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/TokenClient.java b/flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/TokenClient.java similarity index 100% rename from flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/TokenClient.java rename to flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/TokenClient.java diff --git a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcCredentialSourceTest.java b/flash-extensions/flash-ext-auth-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcCredentialSourceTest.java similarity index 100% rename from flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcCredentialSourceTest.java rename to flash-extensions/flash-ext-auth-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcCredentialSourceTest.java diff --git a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcOpenApiInteropTest.java b/flash-extensions/flash-ext-auth-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcOpenApiInteropTest.java similarity index 100% rename from flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcOpenApiInteropTest.java rename to flash-extensions/flash-ext-auth-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcOpenApiInteropTest.java diff --git a/flash-extensions/flash-ext-mcp/docs/README.md b/flash-extensions/flash-ext-mcp/docs/README.md index 643ce0b..4022668 100644 --- a/flash-extensions/flash-ext-mcp/docs/README.md +++ b/flash-extensions/flash-ext-mcp/docs/README.md @@ -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. diff --git a/flash-extensions/flash-ext-mcp/docs/jackson-interop.md b/flash-extensions/flash-ext-mcp/docs/jackson-interop.md index dddee59..a968ad9 100644 --- a/flash-extensions/flash-ext-mcp/docs/jackson-interop.md +++ b/flash-extensions/flash-ext-mcp/docs/jackson-interop.md @@ -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. diff --git a/flash-extensions/flash-ext-mcp/docs/keycloak.md b/flash-extensions/flash-ext-mcp/docs/keycloak.md index 6326541..e930b77 100644 --- a/flash-extensions/flash-ext-mcp/docs/keycloak.md +++ b/flash-extensions/flash-ext-mcp/docs/keycloak.md @@ -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. diff --git a/flash-extensions/flash-ext-mcp/docs/security.md b/flash-extensions/flash-ext-mcp/docs/security.md index 43e3903..412b12e 100644 --- a/flash-extensions/flash-ext-mcp/docs/security.md +++ b/flash-extensions/flash-ext-mcp/docs/security.md @@ -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 `true` 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 diff --git a/flash-extensions/flash-ext-mcp/pom.xml b/flash-extensions/flash-ext-mcp/pom.xml index db7f5ac..b0a873f 100644 --- a/flash-extensions/flash-ext-mcp/pom.xml +++ b/flash-extensions/flash-ext-mcp/pom.xml @@ -19,7 +19,7 @@ dev.relism - flash-ext-oidc + flash-ext-auth-oidc true diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpAuthPolicy.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpAuthPolicy.java index 088d35e..6bdd880 100644 --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpAuthPolicy.java +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpAuthPolicy.java @@ -10,7 +10,7 @@ import java.util.function.Supplier; * *

    {@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}. diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpConfig.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpConfig.java index a0538d7..f088b60 100644 --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpConfig.java +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpConfig.java @@ -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). */ diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpExtension.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpExtension.java index baa0dff..3126f99 100644 --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpExtension.java +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpExtension.java @@ -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; } diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpJson.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpJson.java index 426fc01..64f3c4e 100644 --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpJson.java +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpJson.java @@ -19,7 +19,7 @@ import java.nio.charset.StandardCharsets; * *

    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. */ diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpOidcIntegration.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpOidcIntegration.java index c971968..5262bb4 100644 --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpOidcIntegration.java +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpOidcIntegration.java @@ -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}. * *

    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. * - *

    Zero-config by design: when {@code flash-ext-oidc} is installed, everything an MCP OAuth2 + *

    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() + "."); diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpRegistry.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpRegistry.java index 4615862..61105d4 100644 --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpRegistry.java +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpRegistry.java @@ -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 diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpSecurity.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpSecurity.java index 211ea5d..21ee37b 100644 --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpSecurity.java +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpSecurity.java @@ -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 } diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpTransportGuards.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpTransportGuards.java index 94ab5a0..af6be7b 100644 --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpTransportGuards.java +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpTransportGuards.java @@ -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 diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/FakeOidcProvider.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/FakeOidcProvider.java index 8eb5f29..799a7a4 100644 --- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/FakeOidcProvider.java +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/FakeOidcProvider.java @@ -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 { diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionSecurityTest.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionSecurityTest.java index 111b5ea..4b8f606 100644 --- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionSecurityTest.java +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionSecurityTest.java @@ -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. * diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/InMemoryOidcSessionStore.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/InMemoryOidcSessionStore.java deleted file mode 100644 index 3df2afc..0000000 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/InMemoryOidcSessionStore.java +++ /dev/null @@ -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}. - * - *

    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 store = new ConcurrentHashMap<>(); - - @Override public void save(OidcSession s) { store.put(s.id(), s); } - @Override public Optional find(String id) { return Optional.ofNullable(store.get(id)); } - @Override public void delete(String id) { store.remove(id); } -} diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcSession.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcSession.java deleted file mode 100644 index 742404b..0000000 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcSession.java +++ /dev/null @@ -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. - * - *

    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 claims; // decoded from id_token - - public OidcSession(String id, String accessToken, String idToken, - String refreshToken, Instant accessTokenExpiresAt, - Map 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 claims() { return claims; } -} diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcSessionStore.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcSessionStore.java deleted file mode 100644 index b23f816..0000000 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcSessionStore.java +++ /dev/null @@ -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 find(String sessionId); - void delete(String sessionId); -} diff --git a/flash-extensions/flash-ext-openapi/README.md b/flash-extensions/flash-ext-openapi/README.md index b49159c..977c86c 100644 --- a/flash-extensions/flash-ext-openapi/README.md +++ b/flash-extensions/flash-ext-openapi/README.md @@ -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` diff --git a/flash-extensions/pom.xml b/flash-extensions/pom.xml index a4f195e..fb4b3fb 100644 --- a/flash-extensions/pom.xml +++ b/flash-extensions/pom.xml @@ -17,7 +17,7 @@ flash-ext-jackson flash-ext-openapi flash-ext-auth-core - flash-ext-oidc + flash-ext-auth-oidc flash-ext-routeviewer flash-ext-view-core flash-ext-view-jte diff --git a/pom.xml b/pom.xml index 452ddc5..14a638e 100644 --- a/pom.xml +++ b/pom.xml @@ -106,7 +106,7 @@ dev.relism - flash-ext-oidc + flash-ext-auth-oidc ${project.version}