refactor(ext-oidc): replace auth modules with security extensions #17

Merged
Relism merged 12 commits from feature/ext-auth/split-oidc-into-auth-core into master 2026-09-16 16:00:15 +00:00
45 changed files with 271 additions and 166 deletions
Showing only changes of commit 9d39e24ccb - Show all commits
+2 -2
View File
@@ -17,8 +17,8 @@
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/resources" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/resources" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-auth-oidc/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/resources" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-auth-oidc/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/resources" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java" charset="UTF-8" /> <file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java" charset="UTF-8" />
+3 -3
View File
@@ -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-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-jackson` | Jackson JSON integration |
| `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI | | `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-auth-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-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-core` | Minimal shared SSR runtime primitives |
| `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension | | `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension |
| `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf 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: See extension-specific READMEs for full details:
- [`flash-ext-jackson`](flash-extensions/flash-ext-jackson/README.md) - [`flash-ext-jackson`](flash-extensions/flash-ext-jackson/README.md)
- [`flash-ext-openapi`](flash-extensions/flash-ext-openapi/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-mcp`](flash-extensions/flash-ext-mcp/docs/README.md)
- [`flash-ext-view-jte`](flash-extensions/flash-ext-view-jte/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) - [`flash-ext-view-thymeleaf`](flash-extensions/flash-ext-view-thymeleaf/README.md)
@@ -5,6 +5,8 @@ import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.models.Request; import dev.relism.flash.models.Request;
import dev.relism.flash.models.Response; import dev.relism.flash.models.Response;
import dev.relism.flash.routing.Middleware; 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.ArrayList;
import java.util.List; import java.util.List;
@@ -28,6 +30,13 @@ import java.util.Map;
*/ */
public class AuthMiddleware { 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 AuthConfig config;
private final CredentialSource source; private final CredentialSource source;
private final String[] roleClaimPathParts; private final String[] roleClaimPathParts;
@@ -40,6 +49,26 @@ public class AuthMiddleware {
this.scopeClaimPathParts = splitClaimPaths(config.scopeClaimPaths()); 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 ----------------------------------------------------------- // -- Public API -----------------------------------------------------------
/** The single configured claim path used by every transport for role checks. */ /** 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. Full OIDC Authorization Code + PKCE flow for the Flash HTTP server.
Supports Keycloak, Authelia, Auth0, Google, and any RFC 8414-compliant provider. 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 ```xml
<dependency> <dependency>
<groupId>dev.relism</groupId> <groupId>dev.relism</groupId>
<artifactId>flash-ext-oidc</artifactId> <artifactId>flash-ext-auth-oidc</artifactId>
<version>1.0-SNAPSHOT</version> <version>1.0-SNAPSHOT</version>
</dependency> </dependency>
``` ```
@@ -10,7 +10,7 @@
<version>2.1.0-SNAPSHOT</version> <version>2.1.0-SNAPSHOT</version>
</parent> </parent>
<artifactId>flash-ext-oidc</artifactId> <artifactId>flash-ext-auth-oidc</artifactId>
<dependencies> <dependencies>
<dependency> <dependency>
@@ -1,5 +1,8 @@
package dev.relism.flash.ext.oidc; 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 * Full OIDC client configuration. Build via
* {@link #builder(String, String, String, String)} or {@link #fromEnv()}. * {@link #builder(String, String, String, String)} or {@link #fromEnv()}.
@@ -50,7 +53,7 @@ public final class OidcConfig {
private final String scopeClaimPaths; private final String scopeClaimPaths;
private final String algorithm; private final String algorithm;
private final String postLogoutRedirectUri; private final String postLogoutRedirectUri;
private final OidcSessionStore sessionStore; private final SessionStore sessionStore;
private final boolean insecureTls; private final boolean insecureTls;
private final ClientAuthMethod clientAuthMethod; private final ClientAuthMethod clientAuthMethod;
private final String schemeName; private final String schemeName;
@@ -68,7 +71,7 @@ public final class OidcConfig {
this.algorithm = b.algorithm; this.algorithm = b.algorithm;
this.postLogoutRedirectUri = b.postLogoutRedirectUri; this.postLogoutRedirectUri = b.postLogoutRedirectUri;
this.sessionStore = b.sessionStore != null ? b.sessionStore this.sessionStore = b.sessionStore != null ? b.sessionStore
: new InMemoryOidcSessionStore(); : new InMemorySessionStore();
this.insecureTls = b.insecureTls; this.insecureTls = b.insecureTls;
this.clientAuthMethod = b.clientAuthMethod; this.clientAuthMethod = b.clientAuthMethod;
this.schemeName = b.schemeName != null ? b.schemeName : deriveScheme(this.issuer); 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 scopeClaimPaths() { return scopeClaimPaths; }
public String algorithm() { return algorithm; } public String algorithm() { return algorithm; }
public String postLogoutRedirectUri() { return postLogoutRedirectUri; } 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> */ /** If {@code true}, TLS certificate validation is skipped. <b>Never use in production.</b> */
public boolean insecureTls() { return insecureTls; } public boolean insecureTls() { return insecureTls; }
public ClientAuthMethod clientAuthMethod() { return clientAuthMethod; } public ClientAuthMethod clientAuthMethod() { return clientAuthMethod; }
@@ -190,7 +193,7 @@ public final class OidcConfig {
private String scopeClaimPaths = "scope,scp"; private String scopeClaimPaths = "scope,scp";
private String algorithm = "RS256"; private String algorithm = "RS256";
private String postLogoutRedirectUri = "/"; private String postLogoutRedirectUri = "/";
private OidcSessionStore sessionStore; private SessionStore sessionStore;
private boolean insecureTls = false; private boolean insecureTls = false;
private ClientAuthMethod clientAuthMethod = ClientAuthMethod.POST; private ClientAuthMethod clientAuthMethod = ClientAuthMethod.POST;
private String schemeName = null; private String schemeName = null;
@@ -218,8 +221,8 @@ public final class OidcConfig {
public Builder algorithm(String algorithm) { this.algorithm = algorithm; return this; } public Builder algorithm(String algorithm) { this.algorithm = algorithm; return this; }
/** Where to redirect after logout (default: {@code /}). */ /** Where to redirect after logout (default: {@code /}). */
public Builder postLogoutRedirectUri(String uri) { this.postLogoutRedirectUri = uri; return this; } public Builder postLogoutRedirectUri(String uri) { this.postLogoutRedirectUri = uri; return this; }
/** Custom session store (default: {@link InMemoryOidcSessionStore}). */ /** Custom session store (default: {@link InMemorySessionStore}). */
public Builder sessionStore(OidcSessionStore store) { this.sessionStore = store; return this; } public Builder sessionStore(SessionStore store) { this.sessionStore = store; return this; }
/** /**
* Disables TLS certificate verification for all HTTP calls made by this extension. * 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> * <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.exceptions.HttpException;
import dev.relism.flash.ext.auth.CredentialSource; 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.Response;
import dev.relism.flash.models.Request; import dev.relism.flash.models.Request;
@@ -20,7 +21,7 @@ import java.util.Optional;
* <p>Resolution order on each request: * <p>Resolution order on each request:
* <ol> * <ol>
* <li>{@code Authorization: Bearer ...} header validated via JWKS ({@link JwtValidator}).</li> * <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> * refreshed if the access token is expired.</li>
* <li>Browser clients (no {@code Accept: application/json}) redirect to * <li>Browser clients (no {@code Accept: application/json}) redirect to
* {@code {routePrefix}/login?redirect={path}}.</li> * {@code {routePrefix}/login?redirect={path}}.</li>
@@ -31,6 +32,24 @@ public final class OidcCredentialSource implements CredentialSource {
private static final String BEARER = "Bearer"; 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 JwtValidator validator;
private final OidcConfig config; private final OidcConfig config;
private final OidcProviderMetadata meta; private final OidcProviderMetadata meta;
@@ -108,14 +127,14 @@ public final class OidcCredentialSource implements CredentialSource {
String sessionId = cookieValue(req, "oidc_session"); String sessionId = cookieValue(req, "oidc_session");
if (sessionId != null) { if (sessionId != null) {
Optional<OidcSession> found = config.sessionStore().find(sessionId); Optional<Session> found = config.sessionStore().find(sessionId);
if (found.isPresent()) { if (found.isPresent()) {
OidcSession session = found.get(); Session session = found.get();
if (!session.isAccessTokenExpired()) if (!session.isExpired())
return session.claims(); return session.claims();
if (session.refreshToken() != null) { if (session.attributeAsString(REFRESH_TOKEN) != null) {
try { try {
OidcSession refreshed = doRefresh(session); Session refreshed = doRefresh(session);
config.sessionStore().save(refreshed); config.sessionStore().save(refreshed);
return refreshed.claims(); return refreshed.claims();
} catch (Exception ignored) { } } catch (Exception ignored) { }
@@ -149,17 +168,17 @@ public final class OidcCredentialSource implements CredentialSource {
// 2. Session cookie // 2. Session cookie
String sessionId = cookieValue(req, "oidc_session"); String sessionId = cookieValue(req, "oidc_session");
if (sessionId != null) { if (sessionId != null) {
Optional<OidcSession> found = config.sessionStore().find(sessionId); Optional<Session> found = config.sessionStore().find(sessionId);
if (found.isPresent()) { if (found.isPresent()) {
OidcSession session = found.get(); Session session = found.get();
if (!session.isAccessTokenExpired()) if (!session.isExpired())
return session.claims(); return session.claims();
// Access token expired try silent refresh // Access token expired try silent refresh
if (session.refreshToken() != null) { if (session.attributeAsString(REFRESH_TOKEN) != null) {
try { try {
OidcSession refreshed = doRefresh(session); Session refreshed = doRefresh(session);
config.sessionStore().save(refreshed); config.sessionStore().save(refreshed);
return refreshed.claims(); return refreshed.claims();
} catch (Exception ignored) { } catch (Exception ignored) {
@@ -184,19 +203,17 @@ public final class OidcCredentialSource implements CredentialSource {
return null; return null;
} }
private OidcSession doRefresh(OidcSession old) throws Exception { private Session doRefresh(Session old) throws Exception {
OidcTokenResponse tokens = tokenClient.refresh( OidcTokenResponse tokens = tokenClient.refresh(
meta.tokenEndpoint(), old.refreshToken()); meta.tokenEndpoint(), old.attributeAsString(REFRESH_TOKEN));
Map<String, Object> claims = mergeRefreshedClaims(tokens, old); return newSession(
return new OidcSession(
old.id(), old.id(),
tokens.accessToken(), tokens.accessToken(),
tokens.idToken() != null ? tokens.idToken() : old.idToken(), tokens.idToken() != null ? tokens.idToken() : old.attributeAsString(ID_TOKEN),
tokens.refreshToken() != null ? tokens.refreshToken() : old.refreshToken(), tokens.refreshToken() != null ? tokens.refreshToken() : old.attributeAsString(REFRESH_TOKEN),
Instant.now().plusSeconds(tokens.expiresIn()), Instant.now().plusSeconds(tokens.expiresIn()),
claims mergeRefreshedClaims(tokens, old)
); );
} }
@@ -280,7 +297,7 @@ public final class OidcCredentialSource implements CredentialSource {
return out.toString(); 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<>(); Map<String, Object> merged = new HashMap<>();
// Fall back to old claims first, then overlay fresh token claims // Fall back to old claims first, then overlay fresh token claims
merged.putAll(old.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.OpenApiOperationContribution;
import dev.relism.flash.ext.openapi.OpenApiResponseContribution; import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
import dev.relism.flash.ext.auth.AuthConfig; 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.AuthMiddleware;
import dev.relism.flash.ext.auth.AuthPolicy; import dev.relism.flash.ext.auth.AuthPolicy;
import dev.relism.flash.ext.auth.Authenticated; 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.FlashContext;
import dev.relism.flash.extension.FlashExtension; import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar; 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 dev.relism.flash.models.Request;
import javax.net.ssl.SSLContext; import javax.net.ssl.SSLContext;
@@ -63,7 +62,6 @@ import java.util.*;
* }</pre> * }</pre>
*/ */
public class OidcExtension implements FlashExtension { public class OidcExtension implements FlashExtension {
private static final MiddlewareKey POLICY = MiddlewareKey.of("flash.auth.policy");
private final OidcConfig config; private final OidcConfig config;
@@ -96,19 +94,14 @@ public class OidcExtension implements FlashExtension {
stateStore = new OidcStateStore(); stateStore = new OidcStateStore();
tokenClient = new TokenClient(http, config); tokenClient = new TokenClient(http, config);
source = new OidcCredentialSource(validator, config, meta, tokenClient); source = new OidcCredentialSource(validator, config, meta, tokenClient);
authMw = new AuthMiddleware(AuthConfig.builder() authMw = AuthMiddleware.install(ctx, AuthConfig.builder()
.rolesClaimPath(config.rolesClaimPath()) .rolesClaimPath(config.rolesClaimPath())
.scopeClaimPaths(config.scopeClaimPaths()) .scopeClaimPaths(config.scopeClaimPaths())
.build(), source); .build(), source);
ctx.provide(AuthMiddleware.class, authMw);
ctx.provide(OidcCredentialSource.class, source); ctx.provide(OidcCredentialSource.class, source);
ctx.provide(JwtValidator.class, validator); 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)); ctx.onReady(() -> registerRoutes(app, ctx));
} }
@@ -177,7 +170,7 @@ public class OidcExtension implements FlashExtension {
} }
Map<String, Object> claims = mergeClaims(tokens); Map<String, Object> claims = mergeClaims(tokens);
OidcSession session = new OidcSession( Session session = OidcCredentialSource.newSession(
UUID.randomUUID().toString(), UUID.randomUUID().toString(),
tokens.accessToken(), tokens.idToken(), tokens.refreshToken(), tokens.accessToken(), tokens.idToken(), tokens.refreshToken(),
Instant.now().plusSeconds(tokens.expiresIn()), claims); Instant.now().plusSeconds(tokens.expiresIn()), claims);
@@ -195,8 +188,8 @@ public class OidcExtension implements FlashExtension {
String idTokenHint = null; String idTokenHint = null;
if (sessionId != null) { if (sessionId != null) {
OidcSession session = config.sessionStore().find(sessionId).orElse(null); Session session = config.sessionStore().find(sessionId).orElse(null);
if (session != null) idTokenHint = session.idToken(); if (session != null) idTokenHint = session.attributeAsString(OidcCredentialSource.ID_TOKEN);
config.sessionStore().delete(sessionId); config.sessionStore().delete(sessionId);
} }
@@ -3,7 +3,7 @@
`flash-ext-mcp` turns a Flash5 app into an [MCP](https://modelcontextprotocol.io) (Model Context `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 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 declared as plain classes and discovered at boot, optional OAuth2 protection built on
`flash-ext-oidc`. `flash-ext-auth-oidc`.
## Quick Start ## Quick Start
@@ -44,7 +44,7 @@ public class GetWeatherTool extends McpTool {
`tools-resources-prompts.md`. `tools-resources-prompts.md`.
- **Transport**: Streamable HTTP, `POST`-only, stateless in this revision — see `transport.md` - **Transport**: Streamable HTTP, `POST`-only, stateless in this revision — see `transport.md`
for exactly what that means and why. 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 - **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. `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 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. 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 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 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. than routing it through the app's general-purpose JSON extension.
@@ -1,7 +1,7 @@
# Keycloak cookbook # Keycloak cookbook
`security.md` covers the OAuth2 mechanics `McpOidcIntegration` implements against any `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 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. (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` ## `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()`: 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`) | | `REQUIRED` | protected | **boot fails** (`IllegalStateException`) |
| `AUTO` (default) | protected | runs unprotected, logs a warning | | `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 endpoint. `AUTO` is meant for local development, where spinning up a real identity provider is
friction you don't want yet. 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 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. `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-oidc`'s classes are on the compile/test classpath as 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 normal — this extension can (and does) reference `OidcMiddleware`/`ClaimsHolder` directly in
source. source.
That reference is isolated in its own class, `McpOidcIntegration`, invoked only from inside a 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 `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 `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 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 `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 `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 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: 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 — 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 `resource_metadata` challenge parameter (see below). No JWT parsing or JWKS handling is
reimplemented here. reimplemented here.
@@ -114,7 +114,7 @@ unaffected — this parameter is additive and MCP-specific.
## Per-tool `@RolesAllowed`/`@ScopesAllowed` ## 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 ```java
@Tool(name = "delete_route", description = "Delete a route") @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 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 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, 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 ## 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 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 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 of the thrown exception's embedded status code; only an app that explicitly calls
+1 -1
View File
@@ -19,7 +19,7 @@
</dependency> </dependency>
<dependency> <dependency>
<groupId>dev.relism</groupId> <groupId>dev.relism</groupId>
<artifactId>flash-ext-oidc</artifactId> <artifactId>flash-ext-auth-oidc</artifactId>
<optional>true</optional> <optional>true</optional>
</dependency> </dependency>
<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 * <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 * 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} * 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 * signature crosses the boundary; the closure itself, built once inside {@code
* McpOidcIntegration}, is the only place that ever touches {@code OidcUser}/{@code ClaimsHolder}. * 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 * 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 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 * forwarded/{@code Host} headers (same resolution {@code OidcExtension} uses for its own
* redirect URIs) and audience binding is enforced unconditionally. Set this explicitly * redirect URIs) and audience binding is enforced unconditionally. Set this explicitly
* only to override that guess — a reverse proxy that forwards neither * 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 * Authorization server issuer URL, published in the RFC 9728 Protected Resource
* Metadata document at {@code /.well-known/oauth-protected-resource{rootPath}}. Optional * 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 * issuer. Set this explicitly only to override that (e.g. publishing a different issuer
* than the one actually validating tokens). * than the one actually validating tokens).
*/ */
@@ -25,7 +25,7 @@ import java.util.List;
* .build())) * .build()))
* .start(); * .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 * // resource identifier, RFC 8707 audience binding and RFC 9728 metadata are all derived from
* // the installed OidcExtension. * // the installed OidcExtension.
* FlashApp.create(8080) * FlashApp.create(8080)
@@ -83,20 +83,20 @@ public class McpExtension implements FlashExtension {
try { try {
resolved = McpOidcIntegration.resolve(ctx, config); resolved = McpOidcIntegration.resolve(ctx, config);
} catch (NoClassDefFoundError e) { } 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 (resolved != null) return resolved;
if (config.security() == McpSecurity.REQUIRED) { if (config.security() == McpSecurity.REQUIRED) {
throw new IllegalStateException( 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 " + "\" — install an OidcExtension before this McpExtension, or relax security to " +
"McpSecurity.AUTO/NONE if this server is meant to be public."); "McpSecurity.AUTO/NONE if this server is meant to be public.");
} }
log.warn("[flash-ext-mcp] MCP server \"{}\" is running WITHOUT OAuth2 protection — " + log.warn("[flash-ext-mcp] MCP server \"{}\" is running WITHOUT OAuth2 protection — " +
"flash-ext-oidc is not installed and McpSecurity.AUTO degrades to unprotected. " + "flash-ext-auth-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.", "Install flash-ext-auth-oidc or set McpSecurity.REQUIRED to make this a hard failure instead.",
config.name()); config.name());
return null; 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 * <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 * 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 * (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. * 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; 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} * <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 * 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 * this separate nested class. The caller wraps the invocation in {@code catch
* (NoClassDefFoundError)}, exactly like {@code OidcExtension}'s own lazy bridge to {@code * (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 * 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 * 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 * {@link Function}, a {@link Supplier}) so no other class in this package ever has to reference
* an OIDC type. * 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 * 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 * 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. * the installed {@link OidcCredentialSource}, with no additional {@link McpConfig} calls.
@@ -123,7 +123,7 @@ final class McpOidcIntegration {
if (!oidcActive) { if (!oidcActive) {
throw new IllegalStateException( throw new IllegalStateException(
"MCP tool \"" + toolClass.getSimpleName() + "\" declares @Authenticated/@RolesAllowed/" + "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 " + "is not installed for it, or McpSecurity is NONE. These annotations require " +
"McpSecurity.AUTO/REQUIRED with an OidcExtension installed; install one, or remove the " + "McpSecurity.AUTO/REQUIRED with an OidcExtension installed; install one, or remove the " +
"annotation from " + toolClass.getSimpleName() + "."); "annotation from " + toolClass.getSimpleName() + ".");
@@ -175,7 +175,7 @@ final class McpRegistry {
/** /**
* Isolated the same way {@link McpOidcIntegration#resolve} is — {@code * 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 * 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 * {@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 * 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 * 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 { 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, 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, 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 NONE
} }
@@ -38,7 +38,7 @@ final class McpTransportGuards {
/** /**
* Safety net around the whole MCP route: translates {@link HttpException} (thrown by * 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 * 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 * generic 500 for every exception type unless the app owner overrides it (see
* {@code AbstractRouter}'s default {@code exceptionHandler}). Keeps the MCP endpoint * {@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 * 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 * 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 { 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; 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 * installation backed by {@link FakeOidcProvider} — real discovery, real JWKS, real RS256
* tokens — plus the fail-fast/degrade behavior when oidc is absent. * 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 ## 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` - security scheme under `components.securitySchemes`
- per-operation `security` - per-operation `security`
+1 -1
View File
@@ -17,7 +17,7 @@
<module>flash-ext-jackson</module> <module>flash-ext-jackson</module>
<module>flash-ext-openapi</module> <module>flash-ext-openapi</module>
<module>flash-ext-auth-core</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-routeviewer</module>
<module>flash-ext-view-core</module> <module>flash-ext-view-core</module>
<module>flash-ext-view-jte</module> <module>flash-ext-view-jte</module>
+1 -1
View File
@@ -106,7 +106,7 @@
</dependency> </dependency>
<dependency> <dependency>
<groupId>dev.relism</groupId> <groupId>dev.relism</groupId>
<artifactId>flash-ext-oidc</artifactId> <artifactId>flash-ext-auth-oidc</artifactId>
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
<dependency> <dependency>