From 4feabc45d5ead7de49d342bd66f2069192ea85e6 Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Wed, 9 Sep 2026 13:31:21 +0000 Subject: [PATCH 1/9] fix(ext-view): clear cross-engine error, propagate globals across JteExtension builder chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BaseViewExtension: wrap the ViewRuntimeBridge provider collision with a message naming the real constraint (one view engine per FlashApp) instead of the generic "duplicate provider" error. - BaseViewExtension/JteExtension: carry registered globals across JteExtension's immutable settings builders (templateRoot/serveStatics/staticPrefix/withStaticCors/ staticCors). addGlobal() called before any of those used to be silently dropped, since each builder method returned a fresh instance with an empty globals list. - Correct flash-ext-view-jte docs (README, architecture.md, model-and-globals.md): globals merge flat with one typed @param per key, not under a global.* namespace like Thymeleaf — the docs previously claimed the same reserved namespace for both engines, which doesn't match JteRuntime's actual merge behavior. Co-Authored-By: Claude Sonnet 5 --- .../ext/view/core/BaseViewExtension.java | 25 +++++++++++- .../ext/view/core/BaseViewExtensionTest.java | 40 +++++++++++++++++++ flash-extensions/flash-ext-view-jte/README.md | 2 +- .../flash-ext-view-jte/docs/architecture.md | 2 +- .../docs/model-and-globals.md | 19 ++++++++- .../flash/ext/view/jte/JteExtension.java | 13 +++--- .../flash/ext/view/jte/JteExtensionTest.java | 38 ++++++++++++++++++ 7 files changed, 128 insertions(+), 11 deletions(-) create mode 100644 flash-extensions/flash-ext-view-core/src/test/java/dev/relism/flash/ext/view/core/BaseViewExtensionTest.java diff --git a/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/BaseViewExtension.java b/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/BaseViewExtension.java index 356d222..bf5f3e5 100644 --- a/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/BaseViewExtension.java +++ b/flash-extensions/flash-ext-view-core/src/main/java/dev/relism/flash/ext/view/core/BaseViewExtension.java @@ -15,6 +15,23 @@ import java.util.function.Function; public abstract class BaseViewExtension implements FlashExtension { private final List globals = new ArrayList<>(); + protected BaseViewExtension() {} + + /** + * Seeds this instance with globals carried over from a prior one. Subclasses whose + * fluent settings methods return a new instance (e.g. {@code JteExtension.templateRoot(...)}) + * must route through this constructor — otherwise {@link #addGlobal} calls made before + * such a method silently vanish, since the new instance would start with an empty list. + */ + protected BaseViewExtension(List seedGlobals) { + globals.addAll(seedGlobals); + } + + /** Snapshot of globals registered so far — for subclasses to carry over into a new instance. */ + protected final List globals() { + return List.copyOf(globals); + } + public BaseViewExtension addGlobal(String key, Function resolver) { String k = Objects.requireNonNull(key, "global key must not be null").trim(); if (k.isEmpty()) { @@ -34,7 +51,13 @@ public abstract class BaseViewExtension implements FlashExtension { @Override public void configure(FlashRegistrar app, FlashContext ctx) { ViewRuntimeBridge runtime = createRuntime(List.copyOf(globals)); - ctx.provide(ViewRuntimeBridge.class, runtime); + try { + ctx.provide(ViewRuntimeBridge.class, runtime); + } catch (IllegalStateException e) { + throw new IllegalStateException("Another flash-ext-view implementation is already installed in " + + "this FlashApp. Only one view engine (e.g. ThymeleafExtension or JteExtension, not both) " + + "can be active per app — install a single implementation.", e); + } ctx.addAnnotationProcessor(handlerClass -> { validateHandlerClass(handlerClass); return List.of(); diff --git a/flash-extensions/flash-ext-view-core/src/test/java/dev/relism/flash/ext/view/core/BaseViewExtensionTest.java b/flash-extensions/flash-ext-view-core/src/test/java/dev/relism/flash/ext/view/core/BaseViewExtensionTest.java new file mode 100644 index 0000000..0e669d3 --- /dev/null +++ b/flash-extensions/flash-ext-view-core/src/test/java/dev/relism/flash/ext/view/core/BaseViewExtensionTest.java @@ -0,0 +1,40 @@ +package dev.relism.flash.ext.view.core; + +import dev.relism.flash.extension.FlashContext; +import dev.relism.flash.models.RequestHandler; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class BaseViewExtensionTest { + + private static final class FakeViewExtension extends BaseViewExtension { + @Override + protected ViewRuntimeBridge createRuntime(List globals) { + return new ViewRuntimeBridge<>() { + @Override public Object resolve(Class handlerClass) { return null; } + @Override public RenderedView render(BaseViewHandler handler, Object target, + dev.relism.flash.models.Request req, + dev.relism.flash.models.Response res) { return null; } + }; + } + + @Override + protected void validateHandlerClass(Class handlerClass) {} + } + + @Test + void configure_twoViewEngines_failsWithClearMessage() { + FlashContext ctx = new FlashContext(); + new FakeViewExtension().configure(null, ctx); + + IllegalStateException ex = assertThrows(IllegalStateException.class, + () -> new FakeViewExtension().configure(null, ctx)); + + assertTrue(ex.getMessage().contains("Only one view engine"), + "expected a clear cross-engine message, got: " + ex.getMessage()); + } +} diff --git a/flash-extensions/flash-ext-view-jte/README.md b/flash-extensions/flash-ext-view-jte/README.md index 3365142..bb0aba8 100644 --- a/flash-extensions/flash-ext-view-jte/README.md +++ b/flash-extensions/flash-ext-view-jte/README.md @@ -8,7 +8,7 @@ This module keeps jte semantics front and center: - `JteHandler` - `@Template` - `ViewModel` from `flash-ext-view-core` -- `global.*` reserved namespace +- globals merged flat into the model, one typed `@param` per global The extension mirrors `gg.jte.ContentType` into Flash HTTP content type: diff --git a/flash-extensions/flash-ext-view-jte/docs/architecture.md b/flash-extensions/flash-ext-view-jte/docs/architecture.md index 5acd87f..eda94c4 100644 --- a/flash-extensions/flash-ext-view-jte/docs/architecture.md +++ b/flash-extensions/flash-ext-view-jte/docs/architecture.md @@ -9,7 +9,7 @@ 2. **Request-time** - Handler builds local `ViewModel`. - - Runtime injects globals under reserved `global` namespace and merges local model. + - Runtime merges globals and local model into one flat parameter map (fails fast on key collision). - jte renders template into `StringOutput`. 3. **Static assets (optional, enabled by default)** diff --git a/flash-extensions/flash-ext-view-jte/docs/model-and-globals.md b/flash-extensions/flash-ext-view-jte/docs/model-and-globals.md index 08b4e82..affd85f 100644 --- a/flash-extensions/flash-ext-view-jte/docs/model-and-globals.md +++ b/flash-extensions/flash-ext-view-jte/docs/model-and-globals.md @@ -28,6 +28,21 @@ Register globals in extension setup: new JteExtension().addGlobal("appName", req -> "Flash") ``` -Globals are available under `global` namespace in templates. +Globals are merged **flat** into the model — same level as local `ViewModel` keys, not +under a `global.*` namespace. jte templates are statically typed, so each template declares +one `@param` per global it actually uses, named exactly like the global key: -`global` is reserved and cannot be used as local model key. +```jte +@param String appName + +${appName} +``` + +A template that doesn't declare `appName` simply never sees it — no need to declare every +global on every page, only the ones a given template actually uses. + +A local `ViewModel` key that collides with a registered global name fails fast with +`IllegalStateException` at render time, so a typo can't silently shadow a global. + +Every registered global is still resolved on **every** render regardless of whether the +target template declares it, so keep resolvers cheap (no blocking I/O, no heavy allocation). diff --git a/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteExtension.java b/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteExtension.java index 832bac2..e36ea2e 100644 --- a/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteExtension.java +++ b/flash-extensions/flash-ext-view-jte/src/main/java/dev/relism/flash/ext/view/jte/JteExtension.java @@ -29,31 +29,32 @@ public final class JteExtension extends BaseViewExtension { this.settings = builder.build(); } - private JteExtension(JteSettings settings) { + private JteExtension(JteSettings settings, List seedGlobals) { + super(seedGlobals); this.settings = settings; } public JteExtension templateRoot(String templateRoot) { - return new JteExtension(settings.toBuilder().templateRoot(templateRoot).build()); + return new JteExtension(settings.toBuilder().templateRoot(templateRoot).build(), globals()); } public JteExtension serveStatics(boolean serveStatics) { - return new JteExtension(settings.toBuilder().serveStatics(serveStatics).build()); + return new JteExtension(settings.toBuilder().serveStatics(serveStatics).build(), globals()); } public JteExtension staticPrefix(String staticPrefix) { - return new JteExtension(settings.toBuilder().staticPrefix(staticPrefix).build()); + return new JteExtension(settings.toBuilder().staticPrefix(staticPrefix).build(), globals()); } public JteExtension withStaticCors() { - return new JteExtension(settings.toBuilder().enableStaticCors(true).build()); + return new JteExtension(settings.toBuilder().enableStaticCors(true).build(), globals()); } public JteExtension staticCors(Consumer corsConfig) { JteSettings.Builder builder = settings.toBuilder(); builder.enableStaticCors(true); corsConfig.accept(builder); - return new JteExtension(builder.build()); + return new JteExtension(builder.build(), globals()); } @Override diff --git a/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteExtensionTest.java b/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteExtensionTest.java index bb80188..9e1197e 100644 --- a/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteExtensionTest.java +++ b/flash-extensions/flash-ext-view-jte/src/test/java/dev/relism/flash/ext/view/jte/JteExtensionTest.java @@ -1,6 +1,11 @@ package dev.relism.flash.ext.view.jte; +import dev.relism.flash.ext.view.core.RenderedView; import dev.relism.flash.ext.view.core.ViewModel; +import dev.relism.flash.ext.view.core.ViewRuntimeBridge; +import dev.relism.flash.ext.view.jte.model.HomePage; +import dev.relism.flash.extension.FlashContext; +import dev.relism.flash.http.ContentType; import dev.relism.flash.models.Request; import dev.relism.flash.models.RequestHandler; import dev.relism.flash.models.Response; @@ -89,6 +94,39 @@ class JteExtensionTest { assertEquals("https://cdn.example.com", settings.staticCorsAllowOrigin()); } + @Test + void addGlobal_survives_subsequent_settings_builder_calls() throws Exception { + // addGlobal() before a chained settings method (templateRoot/serveStatics/...) used to + // be silently dropped: those methods return a *new* JteExtension instance, and the + // globals list lived on the instance, not the settings being carried forward. + JteExtension ext = new JteExtension(cfg -> cfg.developmentMode(true)) + .addGlobal("appName", req -> "FlashLab") + .templateRoot("templates") + .serveStatics(false); + + TestRegistrar app = new TestRegistrar(); + FlashContext ctx = new FlashContext(); + ext.configure(app, ctx); + ctx.complete(); + + @SuppressWarnings("unchecked") + ViewRuntimeBridge runtime = (ViewRuntimeBridge) ctx.require(ViewRuntimeBridge.class); + + JteHandler handler = new JteHandler() { + @Override + public ViewModel render(Request req) { + return ViewModel.empty() + .with("page", new HomePage("Flash + jte", "elorc")) + .with("build", "dev"); + } + }; + JteTarget target = new JteTarget("pages/home.jte", ContentType.TEXT_HTML); + RenderedView out = runtime.render(handler, target, request("/", null, null, null), + new Response(200, ContentType.JSON)); + + assertTrue(out.body().contains("FlashLab"), "global registered before templateRoot() must survive"); + } + @Test void routes_register_static_wildcard_when_enabled() { JteExtension ext = new JteExtension(cfg -> cfg.staticPrefix("/assets")); -- 2.54.0 From ea00182c7cee8c5807ac26ae3a98b3cf908f7263 Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Thu, 10 Sep 2026 18:48:10 +0000 Subject: [PATCH 2/9] test(ext-oidc): characterise claim matching before the auth-core split Pins the current behaviour of the role/scope matching that is about to move out of OidcMiddleware: delimiter set for string claims, whole-entry comparison for list claims, trimming, empty-requirement semantics under ALL vs ANY, and how a claim path that walks into a non-map resolves. None of it is OIDC-specific and none of it was covered directly. --- .../flash/ext/oidc/ClaimMatchingTest.java | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/ClaimMatchingTest.java diff --git a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/ClaimMatchingTest.java b/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/ClaimMatchingTest.java new file mode 100644 index 0000000..82132b2 --- /dev/null +++ b/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/ClaimMatchingTest.java @@ -0,0 +1,209 @@ +package dev.relism.flash.ext.oidc; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Characterisation tests for claim matching — the part of authorization that has nothing to do + * with OIDC: given a claims map, does the caller hold a role or a scope. + * + *

Written to pin the current behaviour, including the edges that are easy to change by + * accident: which characters separate scopes in a string claim, whether a list entry is trimmed + * before comparison, what an empty requirement means under each match mode, and how a claim path + * that walks into a non-map resolves. Every assertion here reflects what the code does today, not + * what it arguably should do. + */ +class ClaimMatchingTest { + + private static OidcMiddleware middleware(String rolesPath, String scopePaths) { + return new OidcMiddleware(null, OidcConfig + .builder("https://idp.example.com", "client", "secret", "/auth/callback") + .rolesClaimPath(rolesPath) + .scopeClaimPaths(scopePaths) + .build(), null, null); + } + + private static OidcMiddleware middleware() { + return middleware("realm_access.roles", "scope,scp"); + } + + // ── Claim path traversal ───────────────────────────────────────────────── + + @Test + void aPathWalksNestedMaps() { + Map claims = Map.of("a", Map.of("b", Map.of("c", List.of("x")))); + assertTrue(middleware("a.b.c", "scope").rolesAllowed(claims, new String[]{"x"})); + } + + @Test + void aPathThatWalksIntoANonMapResolvesToNothing() { + // "a" is a string, so "a.b" has nowhere to go — not an error, just no match. + Map claims = Map.of("a", "not-a-map"); + assertFalse(middleware("a.b", "scope").rolesAllowed(claims, new String[]{"anything"})); + } + + @Test + void aMissingPathResolvesToNothing() { + assertFalse(middleware().rolesAllowed(Map.of("other", "value"), new String[]{"admin"})); + } + + @Test + void emptySegmentsInAPathAreSkipped() { + // "realm_access..roles" collapses to the same two segments. + Map claims = Map.of("realm_access", Map.of("roles", List.of("admin"))); + assertTrue(middleware("realm_access..roles", "scope").rolesAllowed(claims, new String[]{"admin"})); + } + + @Test + void segmentsAreTrimmed() { + Map claims = Map.of("realm_access", Map.of("roles", List.of("admin"))); + assertTrue(middleware(" realm_access . roles ", "scope").rolesAllowed(claims, new String[]{"admin"})); + } + + @Test + void aBlankRolesPathIsRejectedAtConstruction() { + assertThrows(IllegalStateException.class, () -> middleware(" ", "scope")); + } + + @Test + void aNullClaimValueResolvesToNothing() { + Map nested = new HashMap<>(); + nested.put("roles", null); + Map claims = Map.of("realm_access", nested); + assertFalse(middleware().rolesAllowed(claims, new String[]{"admin"})); + } + + // ── Roles: ANY semantics ───────────────────────────────────────────────── + + @Test + void anyOneOfTheRequiredRolesIsEnough() { + Map claims = Map.of("realm_access", Map.of("roles", List.of("user"))); + assertTrue(middleware().rolesAllowed(claims, new String[]{"admin", "user"})); + assertFalse(middleware().rolesAllowed(claims, new String[]{"admin", "ops"})); + } + + @Test + void requiringNoRoleAtAllMatchesNothing() { + // The loop never runs, so the answer is false even when the claim is present. + Map claims = Map.of("realm_access", Map.of("roles", List.of("admin"))); + assertFalse(middleware().rolesAllowed(claims, new String[0])); + } + + // ── What counts as "contains" ──────────────────────────────────────────── + + @Test + void aListClaimMatchesEntrywiseAndTrimsEachEntry() { + Map claims = Map.of("realm_access", Map.of("roles", List.of(" admin ", "user"))); + assertTrue(middleware().rolesAllowed(claims, new String[]{"admin"})); + } + + @Test + void aListEntryIsNeverSplitOnDelimiters() { + // Unlike a string claim, a list entry is compared whole: "a b" is one role named "a b". + Map claims = Map.of("realm_access", Map.of("roles", List.of("a b"))); + assertFalse(middleware().rolesAllowed(claims, new String[]{"a"})); + assertTrue(middleware().rolesAllowed(claims, new String[]{"a b"})); + } + + @Test + void nullEntriesInAListAreSkipped() { + Map claims = Map.of("realm_access", + Map.of("roles", Arrays.asList(null, "admin"))); + assertTrue(middleware().rolesAllowed(claims, new String[]{"admin"})); + } + + @Test + void anArrayClaimBehavesLikeAList() { + Map claims = Map.of("realm_access", + Map.of("roles", (Object) new String[]{"admin", "user"})); + assertTrue(middleware().rolesAllowed(claims, new String[]{"user"})); + } + + @Test + void aScalarClaimIsComparedWhole() { + Map claims = Map.of("realm_access", Map.of("roles", 42)); + assertTrue(middleware().rolesAllowed(claims, new String[]{"42"})); + } + + @Test + void aStringClaimIsSplitOnSpacesTabsNewlinesAndCommas() { + for (String separator : List.of(" ", "\t", "\n", "\r", ",")) { + Map claims = Map.of("realm_access", + Map.of("roles", "admin" + separator + "user")); + assertTrue(middleware().rolesAllowed(claims, new String[]{"user"}), + "separator " + separator.strip().isEmpty() + " should split the claim"); + } + } + + @Test + void aStringClaimDoesNotMatchAPrefixOrASubstring() { + Map claims = Map.of("realm_access", Map.of("roles", "administrator")); + assertFalse(middleware().rolesAllowed(claims, new String[]{"admin"})); + } + + @Test + void repeatedDelimitersProduceNoEmptyTokens() { + Map claims = Map.of("realm_access", Map.of("roles", " ,, admin ,, ")); + assertTrue(middleware().rolesAllowed(claims, new String[]{"admin"})); + } + + // ── Scopes: ALL vs ANY, across several claim paths ─────────────────────── + + @Test + void allRequiresEveryScope() { + Map claims = Map.of("scope", "openid orders:read"); + assertTrue(middleware().scopesAllowed(claims, new String[]{"openid", "orders:read"}, ScopesAllowed.Match.ALL)); + assertFalse(middleware().scopesAllowed(claims, new String[]{"openid", "orders:write"}, ScopesAllowed.Match.ALL)); + } + + @Test + void anyRequiresOne() { + Map claims = Map.of("scope", "openid"); + assertTrue(middleware().scopesAllowed(claims, new String[]{"nope", "openid"}, ScopesAllowed.Match.ANY)); + assertFalse(middleware().scopesAllowed(claims, new String[]{"nope", "neither"}, ScopesAllowed.Match.ANY)); + } + + @Test + void requiringNoScopeIsVacuouslyTrueUnderAllAndFalseUnderAny() { + // The asymmetry falls out of the loops and is load-bearing for @ScopesAllowed's validation, + // which rejects an empty value list before it can ever reach here. + Map claims = Map.of("scope", "openid"); + assertTrue(middleware().scopesAllowed(claims, new String[0], ScopesAllowed.Match.ALL)); + assertFalse(middleware().scopesAllowed(claims, new String[0], ScopesAllowed.Match.ANY)); + } + + @Test + void scopesAreLookedForInEveryConfiguredPathUntilOneMatches() { + OidcMiddleware mw = middleware("roles", "scope, scp , permissions.scopes"); + Map claims = Map.of( + "scp", List.of("payments:write"), + "permissions", Map.of("scopes", "orders:approve")); + + assertTrue(mw.scopesAllowed(claims, new String[]{"payments:write"}, ScopesAllowed.Match.ALL)); + assertTrue(mw.scopesAllowed(claims, new String[]{"orders:approve"}, ScopesAllowed.Match.ALL)); + // ALL is satisfied even when the two scopes come from different claims. + assertTrue(mw.scopesAllowed(claims, + new String[]{"payments:write", "orders:approve"}, ScopesAllowed.Match.ALL)); + } + + @Test + void blankScopePathsFallBackToScopeAndScp() { + OidcMiddleware mw = middleware("roles", " "); + assertTrue(mw.scopesAllowed(Map.of("scope", "a"), new String[]{"a"}, ScopesAllowed.Match.ALL)); + assertTrue(mw.scopesAllowed(Map.of("scp", "b"), new String[]{"b"}, ScopesAllowed.Match.ALL)); + } + + @Test + void aScopePathListOfOnlySeparatorsFallsBackToScopeAndScp() { + OidcMiddleware mw = middleware("roles", " , , "); + assertTrue(mw.scopesAllowed(Map.of("scp", "b"), new String[]{"b"}, ScopesAllowed.Match.ALL)); + } +} -- 2.54.0 From c5be6ac7b8cc65dcb403375c87bdf7f619195bb7 Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Thu, 10 Sep 2026 19:00:39 +0000 Subject: [PATCH 3/9] refactor(ext-auth): extract flash-ext-auth-core out of flash-ext-oidc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit flash-ext-oidc has always held two things: the OpenID Connect protocol, and a session/claims/authorization layer that is generic and was only ever fed by one source. This splits them along Flash's own -core convention, the same shape cache-core, data-core and view-core already use. flash-ext-auth-core gets what never referenced the protocol — @Authenticated, @RolesAllowed, @ScopesAllowed, ClaimsHolder, the claim matching, and the policy compiled from annotations — under generic names: OidcUser is Claims, since it never was more than a typed view over a claims map, and OidcAuthPolicy is AuthPolicy. It was package-private while being the parameter type of a public method, so the move also fixes that. The new seam is CredentialSource: it resolves a request's claims, or rejects the request the way its protocol says to. AuthMiddleware publishes the result and matches roles and scopes against it. ClaimsHolder's writers stay package-private — an implementation produces claims and core publishes them, so nothing outside this module can put claims on a request that did not carry them. flash-ext-oidc keeps discovery, JWKS, PKCE, the token endpoint, the login and callback routes and the OpenAPI oauth2 contributor, and now registers OidcCredentialSource. flash-ext-mcp still keys McpSecurity on finding that type and not on AuthMiddleware: REQUIRED has to keep meaning "a real authorization server is protecting this endpoint", not "something authenticates here". The middleware key moves with the mechanism: flash.oidc.policy -> flash.auth.policy. Breaking for consumers: imports move to dev.relism.flash.ext.auth, OidcMiddleware becomes AuthMiddleware, ClaimsHolder.user()/get() become current()/map(). --- flash-extensions/flash-ext-auth-core/pom.xml | 25 + .../dev/relism/flash/ext/auth/AuthConfig.java | 43 ++ .../relism/flash/ext/auth/AuthMiddleware.java | 280 +++++++++ .../relism/flash/ext/auth/AuthPolicy.java} | 36 +- .../relism/flash/ext/auth}/Authenticated.java | 18 +- .../dev/relism/flash/ext/auth/Claims.java} | 37 +- .../relism/flash/ext/auth/ClaimsHolder.java | 64 ++ .../flash/ext/auth/CredentialSource.java | 48 ++ .../relism/flash/ext/auth}/RolesAllowed.java | 15 +- .../relism/flash/ext/auth}/ScopesAllowed.java | 6 +- .../flash/ext/auth/AuthPolicyTest.java} | 22 +- .../flash/ext/auth}/ClaimMatchingTest.java | 18 +- .../flash/ext/auth/ClaimsScopesTest.java} | 12 +- .../flash/ext/mcp/McpOidcIntegration.java | 46 +- .../flash/ext/mcp/FakeOidcProvider.java | 2 +- .../authenticatedonly/PointlessAuthTool.java | 2 +- .../authfixtures/secured/AdminOnlyTool.java | 2 +- .../authfixtures/secured/WriteScopeTool.java | 2 +- flash-extensions/flash-ext-oidc/pom.xml | 4 + .../relism/flash/ext/oidc/ClaimsHolder.java | 71 --- .../flash/ext/oidc/OidcCredentialSource.java | 316 ++++++++++ .../relism/flash/ext/oidc/OidcExtension.java | 37 +- .../relism/flash/ext/oidc/OidcMiddleware.java | 550 ------------------ .../ext/oidc/OidcCredentialSourceTest.java | 43 ++ .../ext/oidc/OidcMiddlewareAuthzTest.java | 72 --- .../ext/oidc/OidcOpenApiInteropTest.java | 4 + flash-extensions/pom.xml | 6 + 27 files changed, 966 insertions(+), 815 deletions(-) create mode 100644 flash-extensions/flash-ext-auth-core/pom.xml create mode 100644 flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthConfig.java create mode 100644 flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthMiddleware.java rename flash-extensions/{flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcAuthPolicy.java => flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthPolicy.java} (71%) rename flash-extensions/{flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc => flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth}/Authenticated.java (67%) rename flash-extensions/{flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcUser.java => flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/Claims.java} (85%) create mode 100644 flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/ClaimsHolder.java create mode 100644 flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/CredentialSource.java rename flash-extensions/{flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc => flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth}/RolesAllowed.java (56%) rename flash-extensions/{flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc => flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth}/ScopesAllowed.java (87%) rename flash-extensions/{flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcAuthPolicyTest.java => flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/AuthPolicyTest.java} (74%) rename flash-extensions/{flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc => flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth}/ClaimMatchingTest.java (94%) rename flash-extensions/{flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcUserScopesTest.java => flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/ClaimsScopesTest.java} (74%) delete mode 100644 flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ClaimsHolder.java create mode 100644 flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcCredentialSource.java delete mode 100644 flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcMiddleware.java create mode 100644 flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcCredentialSourceTest.java delete mode 100644 flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcMiddlewareAuthzTest.java diff --git a/flash-extensions/flash-ext-auth-core/pom.xml b/flash-extensions/flash-ext-auth-core/pom.xml new file mode 100644 index 0000000..abc299f --- /dev/null +++ b/flash-extensions/flash-ext-auth-core/pom.xml @@ -0,0 +1,25 @@ + + + 4.0.0 + + + dev.relism + flash-extensions + 2.1.0-SNAPSHOT + + + flash-ext-auth-core + + + + dev.relism + flash + + + org.junit.jupiter + junit-jupiter + + + diff --git a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthConfig.java b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthConfig.java new file mode 100644 index 0000000..6df58af --- /dev/null +++ b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthConfig.java @@ -0,0 +1,43 @@ +package dev.relism.flash.ext.auth; + +/** + * Where authorization reads its inputs from. Deliberately small: everything about *obtaining* a + * credential belongs to the {@link CredentialSource} that produced it, and everything about + * *checking* one is right here. + * + *

Defaults are the generic spelling, not any one provider's. A source that knows better — + * {@code flash-ext-auth-oidc} defaults roles to Keycloak's {@code realm_access.roles} — builds + * its own {@code AuthConfig} with the paths its provider actually uses. + */ +public final class AuthConfig { + + private final String rolesClaimPath; + private final String scopeClaimPaths; + + private AuthConfig(Builder b) { + this.rolesClaimPath = b.rolesClaimPath; + this.scopeClaimPaths = b.scopeClaimPaths; + } + + /** Dot-separated path to the roles list in the claims (default: {@code roles}). */ + public String rolesClaimPath() { return rolesClaimPath; } + + /** Comma-separated claim paths scopes are read from, in order (default: {@code scope,scp}). */ + public String scopeClaimPaths() { return scopeClaimPaths; } + + public static Builder builder() { return new Builder(); } + + public static final class Builder { + private String rolesClaimPath = "roles"; + private String scopeClaimPaths = "scope,scp"; + + private Builder() {} + + /** Dot-separated path to the roles list — e.g. {@code realm_access.roles}, {@code groups}. */ + public Builder rolesClaimPath(String path) { this.rolesClaimPath = path; return this; } + /** Comma-separated claim paths scopes are read from, tried in order. */ + public Builder scopeClaimPaths(String paths) { this.scopeClaimPaths = paths; return this; } + + public AuthConfig build() { return new AuthConfig(this); } + } +} 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 new file mode 100644 index 0000000..30c7a4d --- /dev/null +++ b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthMiddleware.java @@ -0,0 +1,280 @@ +package dev.relism.flash.ext.auth; + +import dev.relism.flash.exceptions.HttpException; +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 java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Turns claims into a yes or a no. Exposed in the {@link FlashContext} for manual use on lambda + * routes, and injected automatically for handlers annotated with {@link Authenticated}, + * {@link RolesAllowed} or {@link ScopesAllowed}. + * + *

It knows nothing about how the caller proved who they are — that is the + * {@link CredentialSource} it is built with. What lives here is the half that is the same for + * every mechanism: publish the claims for the request, match roles and scopes against them, clear + * up afterwards. + * + *

{@code
+ * AuthMiddleware auth = app.ctx().require(AuthMiddleware.class);
+ * app.get("/api/me", (req, res) -> ClaimsHolder.claim("sub"), auth.protect());
+ * app.delete("/admin/users/{id}", handler, auth.requireRole("admin"));
+ * }
+ */ +public class AuthMiddleware { + + private final AuthConfig config; + private final CredentialSource source; + private final String[] roleClaimPathParts; + private final String[][] scopeClaimPathParts; + + public AuthMiddleware(AuthConfig config, CredentialSource source) { + this.config = config; + this.source = source; + this.roleClaimPathParts = splitClaimPath(config.rolesClaimPath()); + this.scopeClaimPathParts = splitClaimPaths(config.scopeClaimPaths()); + } + + // -- Public API ----------------------------------------------------------- + + /** The single configured claim path used by every transport for role checks. */ + public String rolesClaimPath() { return config.rolesClaimPath(); } + + /** The source this middleware authenticates with. */ + public CredentialSource source() { return source; } + + /** + * The same authorization rules against a different credential source. Used where one route + * needs a variant of an installed source — {@code flash-ext-mcp} protects {@code /mcp} with an + * OIDC source whose challenges carry RFC 9728 resource metadata, while every other route keeps + * the plain one. + */ + public AuthMiddleware withSource(CredentialSource source) { + return new AuthMiddleware(config, source); + } + + /** + * Rejects the request unless the caller is authenticated. How it is rejected — a 401 with a + * challenge, a redirect into a sign-in flow — is the source's decision, not this one's. + */ + public Middleware protect() { + return next -> (req, res) -> { + Map claims = source.authenticate(req, res); + if (claims == null) return null; // the source already answered the request + ClaimsHolder.set(claims); + try { + return next.handle(req, res); + } finally { + ClaimsHolder.clear(); + } + }; + } + + /** + * Publishes claims when the caller happens to be authenticated and never rejects anyone. Use + * it on public routes that personalise their response for signed-in callers. + * + *
{@code
+     * app.get("/", handler, auth.optional());
+     * // Inside handler: ClaimsHolder.current() is non-null iff the caller is signed in.
+     * }
+ */ + public Middleware optional() { + return next -> (req, res) -> { + Map claims = source.peek(req); + if (claims != null) ClaimsHolder.set(claims); + try { + return next.handle(req, res); + } finally { + ClaimsHolder.clear(); + } + }; + } + + /** + * Applies a policy compiled once at boot from a handler's annotations. This is the path + * annotation-driven mounting takes. + */ + public Middleware authorize(AuthPolicy policy) { + if (policy.optionalAuth()) return optional(); + return next -> (req, res) -> { + Map claims = source.authenticate(req, res); + if (claims == null) return null; + enforcePolicy(claims, policy, res); + ClaimsHolder.set(claims); + try { + return next.handle(req, res); + } finally { + ClaimsHolder.clear(); + } + }; + } + + /** {@link #protect()} plus at least one of the given roles (OR semantics). */ + public Middleware requireRole(String... roles) { + return authorize(AuthPolicy.rolesAny(roles)); + } + + /** {@link #protect()} plus every one of the given scopes. */ + public Middleware requireScopes(String... scopes) { + return authorize(AuthPolicy.scopes(scopes, ScopesAllowed.Match.ALL)); + } + + /** {@link #protect()} plus at least one of the given scopes. */ + public Middleware requireAnyScope(String... scopes) { + return authorize(AuthPolicy.scopes(scopes, ScopesAllowed.Match.ANY)); + } + + // -- Policy enforcement --------------------------------------------------- + + private void enforcePolicy(Map claims, AuthPolicy policy, Response res) { + checkRoles(claims, policy.requiredRoles()); + checkScopes(claims, policy.requiredScopes(), policy.scopeMatch(), res); + } + + private void checkRoles(Map claims, String[] required) { + if (required.length == 0) return; + if (rolesAllowed(claims, required)) return; + throw HttpException.forbidden(); + } + + private void checkScopes(Map claims, String[] required, ScopesAllowed.Match match, + Response res) { + if (required.length == 0) return; + if (scopesAllowed(claims, required, match)) return; + String challenge = source.insufficientScopeChallenge(required); + if (challenge != null) res.header("WWW-Authenticate", challenge); + throw HttpException.forbidden(); + } + + // -- Claim matching ------------------------------------------------------- + + boolean rolesAllowed(Map claims, String[] required) { + Object actual = valueAtPath(claims, roleClaimPathParts); + if (actual == null) return false; + for (String role : required) { + if (containsToken(actual, role)) return true; + } + return false; + } + + boolean scopesAllowed(Map claims, String[] required, ScopesAllowed.Match match) { + if (match == ScopesAllowed.Match.ALL) { + for (String scope : required) { + if (!hasScope(claims, scope)) return false; + } + return true; + } + for (String scope : required) { + if (hasScope(claims, scope)) return true; + } + return false; + } + + private boolean hasScope(Map claims, String scope) { + for (String[] pathParts : scopeClaimPathParts) { + Object value = valueAtPath(claims, pathParts); + if (value != null && containsToken(value, scope)) return true; + } + return false; + } + + private static Object valueAtPath(Map claims, String[] pathParts) { + Object current = claims; + for (String part : pathParts) { + if (!(current instanceof Map map)) return null; + current = map.get(part); + if (current == null) return null; + } + return current; + } + + private static boolean containsToken(Object source, String token) { + if (source instanceof String s) return containsDelimitedToken(s, token); + if (source instanceof List list) { + for (Object item : list) { + if (item == null) continue; + if (tokenEquals(item.toString(), token)) return true; + } + return false; + } + if (source instanceof Object[] arr) { + for (Object item : arr) { + if (item == null) continue; + if (tokenEquals(item.toString(), token)) return true; + } + return false; + } + return tokenEquals(source.toString(), token); + } + + private static boolean containsDelimitedToken(String value, String token) { + int len = value.length(); + int i = 0; + while (i < len) { + while (i < len && isScopeDelimiter(value.charAt(i))) i++; + int start = i; + while (i < len && !isScopeDelimiter(value.charAt(i))) i++; + int end = i; + if (end > start && end - start == token.length() && value.regionMatches(start, token, 0, token.length())) { + return true; + } + } + return false; + } + + private static boolean tokenEquals(String value, String token) { + int start = 0; + int end = value.length(); + while (start < end && Character.isWhitespace(value.charAt(start))) start++; + while (end > start && Character.isWhitespace(value.charAt(end - 1))) end--; + return end - start == token.length() && value.regionMatches(start, token, 0, token.length()); + } + + private static boolean isScopeDelimiter(char c) { + return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ','; + } + + private static String[] splitClaimPath(String path) { + if (path == null || path.isBlank()) { + throw new IllegalStateException("Claim path cannot be blank"); + } + List parts = new ArrayList<>(4); + int start = 0; + int len = path.length(); + for (int i = 0; i <= len; i++) { + if (i == len || path.charAt(i) == '.') { + String p = path.substring(start, i).trim(); + if (!p.isEmpty()) parts.add(p); + start = i + 1; + } + } + if (parts.isEmpty()) { + throw new IllegalStateException("Claim path cannot be blank"); + } + return parts.toArray(String[]::new); + } + + private static String[][] splitClaimPaths(String paths) { + String source = (paths == null || paths.isBlank()) ? "scope,scp" : paths; + List out = new ArrayList<>(4); + int start = 0; + int len = source.length(); + for (int i = 0; i <= len; i++) { + if (i == len || source.charAt(i) == ',') { + String raw = source.substring(start, i).trim(); + if (!raw.isEmpty()) out.add(splitClaimPath(raw)); + start = i + 1; + } + } + if (out.isEmpty()) { + return new String[][]{ splitClaimPath("scope"), splitClaimPath("scp") }; + } + return out.toArray(String[][]::new); + } +} diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcAuthPolicy.java b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthPolicy.java similarity index 71% rename from flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcAuthPolicy.java rename to flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthPolicy.java index 4f7c5b1..8cdb741 100644 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcAuthPolicy.java +++ b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthPolicy.java @@ -1,4 +1,4 @@ -package dev.relism.flash.ext.oidc; +package dev.relism.flash.ext.auth; import java.util.LinkedHashSet; import java.util.List; @@ -7,13 +7,13 @@ import java.util.List; * Compiled authorization policy derived from handler annotations at mount time. * Immutable and allocation-free on the request hot path. */ -final class OidcAuthPolicy { +public final class AuthPolicy { private static final String[] EMPTY = new String[0]; - private static final OidcAuthPolicy AUTH_REQUIRED = new OidcAuthPolicy( + private static final AuthPolicy AUTH_REQUIRED = new AuthPolicy( false, EMPTY, EMPTY, ScopesAllowed.Match.ALL); - private static final OidcAuthPolicy AUTH_OPTIONAL = new OidcAuthPolicy( + private static final AuthPolicy AUTH_OPTIONAL = new AuthPolicy( true, EMPTY, EMPTY, ScopesAllowed.Match.ALL); private final boolean optionalAuth; @@ -21,7 +21,7 @@ final class OidcAuthPolicy { private final String[] requiredScopes; private final ScopesAllowed.Match scopeMatch; - private OidcAuthPolicy(boolean optionalAuth, + private AuthPolicy(boolean optionalAuth, String[] requiredRoles, String[] requiredScopes, ScopesAllowed.Match scopeMatch) { @@ -31,19 +31,19 @@ final class OidcAuthPolicy { this.scopeMatch = scopeMatch; } - static OidcAuthPolicy authenticated() { return AUTH_REQUIRED; } + public static AuthPolicy authenticated() { return AUTH_REQUIRED; } - static OidcAuthPolicy optional() { return AUTH_OPTIONAL; } + public static AuthPolicy optional() { return AUTH_OPTIONAL; } - static OidcAuthPolicy rolesAny(String... roles) { - return new OidcAuthPolicy(false, normalizeRequired("RolesAllowed", roles), EMPTY, ScopesAllowed.Match.ALL); + public static AuthPolicy rolesAny(String... roles) { + return new AuthPolicy(false, normalizeRequired("RolesAllowed", roles), EMPTY, ScopesAllowed.Match.ALL); } - static OidcAuthPolicy scopes(String[] scopes, ScopesAllowed.Match match) { - return new OidcAuthPolicy(false, EMPTY, normalizeRequired("ScopesAllowed", scopes), match); + public static AuthPolicy scopes(String[] scopes, ScopesAllowed.Match match) { + return new AuthPolicy(false, EMPTY, normalizeRequired("ScopesAllowed", scopes), match); } - static OidcAuthPolicy compileFromAnnotations(Class handlerClass) { + public static AuthPolicy compileFromAnnotations(Class handlerClass) { Authenticated auth = handlerClass.getAnnotation(Authenticated.class); RolesAllowed roles = handlerClass.getAnnotation(RolesAllowed.class); ScopesAllowed scopes = handlerClass.getAnnotation(ScopesAllowed.class); @@ -60,10 +60,10 @@ final class OidcAuthPolicy { + handlerClass.getName()); } - return new OidcAuthPolicy(optionalAuth, requiredRoles, requiredScopes, scopeMatch); + return new AuthPolicy(optionalAuth, requiredRoles, requiredScopes, scopeMatch); } - static List openApiScopesFor(Class handlerClass) { + public static List openApiScopesFor(Class handlerClass) { Authenticated auth = handlerClass.getAnnotation(Authenticated.class); RolesAllowed roles = handlerClass.getAnnotation(RolesAllowed.class); ScopesAllowed scopes = handlerClass.getAnnotation(ScopesAllowed.class); @@ -72,13 +72,13 @@ final class OidcAuthPolicy { return List.of(normalizeRequired("ScopesAllowed", scopes.value())); } - boolean optionalAuth() { return optionalAuth; } + public boolean optionalAuth() { return optionalAuth; } - String[] requiredRoles() { return requiredRoles; } + public String[] requiredRoles() { return requiredRoles; } - String[] requiredScopes() { return requiredScopes; } + public String[] requiredScopes() { return requiredScopes; } - ScopesAllowed.Match scopeMatch() { return scopeMatch; } + public ScopesAllowed.Match scopeMatch() { return scopeMatch; } private static String[] normalizeRequired(String annotation, String[] values) { if (values == null || values.length == 0) diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/Authenticated.java b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/Authenticated.java similarity index 67% rename from flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/Authenticated.java rename to flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/Authenticated.java index 4888202..7ef6faa 100644 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/Authenticated.java +++ b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/Authenticated.java @@ -1,4 +1,4 @@ -package dev.relism.flash.ext.oidc; +package dev.relism.flash.ext.auth; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; @@ -6,23 +6,23 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** - * Marks a handler as requiring a valid JWT. Any bearer token that passes - * signature + expiry + issuer validation is accepted — no role check is performed. + * Marks a handler as requiring an authenticated caller. Any credential a registered source + * accepts is enough — no role or scope check is performed. * *

For role-based access use {@link RolesAllowed} instead (it implies authentication). * - *

Set {@code optional = true} on public routes that personalise their response when - * the user happens to be logged in but should remain accessible to guests. The middleware - * will populate {@link ClaimsHolder} if credentials are present and silently skip it - * otherwise — the request is never rejected. + *

Set {@code optional = true} on public routes that personalise their response when the caller + * happens to be signed in but should remain reachable by guests. The middleware populates + * {@link ClaimsHolder} when a credential is present and silently skips it otherwise — the request + * is never rejected. * *

{@code
- * // Hard auth — redirects / 401 when unauthenticated:
+ * // Hard auth — 401 or a redirect when unauthenticated:
  * @Route(method = HttpMethod.GET, path = "/api/profile")
  * @Authenticated
  * public class GetProfile extends JacksonHandler { ... }
  *
- * // Soft auth — guest-friendly, ClaimsHolder populated only when logged in:
+ * // Soft auth — guest-friendly, ClaimsHolder populated only when signed in:
  * @Route(method = HttpMethod.GET, path = "/")
  * @Authenticated(optional = true)
  * public class HomePage extends HtmlHandler { ... }
diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcUser.java b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/Claims.java
similarity index 85%
rename from flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcUser.java
rename to flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/Claims.java
index d54f7f4..bc71286 100644
--- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcUser.java
+++ b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/Claims.java
@@ -1,43 +1,36 @@
-package dev.relism.flash.ext.oidc;
+package dev.relism.flash.ext.auth;
 
 import java.util.List;
 import java.util.Map;
 import java.util.ArrayList;
 
 /**
- * Type-safe view over the JWT claims stored in {@link ClaimsHolder}.
+ * A typed view over one request's claims — whatever the {@link CredentialSource} that
+ * authenticated it produced. Obtained from {@link ClaimsHolder#current()}.
  *
- * 

Obtainable from any protected context via {@link ClaimsHolder#user()}. - * Class-based handlers that extend the {@code SessionHandler} hierarchy already - * have a provisioned DB user in {@code currentUser}; {@code OidcUser} complements - * that by giving access to the raw OIDC claims when needed, and is the primary - * API for lambda routes. + *

The accessors name claim keys, not a protocol: {@code sub} is RFC 7519, and + * {@code email}, {@code name} and {@code preferred_username} are spelled the same way by every + * token issuer worth integrating. A source that uses different keys exposes them through + * {@link #claim(String)} or {@link #roles(String)}. * *

{@code
- * // Lambda route (OidcMiddleware injected):
  * app.get("/api/whoami", (req, res) -> {
- *     OidcUser u = ClaimsHolder.user();
- *     return Map.of("sub", u.sub(), "email", u.email(), "roles", u.roles("realm_access.roles"), "scopes", u.scopes());
- * }, oidcMw.protect());
- *
- * // Class-based handler (currentUser is the DB entity; oidcUser() for raw claims):
- * protected Object handleAuthenticated(Request req, Response res) throws Exception {
- *     OidcUser u = oidcUser();           // same as ClaimsHolder.user()
- *     return json(res, currentUser);     // DB entity — provisioned from OIDC sub
- * }
+ *     Claims c = ClaimsHolder.current();
+ *     return Map.of("sub", c.sub(), "email", c.email(), "roles", c.roles("realm_access.roles"));
+ * }, auth.protect());
  * }
*/ -public final class OidcUser { +public final class Claims { private final Map claims; - OidcUser(Map claims) { + Claims(Map claims) { this.claims = claims; } - // ── Common OIDC standard claims ─────────────────────────────────────────── + // ── Common claims ───────────────────────────────────────────────────────── - /** Subject identifier — unique, stable user ID issued by the provider. */ + /** Subject identifier — the stable, unique id of the caller. */ public String sub() { return str("sub"); } /** User's email address ({@code email} claim). */ @@ -84,7 +77,7 @@ public final class OidcUser { // -- Scopes --------------------------------------------------------------- /** - * Resolves OAuth2 scopes from standard OIDC/OAuth claims using fallback order: + * Resolves scopes using the conventional fallback order: * {@code scope} then {@code scp}. Supports both space-separated string and list forms. */ public List scopes() { diff --git a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/ClaimsHolder.java b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/ClaimsHolder.java new file mode 100644 index 0000000..a8cf400 --- /dev/null +++ b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/ClaimsHolder.java @@ -0,0 +1,64 @@ +package dev.relism.flash.ext.auth; + +import java.util.Map; + +/** + * The current request's claims, published by {@link AuthMiddleware} before the handler runs and + * cleared in a {@code finally} afterwards. + * + *

Safe with virtual threads: each request gets its own, so a {@link ThreadLocal} is naturally + * isolated per request. + * + *

Writing is deliberately not public. A {@link CredentialSource} returns claims and the + * middleware publishes them, so no code outside this module can put claims on a request that did + * not carry them. + * + *

{@code
+ * // Inside any handler behind @Authenticated or @RolesAllowed:
+ * Claims caller = ClaimsHolder.current();
+ * String email  = caller.email();
+ * List roles = caller.roles("realm_access.roles");
+ *
+ * // Raw escape hatch:
+ * Map all = ClaimsHolder.map();
+ * }
+ */ +public final class ClaimsHolder { + + private static final ThreadLocal> HOLDER = new ThreadLocal<>(); + + private ClaimsHolder() {} + + /** Called by {@link AuthMiddleware} once a source has authenticated the request. */ + static void set(Map claims) { + HOLDER.set(claims); + } + + /** Called by {@link AuthMiddleware} in the {@code finally} block. */ + static void clear() { + HOLDER.remove(); + } + + /** + * A typed view of the current request's claims, or {@code null} when the route carries no + * authentication middleware or the caller is anonymous under + * {@link Authenticated}{@code (optional = true)}. + */ + public static Claims current() { + Map claims = HOLDER.get(); + return claims != null ? new Claims(claims) : null; + } + + /** The raw claims map for the current request, or {@code null}. @see #current() */ + public static Map map() { + return HOLDER.get(); + } + + /** A single claim as a String, or {@code null} when absent or the caller is anonymous. */ + public static String claim(String key) { + Map claims = HOLDER.get(); + if (claims == null) return null; + Object v = claims.get(key); + return v != null ? v.toString() : null; + } +} diff --git a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/CredentialSource.java b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/CredentialSource.java new file mode 100644 index 0000000..4ceb2f5 --- /dev/null +++ b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/CredentialSource.java @@ -0,0 +1,48 @@ +package dev.relism.flash.ext.auth; + +import dev.relism.flash.exceptions.HttpException; +import dev.relism.flash.models.Request; +import dev.relism.flash.models.Response; + +import java.util.Map; + +/** + * Turns whatever a request carries — a bearer token, a session cookie, an API key — into the + * claims authorization runs on. One is installed per authentication mechanism; + * {@code flash-ext-auth-oidc} contributes the OpenID Connect one. + * + *

Implementations never touch {@link ClaimsHolder}: they produce claims and {@link + * AuthMiddleware} publishes them for the duration of the request. Nothing outside this module can + * inject claims into a request, which is the point. + */ +public interface CredentialSource { + + /** + * Resolves the caller's claims, rejecting the request when it cannot. + * + *

Three outcomes, and the difference between the last two matters: + *

    + *
  • claims — the caller presented a valid credential;
  • + *
  • {@code null} — no credential was presented and this source has already answered the + * request itself (typically a redirect into a sign-in flow). The middleware stops and + * writes nothing more;
  • + *
  • {@link HttpException} — a credential was presented and is invalid. The source + * sets any challenge header it owes the caller before throwing.
  • + *
+ */ + Map authenticate(Request req, Response res); + + /** + * Resolves claims without ever rejecting: {@code null} when no valid credential is present. + * Backs {@link Authenticated}{@code (optional = true)}, where an anonymous caller is a normal + * outcome rather than a failure. + */ + Map peek(Request req); + + /** + * The {@code WWW-Authenticate} value to send with a 403 caused by missing scopes, or + * {@code null} when this source has no such concept. Only consulted after authentication has + * already succeeded. + */ + default String insufficientScopeChallenge(String[] requiredScopes) { return null; } +} diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/RolesAllowed.java b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/RolesAllowed.java similarity index 56% rename from flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/RolesAllowed.java rename to flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/RolesAllowed.java index 1009f14..b37020d 100644 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/RolesAllowed.java +++ b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/RolesAllowed.java @@ -1,4 +1,4 @@ -package dev.relism.flash.ext.oidc; +package dev.relism.flash.ext.auth; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; @@ -6,20 +6,19 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** - * Restricts a handler to callers whose JWT contains at least one of the - * specified roles. Authentication is implicitly required — no need to combine - * with {@link Authenticated}. + * Restricts a handler to callers holding at least one of the named roles. Authentication is + * implied — there is no need to combine it with {@link Authenticated}. * - *

Roles are read from the claim configured in {@link OidcConfig#rolesClaimPath()} - * (default: {@code "roles"}). Nested paths like {@code "realm_access.roles"} are - * supported with dot notation. + *

Roles are read from the claim path the installed credential source is configured with + * (Keycloak's is {@code realm_access.roles}; many providers use a flat {@code roles} or + * {@code groups}). Nested paths use dot notation. * *

{@code
  * @Route(method = HttpMethod.DELETE, path = "/api/admin/blogs/{id}")
  * @RolesAllowed("admin")
  * public class DeleteBlog extends JacksonHandler { ... }
  *
- * // Multiple accepted roles (OR semantics — any one role is sufficient):
+ * // Multiple accepted roles (OR semantics — any one is sufficient):
  * @RolesAllowed({"admin", "editor"})
  * public class UpdateBlog extends JacksonHandler { ... }
  * }
diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ScopesAllowed.java b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/ScopesAllowed.java similarity index 87% rename from flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ScopesAllowed.java rename to flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/ScopesAllowed.java index a5ba66f..40cd8c5 100644 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ScopesAllowed.java +++ b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/ScopesAllowed.java @@ -1,4 +1,4 @@ -package dev.relism.flash.ext.oidc; +package dev.relism.flash.ext.auth; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; @@ -6,11 +6,11 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** - * Restricts a handler to callers whose token carries the required OAuth2 scopes. + * Restricts a handler to callers whose credential carries the required scopes. * Authentication is implicitly required. * *

Scopes are resolved from the configured claim paths in - * {@link OidcConfig#scopeClaimPaths()} (default: {@code "scope,scp"}) and support + * {@link AuthConfig#scopeClaimPaths()} (default: {@code "scope,scp"}) and support * both standard formats: *

    *
  • {@code scope}: space-separated string
  • diff --git a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcAuthPolicyTest.java b/flash-extensions/flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/AuthPolicyTest.java similarity index 74% rename from flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcAuthPolicyTest.java rename to flash-extensions/flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/AuthPolicyTest.java index e1677a5..a2e8139 100644 --- a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcAuthPolicyTest.java +++ b/flash-extensions/flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/AuthPolicyTest.java @@ -1,4 +1,4 @@ -package dev.relism.flash.ext.oidc; +package dev.relism.flash.ext.auth; import org.junit.jupiter.api.Test; @@ -6,7 +6,7 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.*; -class OidcAuthPolicyTest { +class AuthPolicyTest { static class PlainHandler {} @@ -33,12 +33,12 @@ class OidcAuthPolicyTest { @Test void compileFromAnnotations_noSecurityAnnotations_returnsNull() { - assertNull(OidcAuthPolicy.compileFromAnnotations(PlainHandler.class)); + assertNull(AuthPolicy.compileFromAnnotations(PlainHandler.class)); } @Test void compileFromAnnotations_authenticated_createsRequiredAuthPolicy() { - OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(AuthenticatedHandler.class); + AuthPolicy policy = AuthPolicy.compileFromAnnotations(AuthenticatedHandler.class); assertNotNull(policy); assertFalse(policy.optionalAuth()); assertEquals(0, policy.requiredRoles().length); @@ -47,14 +47,14 @@ class OidcAuthPolicyTest { @Test void compileFromAnnotations_optionalAuth_createsOptionalPolicy() { - OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(OptionalHandler.class); + AuthPolicy policy = AuthPolicy.compileFromAnnotations(OptionalHandler.class); assertNotNull(policy); assertTrue(policy.optionalAuth()); } @Test void compileFromAnnotations_rolesAndScopes_areNormalizedAndMerged() { - OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(CombinedHandler.class); + AuthPolicy policy = AuthPolicy.compileFromAnnotations(CombinedHandler.class); assertNotNull(policy); assertFalse(policy.optionalAuth()); assertArrayEquals(new String[]{"admin"}, policy.requiredRoles()); @@ -64,7 +64,7 @@ class OidcAuthPolicyTest { @Test void compileFromAnnotations_scopesAny_preservesMatchModeAndDedupes() { - OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(ScopesHandler.class); + AuthPolicy policy = AuthPolicy.compileFromAnnotations(ScopesHandler.class); assertNotNull(policy); assertArrayEquals(new String[]{"orders:write", "payments:write"}, policy.requiredScopes()); assertEquals(ScopesAllowed.Match.ANY, policy.scopeMatch()); @@ -73,22 +73,22 @@ class OidcAuthPolicyTest { @Test void compileFromAnnotations_optionalCannotBeCombinedWithConstraints() { assertThrows(IllegalStateException.class, - () -> OidcAuthPolicy.compileFromAnnotations(InvalidOptionalHandler.class)); + () -> AuthPolicy.compileFromAnnotations(InvalidOptionalHandler.class)); } @Test void openApiScopesFor_returnsScopesWhenPresent() { assertEquals(List.of("orders:write", "payments:write"), - OidcAuthPolicy.openApiScopesFor(ScopesHandler.class)); + AuthPolicy.openApiScopesFor(ScopesHandler.class)); } @Test void openApiScopesFor_rolesOnly_returnsEmptyList() { - assertEquals(List.of(), OidcAuthPolicy.openApiScopesFor(RolesHandler.class)); + assertEquals(List.of(), AuthPolicy.openApiScopesFor(RolesHandler.class)); } @Test void openApiScopesFor_noSecurity_returnsNull() { - assertNull(OidcAuthPolicy.openApiScopesFor(PlainHandler.class)); + assertNull(AuthPolicy.openApiScopesFor(PlainHandler.class)); } } diff --git a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/ClaimMatchingTest.java b/flash-extensions/flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/ClaimMatchingTest.java similarity index 94% rename from flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/ClaimMatchingTest.java rename to flash-extensions/flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/ClaimMatchingTest.java index 82132b2..f74f63f 100644 --- a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/ClaimMatchingTest.java +++ b/flash-extensions/flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/ClaimMatchingTest.java @@ -1,4 +1,4 @@ -package dev.relism.flash.ext.oidc; +package dev.relism.flash.ext.auth; import org.junit.jupiter.api.Test; @@ -23,15 +23,15 @@ import static org.junit.jupiter.api.Assertions.assertTrue; */ class ClaimMatchingTest { - private static OidcMiddleware middleware(String rolesPath, String scopePaths) { - return new OidcMiddleware(null, OidcConfig - .builder("https://idp.example.com", "client", "secret", "/auth/callback") + /** No credential source: every assertion here is about claims that are already resolved. */ + private static AuthMiddleware middleware(String rolesPath, String scopePaths) { + return new AuthMiddleware(AuthConfig.builder() .rolesClaimPath(rolesPath) .scopeClaimPaths(scopePaths) - .build(), null, null); + .build(), null); } - private static OidcMiddleware middleware() { + private static AuthMiddleware middleware() { return middleware("realm_access.roles", "scope,scp"); } @@ -182,7 +182,7 @@ class ClaimMatchingTest { @Test void scopesAreLookedForInEveryConfiguredPathUntilOneMatches() { - OidcMiddleware mw = middleware("roles", "scope, scp , permissions.scopes"); + AuthMiddleware mw = middleware("roles", "scope, scp , permissions.scopes"); Map claims = Map.of( "scp", List.of("payments:write"), "permissions", Map.of("scopes", "orders:approve")); @@ -196,14 +196,14 @@ class ClaimMatchingTest { @Test void blankScopePathsFallBackToScopeAndScp() { - OidcMiddleware mw = middleware("roles", " "); + AuthMiddleware mw = middleware("roles", " "); assertTrue(mw.scopesAllowed(Map.of("scope", "a"), new String[]{"a"}, ScopesAllowed.Match.ALL)); assertTrue(mw.scopesAllowed(Map.of("scp", "b"), new String[]{"b"}, ScopesAllowed.Match.ALL)); } @Test void aScopePathListOfOnlySeparatorsFallsBackToScopeAndScp() { - OidcMiddleware mw = middleware("roles", " , , "); + AuthMiddleware mw = middleware("roles", " , , "); assertTrue(mw.scopesAllowed(Map.of("scp", "b"), new String[]{"b"}, ScopesAllowed.Match.ALL)); } } diff --git a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcUserScopesTest.java b/flash-extensions/flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/ClaimsScopesTest.java similarity index 74% rename from flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcUserScopesTest.java rename to flash-extensions/flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/ClaimsScopesTest.java index 4fbcc67..6e6fe1e 100644 --- a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcUserScopesTest.java +++ b/flash-extensions/flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/ClaimsScopesTest.java @@ -1,4 +1,4 @@ -package dev.relism.flash.ext.oidc; +package dev.relism.flash.ext.auth; import org.junit.jupiter.api.Test; @@ -7,11 +7,11 @@ import java.util.Map; import static org.junit.jupiter.api.Assertions.*; -class OidcUserScopesTest { +class ClaimsScopesTest { @Test void scopes_readsStandardScopeString() { - OidcUser user = new OidcUser(Map.of("scope", "openid profile orders:read")); + Claims user = new Claims(Map.of("scope", "openid profile orders:read")); assertEquals(List.of("openid", "profile", "orders:read"), user.scopes()); assertTrue(user.hasScope("orders:read")); @@ -20,7 +20,7 @@ class OidcUserScopesTest { @Test void scopes_fallsBackToScpArray() { - OidcUser user = new OidcUser(Map.of("scp", List.of("orders:write", "payments:write"))); + Claims user = new Claims(Map.of("scp", List.of("orders:write", "payments:write"))); assertEquals(List.of("orders:write", "payments:write"), user.scopes()); assertTrue(user.hasScope("payments:write")); @@ -28,7 +28,7 @@ class OidcUserScopesTest { @Test void scopes_supportsCustomClaimPaths() { - OidcUser user = new OidcUser(Map.of("permissions", Map.of("scopes", List.of("a", "b")))); + Claims user = new Claims(Map.of("permissions", Map.of("scopes", List.of("a", "b")))); assertEquals(List.of("a", "b"), user.scopes("permissions.scopes")); assertTrue(user.hasScope("permissions.scopes", "a")); @@ -37,7 +37,7 @@ class OidcUserScopesTest { @Test void scopes_combinesMultipleClaimPathsInOrder() { - OidcUser user = new OidcUser(Map.of( + Claims user = new Claims(Map.of( "scope", "openid", "scp", List.of("profile", "orders:read") )); 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 80eae76..c971968 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 @@ -1,11 +1,12 @@ package dev.relism.flash.ext.mcp; -import dev.relism.flash.ext.oidc.Authenticated; -import dev.relism.flash.ext.oidc.ClaimsHolder; -import dev.relism.flash.ext.oidc.OidcMiddleware; -import dev.relism.flash.ext.oidc.OidcUser; -import dev.relism.flash.ext.oidc.RolesAllowed; -import dev.relism.flash.ext.oidc.ScopesAllowed; +import dev.relism.flash.ext.auth.AuthMiddleware; +import dev.relism.flash.ext.auth.Authenticated; +import dev.relism.flash.ext.auth.Claims; +import dev.relism.flash.ext.auth.ClaimsHolder; +import dev.relism.flash.ext.auth.RolesAllowed; +import dev.relism.flash.ext.auth.ScopesAllowed; +import dev.relism.flash.ext.oidc.OidcCredentialSource; import dev.relism.flash.exceptions.HttpException; import dev.relism.flash.extension.FlashContext; import dev.relism.flash.models.Request; @@ -19,7 +20,7 @@ import java.util.function.Function; import java.util.function.Supplier; /** - * Lazy, isolated bridge to {@code flash-ext-oidc}. + * Lazy, isolated bridge to {@code flash-ext-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 @@ -34,7 +35,7 @@ import java.util.function.Supplier; *

    Zero-config by design: when {@code flash-ext-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 OidcMiddleware}, with no additional {@link McpConfig} calls. + * the installed {@link OidcCredentialSource}, with no additional {@link McpConfig} calls. * {@link McpConfig#resourceIdentifier(String)}/{@link McpConfig#authorizationServerIssuer(String)} * remain as explicit overrides for the rare case where that guess is wrong. */ @@ -51,20 +52,25 @@ final class McpOidcIntegration { /** Returns the resolved security bundle, or {@code null} if oidc is not installed. */ static Resolved resolve(FlashContext ctx, McpConfig config) { - Optional oidc = ctx.find(OidcMiddleware.class); - if (oidc.isEmpty()) return null; + // Deliberately keyed on the OIDC source and not on AuthMiddleware: McpSecurity means + // "a real OAuth2 authorization server is protecting this endpoint", and an app that + // authenticates some other way must not satisfy REQUIRED by accident. + Optional oidc = ctx.find(OidcCredentialSource.class); + Optional auth = ctx.find(AuthMiddleware.class); + if (oidc.isEmpty() || auth.isEmpty()) return null; - OidcMiddleware oidcMw = oidc.get(); + OidcCredentialSource source = oidc.get(); + AuthMiddleware authMw = auth.get(); String resourceMetadataPath = "/.well-known/oauth-protected-resource" + config.rootPath(); String issuer = config.authorizationServerIssuer() != null - ? config.authorizationServerIssuer() : oidcMw.issuer(); + ? config.authorizationServerIssuer() : source.issuer(); Function resourceId = req -> config.resourceIdentifier() != null ? config.resourceIdentifier() - : OidcMiddleware.selfOrigin(req, oidcMw.selfScheme()) + config.rootPath(); + : OidcCredentialSource.selfOrigin(req, source.selfScheme()) + config.rootPath(); - Middleware protect = oidcMw.protect(resourceMetadataPath); + Middleware protect = authMw.withSource(source.withResourceMetadata(resourceMetadataPath)).protect(); Middleware secured = Middleware.of(protect, audienceGuard(resourceId)); - return new Resolved(secured, issuer, oidcMw.rolesClaimPath(), resourceId); + return new Resolved(secured, issuer, authMw.rolesClaimPath(), resourceId); } /** @@ -73,7 +79,7 @@ final class McpOidcIntegration { */ private static Middleware audienceGuard(Function resourceIdentifier) { return next -> (req, res) -> { - Map claims = ClaimsHolder.get(); + Map claims = ClaimsHolder.map(); String expected = resourceIdentifier.apply(req); if (claims != null && !audienceMatches(claims.get("aud"), expected)) { log.warn("[flash-ext-mcp] Rejecting token (RFC 8707): aud={} does not include expected " + @@ -100,7 +106,7 @@ final class McpOidcIntegration { * annotations. Called once per tool at boot ({@link McpRegistry#scan}), never on the * request hot path — the {@link Supplier} it returns is what runs per {@code tools/call}, * closing over the already-normalized role/scope arrays so the hot path itself allocates - * nothing beyond what {@link OidcUser#hasRole}/{@link OidcUser#hasScope} already do. + * nothing beyond what {@link Claims#hasRole}/{@link Claims#hasScope} already do. * *

    Fails fast at boot, not silently at request time, for the two ways this can be * misconfigured: the annotation present without OAuth2 actually protecting this MCP server @@ -136,7 +142,7 @@ final class McpOidcIntegration { ScopesAllowed.Match scopeMatch = scopes != null ? scopes.match() : ScopesAllowed.Match.ALL; Supplier check = () -> { - OidcUser user = ClaimsHolder.user(); + Claims user = ClaimsHolder.current(); if (user == null) return "not authenticated"; if (requiredRoles.length > 0 && !hasAnyRole(user, rolesClaimPath, requiredRoles)) return "missing required role (any of: " + String.join(", ", requiredRoles) + ")"; @@ -147,12 +153,12 @@ final class McpOidcIntegration { return new McpAuthPolicy(check); } - private static boolean hasAnyRole(OidcUser user, String claimPath, String[] roles) { + private static boolean hasAnyRole(Claims user, String claimPath, String[] roles) { for (String role : roles) if (user.hasRole(claimPath, role)) return true; return false; } - private static boolean hasScopes(OidcUser user, String[] scopes, ScopesAllowed.Match match) { + private static boolean hasScopes(Claims user, String[] scopes, ScopesAllowed.Match match) { if (match == ScopesAllowed.Match.ALL) { for (String scope : scopes) if (!user.hasScope(scope)) return false; return true; 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 8f0bc37..8eb5f29 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 @@ -63,7 +63,7 @@ final class FakeOidcProvider implements AutoCloseable { /** * Same as {@link #signToken(String, String)}, plus a {@code scope} claim (space-delimited, - * matching {@link dev.relism.flash.ext.oidc.OidcUser#hasScope}'s default claim path) and a + * matching {@link dev.relism.flash.ext.auth.Claims#hasScope}'s default claim path) and a * Keycloak-shaped {@code realm_access.roles} claim (matching {@code McpConfig}'s default * {@code rolesClaimPath}) when {@code roles} is non-empty. */ diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/authenticatedonly/PointlessAuthTool.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/authenticatedonly/PointlessAuthTool.java index 9d9a622..71934d9 100644 --- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/authenticatedonly/PointlessAuthTool.java +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/authenticatedonly/PointlessAuthTool.java @@ -5,7 +5,7 @@ import dev.relism.flash.ext.mcp.TextContent; import dev.relism.flash.ext.mcp.Tool; import dev.relism.flash.ext.mcp.ToolArguments; import dev.relism.flash.ext.mcp.ToolResponse; -import dev.relism.flash.ext.oidc.Authenticated; +import dev.relism.flash.ext.auth.Authenticated; /** Deliberately misconfigured fixture: bare @Authenticated has no effect on an McpTool — see * McpOidcIntegration#compileToolPolicy. Boot must fail with a clear message, not silently no-op. */ diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/AdminOnlyTool.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/AdminOnlyTool.java index 1bd96f5..9da014e 100644 --- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/AdminOnlyTool.java +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/AdminOnlyTool.java @@ -5,7 +5,7 @@ import dev.relism.flash.ext.mcp.TextContent; import dev.relism.flash.ext.mcp.Tool; import dev.relism.flash.ext.mcp.ToolArguments; import dev.relism.flash.ext.mcp.ToolResponse; -import dev.relism.flash.ext.oidc.RolesAllowed; +import dev.relism.flash.ext.auth.RolesAllowed; @Tool(name = "admin_only", description = "Only callable with the admin role") @RolesAllowed("admin") diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/WriteScopeTool.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/WriteScopeTool.java index 14b390f..aa4a3c0 100644 --- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/WriteScopeTool.java +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/WriteScopeTool.java @@ -5,7 +5,7 @@ import dev.relism.flash.ext.mcp.TextContent; import dev.relism.flash.ext.mcp.Tool; import dev.relism.flash.ext.mcp.ToolArguments; import dev.relism.flash.ext.mcp.ToolResponse; -import dev.relism.flash.ext.oidc.ScopesAllowed; +import dev.relism.flash.ext.auth.ScopesAllowed; @Tool(name = "write_only", description = "Only callable with the write scope") @ScopesAllowed("write") diff --git a/flash-extensions/flash-ext-oidc/pom.xml b/flash-extensions/flash-ext-oidc/pom.xml index 374d708..9967473 100644 --- a/flash-extensions/flash-ext-oidc/pom.xml +++ b/flash-extensions/flash-ext-oidc/pom.xml @@ -13,6 +13,10 @@ flash-ext-oidc + + dev.relism + flash-ext-auth-core + dev.relism flash diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ClaimsHolder.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ClaimsHolder.java deleted file mode 100644 index 490147a..0000000 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/ClaimsHolder.java +++ /dev/null @@ -1,71 +0,0 @@ -package dev.relism.flash.ext.oidc; - -import java.util.Map; - -/** - * Thread-local store for JWT claims, populated by the OIDC middleware before - * the handler runs and cleared in the {@code finally} block afterward. - * - *

    Safe with virtual threads: each request gets its own virtual thread, so - * {@link ThreadLocal} values are naturally isolated per request. - * - *

    {@code
    - * // Inside any handler protected by @Authenticated or @RolesAllowed:
    - *
    - * // Preferred — typed wrapper:
    - * OidcUser user = ClaimsHolder.user();
    - * String email  = user.email();
    - * List roles = user.roles("realm_access.roles");
    - *
    - * // Raw escape hatch:
    - * Map all = ClaimsHolder.get();
    - * }
    - */ -public final class ClaimsHolder { - - private static final ThreadLocal> HOLDER = new ThreadLocal<>(); - - private ClaimsHolder() {} - - /** Called by the OIDC middleware after successful token validation. */ - static void set(Map claims) { - HOLDER.set(claims); - } - - /** Called by the OIDC middleware in the {@code finally} block. */ - static void clear() { - HOLDER.remove(); - } - - /** - * Returns a type-safe {@link OidcUser} view of the current request's claims, - * or {@code null} if the route is not protected by OIDC middleware. - * - *

    This is the preferred entry point for both lambda and class-based handlers. - */ - public static OidcUser user() { - Map claims = HOLDER.get(); - return claims != null ? new OidcUser(claims) : null; - } - - /** - * Returns the raw claims map for the current request, or {@code null} if - * the route is not protected by OIDC middleware. - * - * @see #user() for the preferred type-safe accessor - */ - public static Map get() { - return HOLDER.get(); - } - - /** - * Returns the value of a single claim as a String, or {@code null} if - * the claim is absent or the request is not authenticated. - */ - public static String claim(String key) { - Map claims = HOLDER.get(); - if (claims == null) return null; - Object v = claims.get(key); - return v != null ? v.toString() : null; - } -} diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcCredentialSource.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcCredentialSource.java new file mode 100644 index 0000000..36f6adf --- /dev/null +++ b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcCredentialSource.java @@ -0,0 +1,316 @@ +package dev.relism.flash.ext.oidc; + +import dev.relism.flash.exceptions.HttpException; +import dev.relism.flash.ext.auth.CredentialSource; +import dev.relism.flash.models.Response; +import dev.relism.flash.models.Request; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** + * The OpenID Connect {@link CredentialSource}: it turns what a request carries into claims, and + * rejects it the way OAuth2 says to when it cannot. Authorization on those claims is + * {@code flash-ext-auth-core}'s job, not this class's. + * + *

    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 + * refreshed if the access token is expired.
    4. + *
    5. Browser clients (no {@code Accept: application/json}) → redirect to + * {@code {routePrefix}/login?redirect={path}}.
    6. + *
    7. API clients → 401 with a {@code WWW-Authenticate: Bearer} challenge.
    8. + *
    + */ +public final class OidcCredentialSource implements CredentialSource { + + private static final String BEARER = "Bearer"; + + private final JwtValidator validator; + private final OidcConfig config; + private final OidcProviderMetadata meta; + private final TokenClient tokenClient; + private final String resourceMetadataPath; + + OidcCredentialSource(JwtValidator validator, OidcConfig config, + OidcProviderMetadata meta, TokenClient tokenClient) { + this(validator, config, meta, tokenClient, null); + } + + private OidcCredentialSource(JwtValidator validator, OidcConfig config, + OidcProviderMetadata meta, TokenClient tokenClient, + String resourceMetadataPath) { + this.validator = validator; + this.config = config; + this.meta = meta; + this.tokenClient = tokenClient; + this.resourceMetadataPath = resourceMetadataPath; + } + + // -- CredentialSource ----------------------------------------------------- + + /** OIDC issuer this source validates tokens against — the {@code iss} claim it enforces. */ + public String issuer() { return config.issuer(); } + + /** Scheme used to build this app's own absolute URLs — see {@link OidcConfig#selfScheme()}. */ + public String selfScheme() { return config.selfScheme(); } + + /** + * A copy of this source whose 401 challenges also carry {@code resource_metadata} + * (RFC 9728 §5.1), resolved against the request's own scheme and host exactly like + * {@link OidcExtension}'s redirect URIs. {@code path} is absolute, e.g. + * {@code "/.well-known/oauth-protected-resource/mcp"}. + * + *

    Used by {@code flash-ext-mcp} to make its Protected Resource Metadata document + * discoverable straight from the {@code WWW-Authenticate} header, per the MCP Authorization + * spec. + */ + public OidcCredentialSource withResourceMetadata(String path) { + return new OidcCredentialSource(validator, config, meta, tokenClient, path); + } + + @Override + public Map authenticate(Request req, Response res) { + return resolve(req, res, resourceMetadataPath); + } + + @Override + public Map peek(Request req) { + return resolveQuiet(req); + } + + @Override + public String insufficientScopeChallenge(String[] requiredScopes) { + return bearerChallenge() + ", error=\"insufficient_scope\", scope=\"" + + quoted(spaceDelimited(requiredScopes)) + "\""; + } + + // -- Internals ------------------------------------------------------------ + + /** + * Like {@link #resolve} but never redirects or throws — returns {@code null} silently + * when no valid credentials are present. Used by {@link #optional()}. + */ + private Map resolveQuiet(Request req) { + String bearerToken = extractBearerToken(req.header("Authorization")); + if (bearerToken != null) { + try { + return validator.validate(bearerToken); + } catch (Exception ignored) { + return null; + } + } + + String sessionId = cookieValue(req, "oidc_session"); + if (sessionId != null) { + Optional found = config.sessionStore().find(sessionId); + if (found.isPresent()) { + OidcSession session = found.get(); + if (!session.isAccessTokenExpired()) + return session.claims(); + if (session.refreshToken() != null) { + try { + OidcSession refreshed = doRefresh(session); + config.sessionStore().save(refreshed); + return refreshed.claims(); + } catch (Exception ignored) { } + } + config.sessionStore().delete(sessionId); + } + } + return null; + } + + /** + * Returns claims on success, or {@code null} if a redirect was already written to + * {@code res}. Throws {@link HttpException} 401/403 for API clients. + */ + private Map resolve(Request req, Response res) { + return resolve(req, res, null); + } + + private Map resolve(Request req, Response res, String resourceMetadataPath) { + // 1. Bearer token + String bearerToken = extractBearerToken(req.header("Authorization")); + if (bearerToken != null) { + try { + return validator.validate(bearerToken); + } catch (HttpException e) { + res.header("WWW-Authenticate", invalidTokenChallenge(req, resourceMetadataPath)); + throw e; + } + } + + // 2. Session cookie + String sessionId = cookieValue(req, "oidc_session"); + if (sessionId != null) { + Optional found = config.sessionStore().find(sessionId); + if (found.isPresent()) { + OidcSession session = found.get(); + + if (!session.isAccessTokenExpired()) + return session.claims(); + + // Access token expired — try silent refresh + if (session.refreshToken() != null) { + try { + OidcSession refreshed = doRefresh(session); + config.sessionStore().save(refreshed); + return refreshed.claims(); + } catch (Exception ignored) { + // Refresh failed — fall through to re-authenticate + } + } + config.sessionStore().delete(sessionId); + } + } + + // 3. No valid credentials + String accept = req.header("Accept"); + if (accept != null && accept.contains("application/json")) { + res.header("WWW-Authenticate", bearerChallenge(req, resourceMetadataPath)); + throw HttpException.unauthorized(); + } + + // Browser — redirect to login, preserving the original URL in state + String loginUrl = config.routePrefix() + "/login?redirect=" + + URLEncoder.encode(req.path(), StandardCharsets.UTF_8); + res.redirect(loginUrl); + return null; + } + + private OidcSession doRefresh(OidcSession old) throws Exception { + OidcTokenResponse tokens = tokenClient.refresh( + meta.tokenEndpoint(), old.refreshToken()); + + Map claims = mergeRefreshedClaims(tokens, old); + + return new OidcSession( + old.id(), + tokens.accessToken(), + tokens.idToken() != null ? tokens.idToken() : old.idToken(), + tokens.refreshToken() != null ? tokens.refreshToken() : old.refreshToken(), + Instant.now().plusSeconds(tokens.expiresIn()), + claims + ); + } + + static String extractBearerToken(String authorizationHeader) { + if (authorizationHeader == null) return null; + int len = authorizationHeader.length(); + int start = 0; + while (start < len && Character.isWhitespace(authorizationHeader.charAt(start))) start++; + int schemeEnd = start + BEARER.length(); + if (schemeEnd > len || !authorizationHeader.regionMatches(true, start, BEARER, 0, BEARER.length())) { + return null; + } + if (schemeEnd == len || !Character.isWhitespace(authorizationHeader.charAt(schemeEnd))) { + return null; + } + int tokenStart = schemeEnd; + while (tokenStart < len && Character.isWhitespace(authorizationHeader.charAt(tokenStart))) tokenStart++; + if (tokenStart >= len) return null; + int tokenEnd = len; + while (tokenEnd > tokenStart && Character.isWhitespace(authorizationHeader.charAt(tokenEnd - 1))) tokenEnd--; + return tokenEnd > tokenStart ? authorizationHeader.substring(tokenStart, tokenEnd) : null; + } + + String bearerChallenge() { + return bearerChallenge(null, null); + } + + private String bearerChallenge(Request req, String resourceMetadataPath) { + String base = BEARER + " realm=\"" + quoted(config.schemeName()) + "\""; + if (resourceMetadataPath == null) return base; + return base + ", resource_metadata=\"" + quoted(absoluteSelf(req, resourceMetadataPath)) + "\""; + } + + String invalidTokenChallenge() { + return invalidTokenChallenge(null, null); + } + + private String invalidTokenChallenge(Request req, String resourceMetadataPath) { + return bearerChallenge(req, resourceMetadataPath) + ", error=\"invalid_token\""; + } + + private String absoluteSelf(Request req, String path) { + if (!path.startsWith("/")) return path; + return selfOrigin(req, config.selfScheme()) + path; + } + + /** + * {@code scheme://host} clients actually reach this app on — the basis for every absolute + * URL it publishes about itself (OAuth2 {@code redirect_uri}, the RFC 9728 resource + * identifier and the {@code resource_metadata} challenge). Behind a reverse proxy the + * request's own {@code Host} is the upstream address the proxy dialled, so + * {@code X-Forwarded-Host}/{@code -Proto} win whenever present: without them the app would + * name an address no client can resolve, and OAuth2 discovery fails with no error anyone + * can trace back to here. Trusted unconditionally — a caller able to reach this app without + * passing the proxy can do worse than spoof a self URL. + */ + public static String selfOrigin(Request req, String fallbackScheme) { + String forwardedHost = req.header("X-Forwarded-Host"); + if (forwardedHost == null) return fallbackScheme + "://" + req.header("Host"); + String forwardedProto = req.header("X-Forwarded-Proto"); + return (forwardedProto != null ? forwardedProto : fallbackScheme) + "://" + forwardedHost; + } + + private static String spaceDelimited(String[] values) { + if (values == null || values.length == 0) return ""; + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < values.length; i++) { + if (i > 0) sb.append(' '); + sb.append(values[i]); + } + return sb.toString(); + } + + private static String quoted(String value) { + StringBuilder out = new StringBuilder(value.length() + 8); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '"' || c == '\\') out.append('\\'); + out.append(c); + } + return out.toString(); + } + + private static Map mergeRefreshedClaims(OidcTokenResponse tokens, OidcSession old) { + Map merged = new HashMap<>(); + // Fall back to old claims first, then overlay fresh token claims + merged.putAll(old.claims()); + if (tokens.accessToken() != null) + merged.putAll(JwtUtils.parseClaims(tokens.accessToken())); + if (tokens.idToken() != null) + merged.putAll(JwtUtils.parseClaims(tokens.idToken())); + return Map.copyOf(merged); + } + + // -- Shared cookie utility (also used by OidcExtension) ------------------- + + static String cookieValue(Request req, String name) { + String header = req.header("Cookie"); + if (header == null || header.isBlank()) return null; + int len = header.length(); + int start = 0; + while (start < len) { + int semi = header.indexOf(';', start); + int end = semi < 0 ? len : semi; + int eq = header.indexOf('=', start); + if (eq > start && eq < end) { + int ns = start, ne = eq; + while (ns < ne && header.charAt(ns) == ' ') ns++; + while (ne > ns && header.charAt(ne-1) == ' ') ne--; + if (ne - ns == name.length() && header.regionMatches(ns, name, 0, name.length())) + return header.substring(eq + 1, end).strip(); + } + start = end + 1; + } + return null; + } +} diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcExtension.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcExtension.java index 10502f7..133c152 100644 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcExtension.java +++ b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcExtension.java @@ -4,6 +4,12 @@ import dev.relism.flash.ext.openapi.OpenApiContributor; 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.AuthMiddleware; +import dev.relism.flash.ext.auth.AuthPolicy; +import dev.relism.flash.ext.auth.Authenticated; +import dev.relism.flash.ext.auth.RolesAllowed; +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; @@ -28,7 +34,8 @@ import java.util.*; *

    At {@link #provide}, the extension: *

      *
    1. Fetches the provider discovery document — fail-fast at startup.
    2. - *
    3. Provides {@link OidcMiddleware} and {@link JwtValidator} in the context.
    4. + *
    5. Provides {@link AuthMiddleware}, {@link OidcCredentialSource} and {@link JwtValidator} + * in the context.
    6. *
    7. Registers annotation processors for {@link Authenticated}, {@link RolesAllowed} * and {@link ScopesAllowed}.
    8. *
    @@ -56,7 +63,7 @@ import java.util.*; * }
*/ public class OidcExtension implements FlashExtension { - private static final MiddlewareKey POLICY = MiddlewareKey.of("flash.oidc.policy"); + private static final MiddlewareKey POLICY = MiddlewareKey.of("flash.auth.policy"); private final OidcConfig config; @@ -65,7 +72,8 @@ public class OidcExtension implements FlashExtension { private OidcStateStore stateStore; private TokenClient tokenClient; private JwtValidator validator; - private OidcMiddleware oidcMw; + private OidcCredentialSource source; + private AuthMiddleware authMw; public OidcExtension(OidcConfig config) { this.config = config; @@ -87,14 +95,19 @@ public class OidcExtension implements FlashExtension { validator = new JwtValidator(meta.jwksUri(), config.issuer(), config.clientId(), config.algorithm(), http); stateStore = new OidcStateStore(); tokenClient = new TokenClient(http, config); - oidcMw = new OidcMiddleware(validator, config, meta, tokenClient); + source = new OidcCredentialSource(validator, config, meta, tokenClient); + authMw = new AuthMiddleware(AuthConfig.builder() + .rolesClaimPath(config.rolesClaimPath()) + .scopeClaimPaths(config.scopeClaimPaths()) + .build(), source); - ctx.provide(OidcMiddleware.class, oidcMw); - ctx.provide(JwtValidator.class, validator); + ctx.provide(AuthMiddleware.class, authMw); + ctx.provide(OidcCredentialSource.class, source); + ctx.provide(JwtValidator.class, validator); ctx.addAnnotationProcessor(handlerClass -> { - OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(handlerClass); - return policy != null ? List.of(MiddlewareNode.of(POLICY, oidcMw.policyMiddleware(policy))) : List.of(); + AuthPolicy policy = AuthPolicy.compileFromAnnotations(handlerClass); + return policy != null ? List.of(MiddlewareNode.of(POLICY, authMw.authorize(policy))) : List.of(); }); ctx.onReady(() -> registerRoutes(app, ctx)); } @@ -178,7 +191,7 @@ public class OidcExtension implements FlashExtension { // ── POST {prefix}/logout ────────────────────────────────────────────── // Invalidates the local session and redirects to end_session_endpoint. app.post(prefix + "/logout", (req, res) -> { - String sessionId = OidcMiddleware.cookieValue(req, "oidc_session"); + String sessionId = OidcCredentialSource.cookieValue(req, "oidc_session"); String idTokenHint = null; if (sessionId != null) { @@ -253,7 +266,7 @@ public class OidcExtension implements FlashExtension { private String absoluteSelf(Request req, String uri) { if (!uri.startsWith("/")) return uri; - return OidcMiddleware.selfOrigin(req, config.selfScheme()) + uri; + return OidcCredentialSource.selfOrigin(req, config.selfScheme()) + uri; } private static String enc(String v) { @@ -299,12 +312,12 @@ public class OidcExtension implements FlashExtension { OpenApiOperationContribution.Builder out = OpenApiOperationContribution.builder(); - List operationScopes = OidcAuthPolicy.openApiScopesFor(handlerClass); + List operationScopes = AuthPolicy.openApiScopesFor(handlerClass); if (operationScopes != null) { out.security(config.schemeName(), operationScopes); } - OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(handlerClass); + AuthPolicy policy = AuthPolicy.compileFromAnnotations(handlerClass); if (policy == null || policy.optionalAuth()) return out.build(); out.response(401, OpenApiResponseContribution.of("Authentication required")); diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcMiddleware.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcMiddleware.java deleted file mode 100644 index 7da1b7f..0000000 --- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcMiddleware.java +++ /dev/null @@ -1,550 +0,0 @@ -package dev.relism.flash.ext.oidc; - -import dev.relism.flash.exceptions.HttpException; -import dev.relism.flash.extension.FlashContext; -import dev.relism.flash.models.Response; -import dev.relism.flash.models.Request; -import dev.relism.flash.routing.Middleware; - -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.time.Instant; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; - -/** - * Request-level OIDC middleware. Exposed in the {@link FlashContext} - * for manual use on lambda routes; injected automatically for handlers annotated with - * {@link Authenticated}, {@link RolesAllowed} or {@link ScopesAllowed}. - * - *

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 - * refreshed if the access token is expired.
  4. - *
  5. Browser clients (no {@code Accept: application/json}) → redirect to - * {@code {routePrefix}/login?redirect={path}}.
  6. - *
  7. API clients → 401.
  8. - *
- * - *
{@code
- * // Manual use on a lambda route:
- * OidcMiddleware oidc = app.ctx().require(OidcMiddleware.class);
- * app.get("/api/me", (req, res) -> ClaimsHolder.claim("sub"), oidc.protect());
- * app.delete("/admin/users/{id}", handler, oidc.requireRole("admin"));
- * }
- */ -public class OidcMiddleware { - - private static final String BEARER = "Bearer"; - - private final JwtValidator validator; - private final OidcConfig config; - private final OidcProviderMetadata meta; - private final TokenClient tokenClient; - private final String[] roleClaimPathParts; - private final String[][] scopeClaimPathParts; - - OidcMiddleware(JwtValidator validator, OidcConfig config, - OidcProviderMetadata meta, TokenClient tokenClient) { - this.validator = validator; - this.config = config; - this.meta = meta; - this.tokenClient = tokenClient; - this.roleClaimPathParts = splitClaimPath(config.rolesClaimPath()); - this.scopeClaimPathParts = splitClaimPaths(config.scopeClaimPaths()); - } - - // -- Public API ----------------------------------------------------------- - - /** The single configured claim path used by every transport for role checks. */ - public String rolesClaimPath() { return config.rolesClaimPath(); } - - /** - * Validates the bearer token or session cookie. Browser clients are redirected - * to the login page on failure; API clients receive 401. - */ - public Middleware protect() { - return protect(null); - } - - /** - * Like {@link #protect()}, but a 401 challenge also carries {@code resource_metadata} - * (RFC 9728 §5.1), resolved against this request's own scheme/host exactly like - * {@link OidcExtension}'s redirect URIs. {@code resourceMetadataPath} is an absolute path - * (e.g. {@code "/.well-known/oauth-protected-resource/mcp"}); pass {@code null} for plain - * challenges. Used by {@code flash-ext-mcp} to make its Protected Resource Metadata - * document discoverable straight from the {@code WWW-Authenticate} header, per the MCP - * Authorization spec. - */ - public Middleware protect(String resourceMetadataPath) { - return next -> (req, res) -> { - Map claims = resolve(req, res, resourceMetadataPath); - if (claims == null) return null; // redirect already written - ClaimsHolder.set(claims); - try { - return next.handle(req, res); - } finally { - ClaimsHolder.clear(); - } - }; - } - - /** OIDC issuer this middleware validates tokens against — the {@code iss} claim it enforces. */ - public String issuer() { return config.issuer(); } - - /** Scheme used to build this app's own absolute URLs — see {@link OidcConfig#selfScheme()}. */ - public String selfScheme() { return config.selfScheme(); } - - /** - * Silently populates {@link ClaimsHolder} if a valid bearer token or session cookie - * is present, but never rejects or redirects unauthenticated requests. Use this on - * public routes that want to personalise the response when the user happens to be - * logged in (e.g. showing a username on a landing page). - * - *
{@code
-     * app.get("/", handler, oidc.optional());
-     * // Inside handler: ClaimsHolder.user() is non-null iff the user is logged in.
-     * }
- */ - public Middleware optional() { - return next -> (req, res) -> { - Map claims = resolveQuiet(req); - if (claims != null) ClaimsHolder.set(claims); - try { - return next.handle(req, res); - } finally { - ClaimsHolder.clear(); - } - }; - } - - /** - * Compiled authorization policy path used by annotation-driven mounting. - * The policy is immutable and built once at boot. - */ - public Middleware authorize(OidcAuthPolicy policy) { - if (policy.optionalAuth()) return optional(); - return next -> (req, res) -> { - Map claims = resolve(req, res); - if (claims == null) return null; - enforcePolicy(claims, policy, res); - ClaimsHolder.set(claims); - try { - return next.handle(req, res); - } finally { - ClaimsHolder.clear(); - } - }; - } - - /** - * Like {@link #protect()} but also enforces that the caller holds at least one - * of the given roles (OR semantics). Roles are extracted via - * {@link OidcConfig#rolesClaimPath()}. - */ - public Middleware requireRole(String... roles) { - return authorize(OidcAuthPolicy.rolesAny(roles)); - } - - /** - * Requires all listed scopes to be present in the token. - * Scopes are resolved from configured claim paths (default: {@code scope,scp}). - */ - public Middleware requireScopes(String... scopes) { - return authorize(OidcAuthPolicy.scopes(scopes, ScopesAllowed.Match.ALL)); - } - - /** - * Requires at least one of the listed scopes to be present in the token. - * Scopes are resolved from configured claim paths (default: {@code scope,scp}). - */ - public Middleware requireAnyScope(String... scopes) { - return authorize(OidcAuthPolicy.scopes(scopes, ScopesAllowed.Match.ANY)); - } - - // -- Package-private: AnnotationProcessor hooks --------------------------- - - Middleware authenticatedMiddleware() { return protect(); } - Middleware optionalMiddleware() { return optional(); } - Middleware rolesMiddleware(String[] required) { return requireRole(required); } - Middleware scopesMiddleware(String[] required, ScopesAllowed.Match match) { - return authorize(OidcAuthPolicy.scopes(required, match)); - } - Middleware policyMiddleware(OidcAuthPolicy policy) { return authorize(policy); } - - // -- Internals ------------------------------------------------------------ - - /** - * Like {@link #resolve} but never redirects or throws — returns {@code null} silently - * when no valid credentials are present. Used by {@link #optional()}. - */ - private Map resolveQuiet(Request req) { - String bearerToken = extractBearerToken(req.header("Authorization")); - if (bearerToken != null) { - try { - return validator.validate(bearerToken); - } catch (Exception ignored) { - return null; - } - } - - String sessionId = cookieValue(req, "oidc_session"); - if (sessionId != null) { - Optional found = config.sessionStore().find(sessionId); - if (found.isPresent()) { - OidcSession session = found.get(); - if (!session.isAccessTokenExpired()) - return session.claims(); - if (session.refreshToken() != null) { - try { - OidcSession refreshed = doRefresh(session); - config.sessionStore().save(refreshed); - return refreshed.claims(); - } catch (Exception ignored) { } - } - config.sessionStore().delete(sessionId); - } - } - return null; - } - - /** - * Returns claims on success, or {@code null} if a redirect was already written to - * {@code res}. Throws {@link HttpException} 401/403 for API clients. - */ - private Map resolve(Request req, Response res) { - return resolve(req, res, null); - } - - private Map resolve(Request req, Response res, String resourceMetadataPath) { - // 1. Bearer token - String bearerToken = extractBearerToken(req.header("Authorization")); - if (bearerToken != null) { - try { - return validator.validate(bearerToken); - } catch (HttpException e) { - res.header("WWW-Authenticate", invalidTokenChallenge(req, resourceMetadataPath)); - throw e; - } - } - - // 2. Session cookie - String sessionId = cookieValue(req, "oidc_session"); - if (sessionId != null) { - Optional found = config.sessionStore().find(sessionId); - if (found.isPresent()) { - OidcSession session = found.get(); - - if (!session.isAccessTokenExpired()) - return session.claims(); - - // Access token expired — try silent refresh - if (session.refreshToken() != null) { - try { - OidcSession refreshed = doRefresh(session); - config.sessionStore().save(refreshed); - return refreshed.claims(); - } catch (Exception ignored) { - // Refresh failed — fall through to re-authenticate - } - } - config.sessionStore().delete(sessionId); - } - } - - // 3. No valid credentials - String accept = req.header("Accept"); - if (accept != null && accept.contains("application/json")) { - res.header("WWW-Authenticate", bearerChallenge(req, resourceMetadataPath)); - throw HttpException.unauthorized(); - } - - // Browser — redirect to login, preserving the original URL in state - String loginUrl = config.routePrefix() + "/login?redirect=" - + URLEncoder.encode(req.path(), StandardCharsets.UTF_8); - res.redirect(loginUrl); - return null; - } - - private OidcSession doRefresh(OidcSession old) throws Exception { - OidcTokenResponse tokens = tokenClient.refresh( - meta.tokenEndpoint(), old.refreshToken()); - - Map claims = mergeRefreshedClaims(tokens, old); - - return new OidcSession( - old.id(), - tokens.accessToken(), - tokens.idToken() != null ? tokens.idToken() : old.idToken(), - tokens.refreshToken() != null ? tokens.refreshToken() : old.refreshToken(), - Instant.now().plusSeconds(tokens.expiresIn()), - claims - ); - } - - private void enforcePolicy(Map claims, OidcAuthPolicy policy, Response res) { - checkRoles(claims, policy.requiredRoles()); - checkScopes(claims, policy.requiredScopes(), policy.scopeMatch(), res); - } - - private void checkRoles(Map claims, String[] required) { - if (required.length == 0) return; - if (rolesAllowed(claims, required)) return; - throw HttpException.forbidden(); - } - - private void checkScopes(Map claims, String[] required, ScopesAllowed.Match match, - Response res) { - if (required.length == 0) return; - if (scopesAllowed(claims, required, match)) return; - res.header("WWW-Authenticate", insufficientScopeChallenge(required)); - throw HttpException.forbidden(); - } - - static String extractBearerToken(String authorizationHeader) { - if (authorizationHeader == null) return null; - int len = authorizationHeader.length(); - int start = 0; - while (start < len && Character.isWhitespace(authorizationHeader.charAt(start))) start++; - int schemeEnd = start + BEARER.length(); - if (schemeEnd > len || !authorizationHeader.regionMatches(true, start, BEARER, 0, BEARER.length())) { - return null; - } - if (schemeEnd == len || !Character.isWhitespace(authorizationHeader.charAt(schemeEnd))) { - return null; - } - int tokenStart = schemeEnd; - while (tokenStart < len && Character.isWhitespace(authorizationHeader.charAt(tokenStart))) tokenStart++; - if (tokenStart >= len) return null; - int tokenEnd = len; - while (tokenEnd > tokenStart && Character.isWhitespace(authorizationHeader.charAt(tokenEnd - 1))) tokenEnd--; - return tokenEnd > tokenStart ? authorizationHeader.substring(tokenStart, tokenEnd) : null; - } - - String bearerChallenge() { - return bearerChallenge(null, null); - } - - private String bearerChallenge(Request req, String resourceMetadataPath) { - String base = BEARER + " realm=\"" + quoted(config.schemeName()) + "\""; - if (resourceMetadataPath == null) return base; - return base + ", resource_metadata=\"" + quoted(absoluteSelf(req, resourceMetadataPath)) + "\""; - } - - String invalidTokenChallenge() { - return invalidTokenChallenge(null, null); - } - - private String invalidTokenChallenge(Request req, String resourceMetadataPath) { - return bearerChallenge(req, resourceMetadataPath) + ", error=\"invalid_token\""; - } - - String insufficientScopeChallenge(String[] requiredScopes) { - return bearerChallenge() + ", error=\"insufficient_scope\", scope=\"" - + quoted(spaceDelimited(requiredScopes)) + "\""; - } - - private String absoluteSelf(Request req, String path) { - if (!path.startsWith("/")) return path; - return selfOrigin(req, config.selfScheme()) + path; - } - - /** - * {@code scheme://host} clients actually reach this app on — the basis for every absolute - * URL it publishes about itself (OAuth2 {@code redirect_uri}, the RFC 9728 resource - * identifier and the {@code resource_metadata} challenge). Behind a reverse proxy the - * request's own {@code Host} is the upstream address the proxy dialled, so - * {@code X-Forwarded-Host}/{@code -Proto} win whenever present: without them the app would - * name an address no client can resolve, and OAuth2 discovery fails with no error anyone - * can trace back to here. Trusted unconditionally — a caller able to reach this app without - * passing the proxy can do worse than spoof a self URL. - */ - public static String selfOrigin(Request req, String fallbackScheme) { - String forwardedHost = req.header("X-Forwarded-Host"); - if (forwardedHost == null) return fallbackScheme + "://" + req.header("Host"); - String forwardedProto = req.header("X-Forwarded-Proto"); - return (forwardedProto != null ? forwardedProto : fallbackScheme) + "://" + forwardedHost; - } - - private static String spaceDelimited(String[] values) { - if (values == null || values.length == 0) return ""; - StringBuilder sb = new StringBuilder(); - for (int i = 0; i < values.length; i++) { - if (i > 0) sb.append(' '); - sb.append(values[i]); - } - return sb.toString(); - } - - private static String quoted(String value) { - StringBuilder out = new StringBuilder(value.length() + 8); - for (int i = 0; i < value.length(); i++) { - char c = value.charAt(i); - if (c == '"' || c == '\\') out.append('\\'); - out.append(c); - } - return out.toString(); - } - - boolean rolesAllowed(Map claims, String[] required) { - Object actual = valueAtPath(claims, roleClaimPathParts); - if (actual == null) return false; - for (String role : required) { - if (containsToken(actual, role)) return true; - } - return false; - } - - boolean scopesAllowed(Map claims, String[] required, ScopesAllowed.Match match) { - if (match == ScopesAllowed.Match.ALL) { - for (String scope : required) { - if (!hasScope(claims, scope)) return false; - } - return true; - } - for (String scope : required) { - if (hasScope(claims, scope)) return true; - } - return false; - } - - private boolean hasScope(Map claims, String scope) { - for (String[] pathParts : scopeClaimPathParts) { - Object value = valueAtPath(claims, pathParts); - if (value != null && containsToken(value, scope)) return true; - } - return false; - } - - private static Object valueAtPath(Map claims, String[] pathParts) { - Object current = claims; - for (String part : pathParts) { - if (!(current instanceof Map map)) return null; - current = map.get(part); - if (current == null) return null; - } - return current; - } - - private static boolean containsToken(Object source, String token) { - if (source instanceof String s) return containsDelimitedToken(s, token); - if (source instanceof List list) { - for (Object item : list) { - if (item == null) continue; - if (tokenEquals(item.toString(), token)) return true; - } - return false; - } - if (source instanceof Object[] arr) { - for (Object item : arr) { - if (item == null) continue; - if (tokenEquals(item.toString(), token)) return true; - } - return false; - } - return tokenEquals(source.toString(), token); - } - - private static boolean containsDelimitedToken(String value, String token) { - int len = value.length(); - int i = 0; - while (i < len) { - while (i < len && isScopeDelimiter(value.charAt(i))) i++; - int start = i; - while (i < len && !isScopeDelimiter(value.charAt(i))) i++; - int end = i; - if (end > start && end - start == token.length() && value.regionMatches(start, token, 0, token.length())) { - return true; - } - } - return false; - } - - private static boolean tokenEquals(String value, String token) { - int start = 0; - int end = value.length(); - while (start < end && Character.isWhitespace(value.charAt(start))) start++; - while (end > start && Character.isWhitespace(value.charAt(end - 1))) end--; - return end - start == token.length() && value.regionMatches(start, token, 0, token.length()); - } - - private static boolean isScopeDelimiter(char c) { - return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ','; - } - - private static String[] splitClaimPath(String path) { - if (path == null || path.isBlank()) { - throw new IllegalStateException("OIDC claim path cannot be blank"); - } - List parts = new ArrayList<>(4); - int start = 0; - int len = path.length(); - for (int i = 0; i <= len; i++) { - if (i == len || path.charAt(i) == '.') { - String p = path.substring(start, i).trim(); - if (!p.isEmpty()) parts.add(p); - start = i + 1; - } - } - if (parts.isEmpty()) { - throw new IllegalStateException("OIDC claim path cannot be blank"); - } - return parts.toArray(String[]::new); - } - - private static String[][] splitClaimPaths(String paths) { - String source = (paths == null || paths.isBlank()) ? "scope,scp" : paths; - List out = new ArrayList<>(4); - int start = 0; - int len = source.length(); - for (int i = 0; i <= len; i++) { - if (i == len || source.charAt(i) == ',') { - String raw = source.substring(start, i).trim(); - if (!raw.isEmpty()) out.add(splitClaimPath(raw)); - start = i + 1; - } - } - if (out.isEmpty()) { - return new String[][]{ splitClaimPath("scope"), splitClaimPath("scp") }; - } - return out.toArray(String[][]::new); - } - - private static Map mergeRefreshedClaims(OidcTokenResponse tokens, OidcSession old) { - Map merged = new HashMap<>(); - // Fall back to old claims first, then overlay fresh token claims - merged.putAll(old.claims()); - if (tokens.accessToken() != null) - merged.putAll(JwtUtils.parseClaims(tokens.accessToken())); - if (tokens.idToken() != null) - merged.putAll(JwtUtils.parseClaims(tokens.idToken())); - return Map.copyOf(merged); - } - - // -- Shared cookie utility (also used by OidcExtension) ------------------- - - static String cookieValue(Request req, String name) { - String header = req.header("Cookie"); - if (header == null || header.isBlank()) return null; - int len = header.length(); - int start = 0; - while (start < len) { - int semi = header.indexOf(';', start); - int end = semi < 0 ? len : semi; - int eq = header.indexOf('=', start); - if (eq > start && eq < end) { - int ns = start, ne = eq; - while (ns < ne && header.charAt(ns) == ' ') ns++; - while (ne > ns && header.charAt(ne-1) == ' ') ne--; - if (ne - ns == name.length() && header.regionMatches(ns, name, 0, name.length())) - return header.substring(eq + 1, end).strip(); - } - start = end + 1; - } - return null; - } -} diff --git a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcCredentialSourceTest.java b/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcCredentialSourceTest.java new file mode 100644 index 0000000..7f7d6dd --- /dev/null +++ b/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcCredentialSourceTest.java @@ -0,0 +1,43 @@ +package dev.relism.flash.ext.oidc; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * What stayed behind when authorization moved to {@code flash-ext-auth-core}: reading a bearer + * token off the wire, and the RFC 6750 challenges this source answers with. The matching of + * claims those credentials produce is {@code ClaimMatchingTest}'s job now. + */ +class OidcCredentialSourceTest { + + private static OidcCredentialSource source() { + return new OidcCredentialSource(null, OidcConfig + .builder("https://idp.example.com", "client", "secret", "/auth/callback") + .build(), null, null); + } + + @Test + void extractBearerToken_acceptsCaseInsensitiveBearerAndTrimsSpaces() { + assertEquals("abc.def.ghi", OidcCredentialSource.extractBearerToken("Bearer abc.def.ghi")); + assertEquals("abc", OidcCredentialSource.extractBearerToken(" bearer abc ")); + assertNull(OidcCredentialSource.extractBearerToken("Basic Zm9vOmJhcg==")); + assertNull(OidcCredentialSource.extractBearerToken("Bearer")); + } + + @Test + void bearerChallenge_containsRealmAndRfcErrors() { + OidcCredentialSource src = source(); + + String basic = src.bearerChallenge(); + String invalid = src.invalidTokenChallenge(); + String insufficient = src.insufficientScopeChallenge(new String[]{"orders:read", "payments:write"}); + + assertTrue(basic.startsWith("Bearer realm=\"")); + assertTrue(invalid.contains("error=\"invalid_token\"")); + assertTrue(insufficient.contains("error=\"insufficient_scope\"")); + assertTrue(insufficient.contains("scope=\"orders:read payments:write\"")); + } +} diff --git a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcMiddlewareAuthzTest.java b/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcMiddlewareAuthzTest.java deleted file mode 100644 index b38a1ae..0000000 --- a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcMiddlewareAuthzTest.java +++ /dev/null @@ -1,72 +0,0 @@ -package dev.relism.flash.ext.oidc; - -import org.junit.jupiter.api.Test; - -import java.util.List; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.*; - -class OidcMiddlewareAuthzTest { - - private static OidcMiddleware middleware(String rolesPath, String scopePaths) { - OidcConfig cfg = OidcConfig.builder("https://idp.example.com", "client", "secret", "/auth/callback") - .rolesClaimPath(rolesPath) - .scopeClaimPaths(scopePaths) - .build(); - return new OidcMiddleware(null, cfg, null, null); - } - - @Test - void rolesAllowed_readsConfiguredNestedClaimPath() { - OidcMiddleware mw = middleware("realm_access.roles", "scope,scp"); - Map claims = Map.of("realm_access", Map.of("roles", List.of("user", "admin"))); - - assertTrue(mw.rolesAllowed(claims, new String[]{"admin"})); - assertFalse(mw.rolesAllowed(claims, new String[]{"ops"})); - } - - @Test - void scopesAllowed_all_requiresEveryScope() { - OidcMiddleware mw = middleware("roles", "scope,scp"); - Map claims = Map.of("scope", "openid profile orders:read"); - - assertTrue(mw.scopesAllowed(claims, new String[]{"openid", "orders:read"}, ScopesAllowed.Match.ALL)); - assertFalse(mw.scopesAllowed(claims, new String[]{"openid", "orders:write"}, ScopesAllowed.Match.ALL)); - } - - @Test - void scopesAllowed_any_acceptsAnyConfiguredScopeSource() { - OidcMiddleware mw = middleware("roles", "scope,scp,permissions.scopes"); - Map claims = Map.of( - "scp", List.of("payments:write"), - "permissions", Map.of("scopes", "orders:approve") - ); - - assertTrue(mw.scopesAllowed(claims, new String[]{"orders:approve", "orders:read"}, ScopesAllowed.Match.ANY)); - assertTrue(mw.scopesAllowed(claims, new String[]{"payments:write"}, ScopesAllowed.Match.ANY)); - assertFalse(mw.scopesAllowed(claims, new String[]{"unknown"}, ScopesAllowed.Match.ANY)); - } - - @Test - void extractBearerToken_acceptsCaseInsensitiveBearerAndTrimsSpaces() { - assertEquals("abc.def.ghi", OidcMiddleware.extractBearerToken("Bearer abc.def.ghi")); - assertEquals("abc", OidcMiddleware.extractBearerToken(" bearer abc ")); - assertNull(OidcMiddleware.extractBearerToken("Basic Zm9vOmJhcg==")); - assertNull(OidcMiddleware.extractBearerToken("Bearer")); - } - - @Test - void bearerChallenge_containsRealmAndRfcErrors() { - OidcMiddleware mw = middleware("roles", "scope,scp"); - - String basic = mw.bearerChallenge(); - String invalid = mw.invalidTokenChallenge(); - String insufficient = mw.insufficientScopeChallenge(new String[]{"orders:read", "payments:write"}); - - assertTrue(basic.startsWith("Bearer realm=\"")); - assertTrue(invalid.contains("error=\"invalid_token\"")); - assertTrue(insufficient.contains("error=\"insufficient_scope\"")); - assertTrue(insufficient.contains("scope=\"orders:read payments:write\"")); - } -} diff --git a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcOpenApiInteropTest.java b/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcOpenApiInteropTest.java index 41b8bdf..2597e90 100644 --- a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcOpenApiInteropTest.java +++ b/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcOpenApiInteropTest.java @@ -4,6 +4,10 @@ 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.openapi.OpenApiContributor; +import dev.relism.flash.ext.auth.AuthPolicy; +import dev.relism.flash.ext.auth.Authenticated; +import dev.relism.flash.ext.auth.RolesAllowed; +import dev.relism.flash.ext.auth.ScopesAllowed; import dev.relism.flash.extension.FlashContext; import org.junit.jupiter.api.Test; diff --git a/flash-extensions/pom.xml b/flash-extensions/pom.xml index aaa38c3..a4f195e 100644 --- a/flash-extensions/pom.xml +++ b/flash-extensions/pom.xml @@ -16,6 +16,7 @@ flash-ext-jackson flash-ext-openapi + flash-ext-auth-core flash-ext-oidc flash-ext-routeviewer flash-ext-view-core @@ -45,6 +46,11 @@ flash-ext-scheduler ${project.version} + + dev.relism + flash-ext-auth-core + ${project.version} + dev.relism flash-ext-cache-core -- 2.54.0 From 9d39e24ccb7bf4ec093dcc6dd20e333dd0264f08 Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Thu, 10 Sep 2026 19:06:15 +0000 Subject: [PATCH 4/9] refactor(ext-auth): generic sessions, shared annotation wiring, rename to flash-ext-auth-oidc AuthMiddleware.install(ctx, config, source) now owns the annotation processor and the flash.auth.policy key, so a second credential source gets annotation-driven authorization without copying the wiring. The key is public: an extension that contributes middleware can order itself around authentication. OidcSession becomes Session in auth-core, carrying claims, an expiry and an opaque attribute map. OpenID Connect keeps its access, id and refresh tokens in that map under its own keys, so renewal stays its business and core has no OAuth2 vocabulary in it. isAccessTokenExpired() becomes isExpired(), with the 30s eager-renewal window it always had and now a test for it. flash-ext-oidc is renamed flash-ext-auth-oidc, matching cache-core/cache-caffeine and data-core/data-hibernate. --- .idea/encodings.xml | 4 +- README.md | 6 +- .../relism/flash/ext/auth/AuthMiddleware.java | 29 ++++++++++ .../flash/ext/auth/InMemorySessionStore.java | 20 +++++++ .../dev/relism/flash/ext/auth/Session.java | 53 +++++++++++++++++ .../relism/flash/ext/auth/SessionStore.java | 13 +++++ .../relism/flash/ext/auth/SessionTest.java | 58 +++++++++++++++++++ .../README.md | 4 +- .../pom.xml | 2 +- .../flash/ext/oidc/ClientAuthMethod.java | 0 .../flash/ext/oidc/DiscoveryClient.java | 0 .../dev/relism/flash/ext/oidc/JwtUtils.java | 0 .../relism/flash/ext/oidc/JwtValidator.java | 0 .../dev/relism/flash/ext/oidc/OidcConfig.java | 15 +++-- .../flash/ext/oidc/OidcCredentialSource.java | 57 +++++++++++------- .../relism/flash/ext/oidc/OidcExtension.java | 17 ++---- .../flash/ext/oidc/OidcProviderMetadata.java | 0 .../relism/flash/ext/oidc/OidcStateStore.java | 0 .../flash/ext/oidc/OidcTokenResponse.java | 0 .../ext/oidc/OidcValidationException.java | 0 .../dev/relism/flash/ext/oidc/PkceUtils.java | 0 .../relism/flash/ext/oidc/TokenClient.java | 0 .../ext/oidc/OidcCredentialSourceTest.java | 0 .../ext/oidc/OidcOpenApiInteropTest.java | 0 flash-extensions/flash-ext-mcp/docs/README.md | 4 +- .../flash-ext-mcp/docs/jackson-interop.md | 2 +- .../flash-ext-mcp/docs/keycloak.md | 2 +- .../flash-ext-mcp/docs/security.md | 20 +++---- flash-extensions/flash-ext-mcp/pom.xml | 2 +- .../relism/flash/ext/mcp/McpAuthPolicy.java | 2 +- .../dev/relism/flash/ext/mcp/McpConfig.java | 4 +- .../relism/flash/ext/mcp/McpExtension.java | 10 ++-- .../dev/relism/flash/ext/mcp/McpJson.java | 2 +- .../flash/ext/mcp/McpOidcIntegration.java | 8 +-- .../dev/relism/flash/ext/mcp/McpRegistry.java | 2 +- .../dev/relism/flash/ext/mcp/McpSecurity.java | 8 +-- .../flash/ext/mcp/McpTransportGuards.java | 2 +- .../flash/ext/mcp/FakeOidcProvider.java | 2 +- .../ext/mcp/McpExtensionSecurityTest.java | 2 +- .../ext/oidc/InMemoryOidcSessionStore.java | 20 ------- .../relism/flash/ext/oidc/OidcSession.java | 47 --------------- .../flash/ext/oidc/OidcSessionStore.java | 14 ----- flash-extensions/flash-ext-openapi/README.md | 2 +- flash-extensions/pom.xml | 2 +- pom.xml | 2 +- 45 files changed, 271 insertions(+), 166 deletions(-) create mode 100644 flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/InMemorySessionStore.java create mode 100644 flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/Session.java create mode 100644 flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/SessionStore.java create mode 100644 flash-extensions/flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/SessionTest.java rename flash-extensions/{flash-ext-oidc => flash-ext-auth-oidc}/README.md (99%) rename flash-extensions/{flash-ext-oidc => flash-ext-auth-oidc}/pom.xml (96%) rename flash-extensions/{flash-ext-oidc => flash-ext-auth-oidc}/src/main/java/dev/relism/flash/ext/oidc/ClientAuthMethod.java (100%) rename flash-extensions/{flash-ext-oidc => flash-ext-auth-oidc}/src/main/java/dev/relism/flash/ext/oidc/DiscoveryClient.java (100%) rename flash-extensions/{flash-ext-oidc => flash-ext-auth-oidc}/src/main/java/dev/relism/flash/ext/oidc/JwtUtils.java (100%) rename flash-extensions/{flash-ext-oidc => flash-ext-auth-oidc}/src/main/java/dev/relism/flash/ext/oidc/JwtValidator.java (100%) rename flash-extensions/{flash-ext-oidc => flash-ext-auth-oidc}/src/main/java/dev/relism/flash/ext/oidc/OidcConfig.java (96%) rename flash-extensions/{flash-ext-oidc => flash-ext-auth-oidc}/src/main/java/dev/relism/flash/ext/oidc/OidcCredentialSource.java (85%) rename flash-extensions/{flash-ext-oidc => flash-ext-auth-oidc}/src/main/java/dev/relism/flash/ext/oidc/OidcExtension.java (95%) rename flash-extensions/{flash-ext-oidc => flash-ext-auth-oidc}/src/main/java/dev/relism/flash/ext/oidc/OidcProviderMetadata.java (100%) rename flash-extensions/{flash-ext-oidc => flash-ext-auth-oidc}/src/main/java/dev/relism/flash/ext/oidc/OidcStateStore.java (100%) rename flash-extensions/{flash-ext-oidc => flash-ext-auth-oidc}/src/main/java/dev/relism/flash/ext/oidc/OidcTokenResponse.java (100%) rename flash-extensions/{flash-ext-oidc => flash-ext-auth-oidc}/src/main/java/dev/relism/flash/ext/oidc/OidcValidationException.java (100%) rename flash-extensions/{flash-ext-oidc => flash-ext-auth-oidc}/src/main/java/dev/relism/flash/ext/oidc/PkceUtils.java (100%) rename flash-extensions/{flash-ext-oidc => flash-ext-auth-oidc}/src/main/java/dev/relism/flash/ext/oidc/TokenClient.java (100%) rename flash-extensions/{flash-ext-oidc => flash-ext-auth-oidc}/src/test/java/dev/relism/flash/ext/oidc/OidcCredentialSourceTest.java (100%) rename flash-extensions/{flash-ext-oidc => flash-ext-auth-oidc}/src/test/java/dev/relism/flash/ext/oidc/OidcOpenApiInteropTest.java (100%) delete mode 100644 flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/InMemoryOidcSessionStore.java delete mode 100644 flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcSession.java delete mode 100644 flash-extensions/flash-ext-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcSessionStore.java 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} -- 2.54.0 From 829b9bf34873e768e85a41a9a092e67bbba33ae5 Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Thu, 10 Sep 2026 19:12:44 +0000 Subject: [PATCH 5/9] feat(ext-mcp): let applications put middleware on the MCP route, and document the auth split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit McpExtension built its middleware chain entirely internally, so a consumer had no way to add rate limiting, audit logging or tracing to /mcp — routine on every other Flash route. McpConfig.middleware(...) appends to the chain after the transport guards and after whatever McpSecurity resolved to, so it composes with OAuth2 protection instead of replacing it, and never satisfies REQUIRED. Docs: flash-ext-auth-core and flash-ext-auth-oidc both get a docs/ directory — oidc had none at all, and its module README documented types that no longer exist. Includes a migration table from flash-ext-oidc. --- README.md | 4 +- .../flash-ext-auth-core/docs/README.md | 105 ++++++++++++++++++ .../docs/credential-sources.md | 95 ++++++++++++++++ .../flash-ext-auth-core/docs/sessions.md | 49 ++++++++ .../flash-ext-auth-oidc/README.md | 86 ++++++++------ .../flash-ext-auth-oidc/docs/README.md | 98 ++++++++++++++++ .../flash-ext-auth-oidc/docs/interop.md | 88 +++++++++++++++ .../flash-ext-mcp/docs/security.md | 18 +++ .../dev/relism/flash/ext/mcp/McpConfig.java | 22 ++++ .../relism/flash/ext/mcp/McpExtension.java | 3 +- .../ext/mcp/McpConfigMiddlewareTest.java | 61 ++++++++++ 11 files changed, 590 insertions(+), 39 deletions(-) create mode 100644 flash-extensions/flash-ext-auth-core/docs/README.md create mode 100644 flash-extensions/flash-ext-auth-core/docs/credential-sources.md create mode 100644 flash-extensions/flash-ext-auth-core/docs/sessions.md create mode 100644 flash-extensions/flash-ext-auth-oidc/docs/README.md create mode 100644 flash-extensions/flash-ext-auth-oidc/docs/interop.md create mode 100644 flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpConfigMiddlewareTest.java diff --git a/README.md b/README.md index 89fc701..6fa1051 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ 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-auth-core` | Authentication seam + role/scope authorization | | `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 | @@ -146,7 +147,8 @@ 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-auth-oidc`](flash-extensions/flash-ext-auth-oidc/README.md) +- [`flash-ext-auth-core`](flash-extensions/flash-ext-auth-core/docs/README.md) +- [`flash-ext-auth-oidc`](flash-extensions/flash-ext-auth-oidc/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-thymeleaf`](flash-extensions/flash-ext-view-thymeleaf/README.md) diff --git a/flash-extensions/flash-ext-auth-core/docs/README.md b/flash-extensions/flash-ext-auth-core/docs/README.md new file mode 100644 index 0000000..be13f11 --- /dev/null +++ b/flash-extensions/flash-ext-auth-core/docs/README.md @@ -0,0 +1,105 @@ +# flash-ext-auth-core + +Authorization, and the plumbing that carries a caller's identity through a request. It does not +know how anyone signed in — that is a `CredentialSource`, and `flash-ext-auth-oidc` ships the +OpenID Connect one. + +The split follows the same shape as `flash-ext-cache-core`/`-caffeine` and +`flash-ext-data-core`/`-hibernate`: the abstract half here, the implementations beside it. + +## The model + +``` +request ──► CredentialSource.authenticate(req, res) ──► claims + │ + ClaimsHolder.set (this module only) + │ + AuthMiddleware matches roles / scopes + │ + handler +``` + +| Type | What it is | +|---|---| +| `CredentialSource` | Turns what a request carries into claims, or rejects it. One per mechanism. | +| `AuthMiddleware` | Publishes the claims, enforces `@RolesAllowed`/`@ScopesAllowed`, clears up. | +| `ClaimsHolder` | The current request's claims. Read from anywhere; written only from here. | +| `Claims` | Typed view over a claims map — `sub()`, `email()`, `roles(path)`, `scopes()`. | +| `AuthPolicy` | What a handler's annotations compiled to, resolved once at boot. | +| `Session`, `SessionStore` | Server-side sessions for sources that keep them. | + +Nothing outside this module can write `ClaimsHolder`. A source *returns* claims and the middleware +publishes them, so no code can put claims on a request that did not carry them. + +## Using it + +You rarely install this module directly — an extension that contributes a source does it for you: + +```java +// inside your extension's configure(...) +AuthMiddleware auth = AuthMiddleware.install(ctx, AuthConfig.builder() + .rolesClaimPath("realm_access.roles") + .scopeClaimPaths("scope,scp") + .build(), mySource); +``` + +`install` publishes the middleware in the context and registers the annotation processor, so every +scanned handler carrying an auth annotation is mounted behind it. See +[`credential-sources.md`](credential-sources.md) to write a source of your own. + +On lambda routes, take the middleware out of the context: + +```java +AuthMiddleware auth = app.ctx().require(AuthMiddleware.class); + +app.get("/api/me", (req, res) -> ClaimsHolder.claim("sub"), auth.protect()); +app.get("/", homeHandler, auth.optional()); +app.delete("/admin/users/{id}", deleteHandler, auth.requireRole("admin")); +app.post("/orders", createOrder, auth.requireScopes("orders:write")); +``` + +## Annotations + +On a scanned handler class, and mounted automatically: + +| Annotation | Effect | +|---|---| +| `@Authenticated` | Any accepted credential. No role check. | +| `@Authenticated(optional = true)` | Never rejects; publishes claims when there are some. | +| `@RolesAllowed({"a","b"})` | Authenticated **and** holding at least one of the roles. | +| `@ScopesAllowed({"x","y"})` | Authenticated **and** holding all of the scopes. | +| `@ScopesAllowed(value = {...}, match = ANY)` | …at least one of them. | + +`@Authenticated(optional = true)` cannot be combined with a role or scope requirement — asking for +a role on a route that admits anonymous callers is a contradiction, and it fails at boot rather +than at 3am. + +## Where roles and scopes are read from + +`AuthConfig` names the claim paths, because every provider spells them differently: + +| | Default | Common alternatives | +|---|---|---| +| `rolesClaimPath` | `roles` | `realm_access.roles` (Keycloak), `groups` (Authelia) | +| `scopeClaimPaths` | `scope,scp` | plus e.g. `permissions.scopes` | + +Paths are dot-separated and walk nested maps. Scope paths are a comma-separated list tried in +order, so a token that puts scopes in `scp` and a legacy one that uses `scope` both work. + +Matching is deliberate about a distinction that bites otherwise: + +- a **string** claim is split on spaces, tabs, newlines and commas — `"openid orders:read"` is two + scopes; +- a **list** claim is compared entry by entry, whole and trimmed — `["a b"]` is one role named + `a b`, not two. + +Prefix matches never count: `administrator` does not satisfy `admin`. + +## Ordering around authentication + +`AuthMiddleware.POLICY` is the boot-time key the annotation-driven node mounts under. An extension +contributing its own middleware can order itself against it: + +```java +MiddlewareNode.of(MY_KEY, myMiddleware).afterIfPresent(AuthMiddleware.POLICY); +``` diff --git a/flash-extensions/flash-ext-auth-core/docs/credential-sources.md b/flash-extensions/flash-ext-auth-core/docs/credential-sources.md new file mode 100644 index 0000000..efabb8c --- /dev/null +++ b/flash-extensions/flash-ext-auth-core/docs/credential-sources.md @@ -0,0 +1,95 @@ +# Writing a credential source + +A `CredentialSource` is the only thing that stands between a request and its claims. Everything +else in this module — annotations, policy, matching, the holder — works the same regardless of +which one is installed. + +```java +public interface CredentialSource { + Map authenticate(Request req, Response res); + Map peek(Request req); + default String insufficientScopeChallenge(String[] requiredScopes) { return null; } +} +``` + +## `authenticate` has three outcomes, and the last two are not the same + +| Return | Means | The middleware then | +|---|---|---| +| claims | a valid credential was presented | publishes them and calls the handler | +| `null` | **no** credential, and the source has already answered the request | stops, writes nothing more | +| throws `HttpException` | a credential **was** presented and is invalid | propagates it | + +Flattening the last two is the single easiest way to get this wrong. "No session, send the browser +to the sign-in page" and "this token is forged" are different answers, and a caller can tell: +the first is a `302` to a login screen, the second a `401` the client must not retry blindly. + +A source that returns `null` owns the response by then — it has redirected, or written a `401` with +its own `WWW-Authenticate` header. A source that throws sets any challenge header it owes *before* +throwing, because the exception unwinds past the middleware. + +`peek` is the same resolution with every rejection removed: no throwing, no redirecting, `null` +when there is nothing valid. It backs `@Authenticated(optional = true)`, where an anonymous caller +is a normal outcome. Never make `peek` refresh state that `authenticate` would not have. + +## A minimal source + +```java +public final class ApiKeySource implements CredentialSource { + + private final Map> keys; // key -> claims + + @Override + public Map authenticate(Request req, Response res) { + String key = req.header("X-Api-Key"); + if (key == null) { + res.header("WWW-Authenticate", "ApiKey realm=\"api\""); + throw HttpException.unauthorized(); + } + Map claims = keys.get(key); + if (claims == null) throw HttpException.unauthorized(); // presented and wrong + return claims; + } + + @Override + public Map peek(Request req) { + String key = req.header("X-Api-Key"); + return key != null ? keys.get(key) : null; + } +} +``` + +This one never returns `null` from `authenticate` — it has no sign-in flow to redirect into, so +"absent" and "invalid" both mean `401`. That is a legitimate shape; the three outcomes are what +the interface *allows*, not a checklist. + +## Claims are yours to shape + +The claims map is whatever your mechanism produces. `Claims` reads a few conventional keys — +`sub`, `email`, `name`, `preferred_username` — so populating those makes your source work with +code written against any other. Roles and scopes are read from wherever `AuthConfig` points, so +they can live under any key you like as long as the two agree. + +## Installing it + +```java +public final class ApiKeyExtension implements FlashExtension { + @Override + public void configure(FlashRegistrar app, FlashContext ctx) { + ctx.provide(ApiKeySource.class, source); + AuthMiddleware.install(ctx, AuthConfig.builder() + .rolesClaimPath("roles") + .build(), source); + } +} +``` + +`AuthMiddleware.install` also registers the annotation processor, so scanned handlers carrying +`@Authenticated` and friends are mounted behind your source with nothing further to do. + +## One source at a time + +`AuthMiddleware` is published in the context under its own type, so installing two extensions that +each call `install` leaves the last one winning — quietly. If an app genuinely needs to accept two +kinds of credential, that is one source that tries both, not two sources: the order they are tried +in, and what happens when the first rejects, are decisions that have to live somewhere explicit. diff --git a/flash-extensions/flash-ext-auth-core/docs/sessions.md b/flash-extensions/flash-ext-auth-core/docs/sessions.md new file mode 100644 index 0000000..e17fb67 --- /dev/null +++ b/flash-extensions/flash-ext-auth-core/docs/sessions.md @@ -0,0 +1,49 @@ +# Sessions + +A `Session` is what a credential source keeps server-side between requests, looked up by a cookie. +Core owns the container; what goes in it is the source's business. + +```java +public final class Session { + String id(); + Map claims(); + Instant expiresAt(); + Map attributes(); + boolean isExpired(); + Object attribute(String key); + String attributeAsString(String key); +} +``` + +## Why it expires early + +`isExpired()` returns true **30 seconds before** `expiresAt`. Without that window a session can +pass the check at the top of a request and be dead by the time the handler uses it — a class of +failure that reproduces once a day and never in a test. Renewal is therefore always slightly +premature, on purpose. + +## Attributes + +`attributes()` is opaque to this module. `flash-ext-auth-oidc` keeps its access, id and refresh +tokens there under its own keys, which is what lets renewal stay entirely inside that extension +while the session itself carries no OAuth2 vocabulary. + +Store what your source needs to renew or revoke, and nothing a handler should be reading — handlers +read `claims()`. + +## The store + +```java +public interface SessionStore { + void save(Session session); + Optional find(String sessionId); + void delete(String sessionId); +} +``` + +`InMemorySessionStore` is the default: a `ConcurrentHashMap`, fine for a single instance, and it +loses every session on restart. Supply your own for Redis or JDBC when sessions have to survive a +deploy or be shared across nodes. + +Sessions are immutable. Renewing one builds a new instance with the same `id()` and `save`s it +over the old — there is no mutate-in-place path, so a store can cache or serialise freely. diff --git a/flash-extensions/flash-ext-auth-oidc/README.md b/flash-extensions/flash-ext-auth-oidc/README.md index 5fccedf..960d20e 100644 --- a/flash-extensions/flash-ext-auth-oidc/README.md +++ b/flash-extensions/flash-ext-auth-oidc/README.md @@ -6,6 +6,11 @@ Supports Keycloak, Authelia, Auth0, Google, and any RFC 8414-compliant provider. Standards alignment focuses on OIDC Core + OAuth2 bearer APIs while preserving Flash's hot-path model (middleware compiled at mount time, no heavy runtime work). +This extension is the OpenID Connect **credential source** for +[`flash-ext-auth-core`](../flash-ext-auth-core/docs/README.md), which owns everything downstream of +identifying the caller. Shorter guides live in [`docs/`](docs/README.md), including +[migration notes](docs/interop.md#migrating-from-flash-ext-oidc) from `flash-ext-oidc`. + ## What it provides | Component | Description | @@ -13,24 +18,25 @@ hot-path model (middleware compiled at mount time, no heavy runtime work). | `GET {prefix}/login` | Starts the OIDC flow: builds the authorization URL with PKCE + state, redirects | | `GET {prefix}/callback` | Exchanges the code, validates the ID token, creates a session, redirects | | `POST {prefix}/logout` | Invalidates the session, redirects to the provider's `end_session_endpoint` | -| `@Authenticated` | Annotation: protects a class-based handler (redirects browsers, 401 for API clients) | -| `@RolesAllowed(...)` | Annotation: protects with role check (OR semantics) | -| `@ScopesAllowed(...)` | Annotation: protects with scope check (`ALL` default, `ANY` optional) | -| `OidcMiddleware` | Programmatic middleware for lambda routes | -| `ClaimsHolder` / `OidcUser` | Thread-local user info accessible from any protected handler | +| `OidcCredentialSource` | The `CredentialSource` this extension contributes to `flash-ext-auth-core` | | `JwtValidator` | JWKS-backed JWT validator (PKCE + key rotation + caching) | +`@Authenticated`, `@RolesAllowed`, `@ScopesAllowed`, `AuthMiddleware`, `ClaimsHolder` and `Claims` +belong to [`flash-ext-auth-core`](../flash-ext-auth-core/docs/README.md) and work the same behind +any credential source. Installing this extension brings them in and wires them up — you do not +install auth-core yourself. + ## Dependencies ```xml dev.relism flash-ext-auth-oidc - 1.0-SNAPSHOT + 2.1.0-SNAPSHOT ``` -Transitive: `nimbus-jose-jwt`, `json-smart`. +Transitive: `flash-ext-auth-core`, `nimbus-jose-jwt`, `json-smart`. Optional: `flash-ext-openapi` — if present, OIDC security schemes are added to the OpenAPI spec automatically. ## Installation @@ -98,7 +104,7 @@ OidcConfig.builder("https://auth.example.com", "my-client", "secret", "/auth/cal | `.scopeClaimPaths("scope,scp")` | `"scope,scp"` | Comma-separated claim paths used to resolve OAuth scopes | | `.algorithm("RS256")` | `"RS256"` | JWS algorithm for token validation | | `.postLogoutRedirectUri("/")` | `"/"` | Where to redirect after logout | -| `.sessionStore(store)` | `InMemoryOidcSessionStore` | Custom session store (see below) | +| `.sessionStore(store)` | `InMemorySessionStore` | Custom session store (see below) | | `.clientAuthMethod(ClientAuthMethod.POST)` | `POST` | `POST` = credentials in body; `BASIC` = `Authorization: Basic` | | `.insecureTls()` | `false` | Disables TLS certificate verification — **development only** | | `.schemeName("myscheme")` | derived from issuer | OpenAPI security scheme name | @@ -130,7 +136,7 @@ OIDC_CLIENT_AUTH_METHOD default: POST public class MePage extends JacksonHandler { @Override public Object handle(Request req, Response res) { - OidcUser u = ClaimsHolder.user(); + Claims u = ClaimsHolder.current(); return json(res, Map.of("sub", u.sub(), "email", u.email())); } } @@ -166,49 +172,50 @@ Annotation composition rules: ### Lambda routes (manual middleware) -For lambda routes, pass the middleware as a varargs argument. Retrieve `OidcMiddleware` +For lambda routes, pass the middleware as a varargs argument. Retrieve `AuthMiddleware` from the context inside another extension's `routes()` phase, or after `start()`: ```java -OidcMiddleware oidc = app.ctx().require(OidcMiddleware.class); +AuthMiddleware auth = app.ctx().require(AuthMiddleware.class); // Authentication only app.get("/api/me", (req, res) -> { - OidcUser u = ClaimsHolder.user(); // never null here + Claims u = ClaimsHolder.current(); // never null here return Map.of("sub", u.sub(), "email", u.email()); -}, oidc.protect()); +}, auth.protect()); // Authentication + role check app.delete("/api/admin/users/{id}", (req, res) -> { - OidcUser u = ClaimsHolder.user(); + Claims u = ClaimsHolder.current(); // ... -}, oidc.requireRole("admin")); +}, auth.requireRole("admin")); // Multiple roles (OR): passes if user holds any one of them -app.get("/api/reports", (req, res) -> { ... }, oidc.requireRole("admin", "reports-viewer")); +app.get("/api/reports", (req, res) -> { ... }, auth.requireRole("admin", "reports-viewer")); // Require all listed scopes -app.post("/api/orders", (req, res) -> { ... }, oidc.requireScopes("orders:write", "payments:write")); +app.post("/api/orders", (req, res) -> { ... }, auth.requireScopes("orders:write", "payments:write")); // Require at least one listed scope -app.post("/api/payments", (req, res) -> { ... }, oidc.requireAnyScope("payments:write", "payments:admin")); +app.post("/api/payments", (req, res) -> { ... }, auth.requireAnyScope("payments:write", "payments:admin")); ``` -`oidc.protect()` / `oidc.requireRole(...)` / `oidc.requireScopes(...)` return a `Middleware` — a composable -`Handler → Handler` wrapper. Flash applies middleware right-to-left so the OIDC check +`auth.protect()` / `auth.requireRole(...)` / `auth.requireScopes(...)` return a `Middleware` — a composable +`Handler → Handler` wrapper. Flash applies middleware right-to-left so the authentication check runs before your handler. ## Accessing the authenticated user `ClaimsHolder` holds the JWT claims for the current request in a `ThreadLocal`. -It is populated by the OIDC middleware before your handler runs and cleared in the -`finally` block afterward. It is safe with virtual threads (each request gets its +It is populated by `AuthMiddleware` — from `flash-ext-auth-core` — once this source has +authenticated the request, and cleared in the `finally` block afterward. Nothing outside that +module can write to it. It is safe with virtual threads (each request gets its own virtual thread, so `ThreadLocal` values are naturally isolated). -### OidcUser (preferred) +### Claims (preferred) ```java -OidcUser u = ClaimsHolder.user(); // never null inside a protected handler +Claims u = ClaimsHolder.current(); // never null inside a protected handler String sub = u.sub(); // unique user ID String email = u.email(); @@ -241,7 +248,7 @@ Map all = u.claims(); ### Raw access (escape hatch) ```java -Map claims = ClaimsHolder.get(); +Map claims = ClaimsHolder.map(); String email = ClaimsHolder.claim("email"); ``` @@ -340,7 +347,7 @@ Quick path to test `@ScopesAllowed` end-to-end: - Include it in `OidcConfig.scopes(...)`, e.g. `"openid profile email orders:write"` 5. **Protect a handler** - `@ScopesAllowed("orders:write")` on class-based handlers - - or `oidc.requireScopes("orders:write")` for lambda routes + - or `auth.requireScopes("orders:write")` for lambda routes 6. **Verify behavior** - token with scope -> 200 - token without scope -> 403 + `WWW-Authenticate: ... insufficient_scope` @@ -354,24 +361,29 @@ Useful token inspection flow while testing: ## Session store -The default `InMemoryOidcSessionStore` is sufficient for single-instance deployments. -For clustered deployments, implement `OidcSessionStore`: +Sessions live in `flash-ext-auth-core`'s `Session`/`SessionStore`; this extension keeps its +access, id and refresh tokens in `Session.attributes()` under its own keys, so renewal stays here +and core carries no OAuth2 vocabulary. See +[`../flash-ext-auth-core/docs/sessions.md`](../flash-ext-auth-core/docs/sessions.md). + +The default `InMemorySessionStore` is sufficient for single-instance deployments. +For clustered deployments, implement `SessionStore`: ```java -public interface OidcSessionStore { - void save(OidcSession session); - Optional find(String sessionId); +public interface SessionStore { + void save(Session session); + Optional find(String sessionId); void delete(String sessionId); } ``` ```java OidcConfig.builder(...) - .sessionStore(new RedisOidcSessionStore(redisClient)) + .sessionStore(new RedisSessionStore(redisClient)) .build() ``` -`OidcSession` fields: `id`, `accessToken`, `idToken`, `refreshToken`, `expiresAt` (`Instant`), `claims` (merged map). +`Session` fields: `id`, `accessToken`, `idToken`, `refreshToken`, `expiresAt` (`Instant`), `claims` (merged map). ## Logout @@ -401,7 +413,7 @@ Authorization: Bearer ``` The token must be a JWT (opaque tokens are not supported). Claims are available via -`ClaimsHolder.user()` as usual. +`ClaimsHolder.current()` as usual. ## Multi-tenant @@ -420,7 +432,7 @@ app.install(new OidcExtension(tenantA)) ``` To reference a specific tenant's middleware on lambda routes, keep the extension instances -and retrieve `OidcMiddleware` from context after `start()`: +and retrieve `AuthMiddleware` from context after `start()`: ```java OidcExtension extA = new OidcExtension(tenantA); @@ -432,10 +444,10 @@ FlashApp app = FlashApp.create(8080) .start() .join(); // wait for bind -OidcMiddleware mwA = app.ctx().require(OidcMiddleware.class); // last registered = tenantB +AuthMiddleware mwA = app.ctx().require(AuthMiddleware.class); // last registered = tenantB ``` -> **Note:** because both extensions register `OidcMiddleware.class` in the same context, +> **Note:** because both extensions register `AuthMiddleware.class` in the same context, > only the last one wins under that key. For multi-tenant setups, use distinct context > keys or provide middleware under a wrapper/alias type, or use lambda routes with explicit > middleware captured from the extension instance before `install()`. diff --git a/flash-extensions/flash-ext-auth-oidc/docs/README.md b/flash-extensions/flash-ext-auth-oidc/docs/README.md new file mode 100644 index 0000000..5a64011 --- /dev/null +++ b/flash-extensions/flash-ext-auth-oidc/docs/README.md @@ -0,0 +1,98 @@ +# flash-ext-auth-oidc + +OpenID Connect for Flash: the authorization-code flow with PKCE, JWKS-validated bearer tokens, +server-side sessions with silent refresh, and single logout. + +It is a **credential source** for [`flash-ext-auth-core`](../../flash-ext-auth-core/docs/README.md), +which owns everything downstream of "who is this caller" — `@Authenticated`, `@RolesAllowed`, +`@ScopesAllowed`, `ClaimsHolder`. Installing this extension installs that machinery too; you do not +install `flash-ext-auth-core` yourself. + +## Quick start + +```java +app.install(new OidcExtension( + OidcConfig.builder( + "https://keycloak.example.com/realms/myrealm", + "my-app", "secret", "/auth/callback") + .rolesClaimPath("realm_access.roles") + .https() + .build())); +``` + +That is the whole integration. Discovery runs at boot and fails fast if the issuer is unreachable, +so a misconfigured provider is a startup crash rather than a 500 on the first login. + +`OidcConfig.fromEnv()` reads the same settings from `OIDC_*` environment variables, and +`OidcConfig.keycloak(serverUrl, realm, ...)` builds the issuer URL for you. + +## What it registers + +| | | +|---|---| +| `GET {prefix}/login` | Builds the authorization URL with PKCE + state and redirects | +| `GET {prefix}/callback` | Validates state and nonce, exchanges the code, creates the session | +| `POST {prefix}/logout` | Ends the session and redirects to the provider's end-session endpoint | + +`{prefix}` is `routePrefix` (default `/auth`). Logout is a `POST` on purpose — a `GET` logout is +one `` tag away from being triggered by any page the user visits. + +In the context it provides `AuthMiddleware` (from auth-core), `OidcCredentialSource` and +`JwtValidator`. + +## How a request is resolved + +1. `Authorization: Bearer …` — validated against the issuer's JWKS. +2. `oidc_session` cookie — looked up in the `SessionStore`; if the access token has expired and a + refresh token is present, refreshed transparently and the session replaced. +3. Neither, and the client sent `Accept: application/json` → `401` with a + `WWW-Authenticate: Bearer` challenge. +4. Neither, and it looks like a browser → redirect to `{prefix}/login?redirect={path}`. + +Points 3 and 4 are why the source distinguishes "no credential" from "bad credential": an API +client must not be redirected into an HTML sign-in page, and a browser must not be left staring at +a bare 401. + +## Configuration + +| Setting | Default | Notes | +|---|---|---| +| `issuer`, `clientId`, `clientSecret`, `redirectUri` | — | required | +| `scopes` | `openid profile email` | | +| `routePrefix` | `/auth` | | +| `selfScheme` | `http` | `https()` behind TLS; only used when no `X-Forwarded-Proto` | +| `rolesClaimPath` | `realm_access.roles` | Keycloak's spelling; `groups` for Authelia | +| `scopeClaimPaths` | `scope,scp` | comma-separated, tried in order | +| `algorithm` | `RS256` | | +| `postLogoutRedirectUri` | `/` | | +| `sessionStore` | `InMemorySessionStore` | swap for Redis/JDBC across instances | +| `clientAuthMethod` | `POST` | token endpoint client authentication | +| `insecureTls()` | off | dev only, skips certificate validation | +| `schemeName` | derived from the issuer | OpenAPI security scheme name | + +A relative `redirectUri` (starting with `/`) is resolved per request against the incoming `Host`, +or `X-Forwarded-Host`/`-Proto` when behind a proxy — so one build works in dev and behind TLS +without a second configuration. + +## Sessions + +A session holds the claims plus the access, id and refresh tokens, the last three in +`Session.attributes()` under this extension's own keys. Core never reads them; renewal happens +here. See [`../../flash-ext-auth-core/docs/sessions.md`](../../flash-ext-auth-core/docs/sessions.md). + +## Multiple providers + +Two issuers on one server, each with its own route prefix: + +```java +app.install(new OidcExtension(tenantAConfig)) // routePrefix("/tenantA/auth") + .install(new OidcExtension(tenantBConfig)); // routePrefix("/tenantB/auth") +``` + +Both are known at boot. Registering an issuer at runtime — a customer connecting their own IdP from +a settings page — is not supported. + +## Interop + +See [`interop.md`](interop.md) for how this extension fits with `flash-ext-auth-core`, +`flash-ext-openapi` and `flash-ext-mcp`. diff --git a/flash-extensions/flash-ext-auth-oidc/docs/interop.md b/flash-extensions/flash-ext-auth-oidc/docs/interop.md new file mode 100644 index 0000000..d0970ea --- /dev/null +++ b/flash-extensions/flash-ext-auth-oidc/docs/interop.md @@ -0,0 +1,88 @@ +# Interop + +## flash-ext-auth-core + +A hard dependency, and the reason this extension is as small as it is. The division: + +| Here | `flash-ext-auth-core` | +|---|---| +| Discovery, JWKS, PKCE, token endpoint | `@Authenticated`, `@RolesAllowed`, `@ScopesAllowed` | +| `/login`, `/callback`, `/logout` | `ClaimsHolder`, `Claims` | +| Bearer and cookie resolution, refresh | Role and scope matching | +| RFC 6750 `WWW-Authenticate` challenges | `Session`, `SessionStore` | + +`OidcExtension` builds an `OidcCredentialSource`, hands it to `AuthMiddleware.install(...)`, and +that publishes the middleware and registers the annotation processor. Everything a handler +annotation does is core's code running against claims this extension produced. + +Consequence worth knowing: `@RolesAllowed` is not OIDC-specific and never was. An app that swaps +this extension for another credential source keeps every annotation it had. + +## flash-ext-openapi + +Optional, and resolved lazily so this extension runs standalone when openapi is not on the +classpath. When it is, an `OpenApiContributor` is registered that emits an `oauth2` security scheme +with the `authorizationCode` flow, filled in from the discovery document: + +```json +"securitySchemes": { + "myrealm": { + "type": "oauth2", + "flows": { "authorizationCode": { "authorizationUrl": "…", "tokenUrl": "…", "scopes": {…} } } + } +} +``` + +Per-operation security comes from the same annotations the middleware reads, so the spec and the +enforcement cannot drift: both call `AuthPolicy.compileFromAnnotations`. + +The scheme name is `schemeName`, derived from the last path segment of the issuer (a Keycloak realm +name, usually) unless set explicitly. + +## flash-ext-mcp + +`McpSecurity` asks whether **this** extension is installed — `ctx.find(OidcCredentialSource.class)` +— and not merely whether something authenticates: + +| Policy | this extension installed | absent | +|---|---|---| +| `REQUIRED` | protected | **boot fails** | +| `AUTO` | protected | unprotected, warning logged | +| `NONE` | never protected | unprotected | + +That distinction is deliberate. `REQUIRED` means "a real OAuth2 authorization server is protecting +this endpoint", because everything it turns on — RFC 9728 Protected Resource Metadata, RFC 8707 +audience binding, `WWW-Authenticate` challenges carrying `resource_metadata` — is meaningless +without an issuer. An app that authenticates some other way must not satisfy it by accident. + +When it is installed, `McpOidcIntegration` derives the whole resource-server configuration from the +source with no extra `McpConfig` calls: + +- the MCP route is wrapped with `authMw.withSource(source.withResourceMetadata(path)).protect()` — + the same validation every other route uses, plus the `resource_metadata` challenge parameter; +- an audience guard runs after it and rejects any token whose `aud` does not include this + endpoint's resource identifier; +- the resource identifier is resolved per request from `X-Forwarded-Host`/`-Proto`, or the `Host` + header and `selfScheme`. + +An app that does **not** use OAuth2 can still guard `/mcp`: set `McpSecurity.NONE` and pass its own +guard to `McpConfig.middleware(...)`. + +## Migrating from flash-ext-oidc + +The module was renamed and its generic half moved. Mechanically: + +| Was | Now | +|---|---| +| `flash-ext-oidc` (artifact) | `flash-ext-auth-oidc` | +| `dev.relism.flash.ext.oidc.Authenticated` (and `RolesAllowed`, `ScopesAllowed`) | `dev.relism.flash.ext.auth.…` | +| `OidcMiddleware` | `AuthMiddleware` (`dev.relism.flash.ext.auth`) | +| `ctx.find(OidcMiddleware.class)` | `ctx.find(AuthMiddleware.class)` | +| `OidcUser` | `Claims` | +| `ClaimsHolder.user()` | `ClaimsHolder.current()` | +| `ClaimsHolder.get()` | `ClaimsHolder.map()` | +| `OidcSession`, `OidcSessionStore`, `InMemoryOidcSessionStore` | `Session`, `SessionStore`, `InMemorySessionStore` | +| `session.isAccessTokenExpired()` | `session.isExpired()` | +| `session.idToken()` | `session.attributeAsString(OidcCredentialSource.ID_TOKEN)` | + +`OidcConfig`, `OidcExtension` and every setting on them are unchanged. diff --git a/flash-extensions/flash-ext-mcp/docs/security.md b/flash-extensions/flash-ext-mcp/docs/security.md index 412b12e..f6fa0d0 100644 --- a/flash-extensions/flash-ext-mcp/docs/security.md +++ b/flash-extensions/flash-ext-mcp/docs/security.md @@ -16,6 +16,24 @@ installed (`ctx.find(OidcMiddleware.class)`), resolved once at boot in `McpExten | `AUTO` (default) | protected | runs unprotected, logs a warning | | `NONE` | never protected, even if oidc is installed elsewhere in the app | runs unprotected | +### Guarding `/mcp` without OAuth2 + +`McpSecurity` only ever answers "is `flash-ext-auth-oidc` installed". An app that authenticates +some other way sets `McpSecurity.NONE` and supplies its own guard: + +```java +McpConfig.builder("my-server") + .toolsPackage("com.example.mcp") + .security(McpSecurity.NONE) + .middleware(myAuthMiddleware.protect()) + .build(); +``` + +`McpConfig.middleware(...)` runs after the transport guards and after whatever `McpSecurity` +resolved to, so it composes with OAuth2 protection rather than replacing it — the same hook is how +you add rate limiting, audit logging or tracing to the endpoint. It never satisfies `REQUIRED`, +which still asks for a real authorization server. + Use `REQUIRED` for anything you intend to run in production reachable over the network — it turns "someone forgot to wire up OAuth2" into a startup crash instead of a silently open endpoint. `AUTO` is meant for local development, where spinning up a real identity provider is 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 f088b60..805d53b 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 @@ -1,5 +1,7 @@ package dev.relism.flash.ext.mcp; +import dev.relism.flash.routing.Middleware; + import java.util.ArrayList; import java.util.List; @@ -28,6 +30,7 @@ public final class McpConfig { private final String authorizationServerIssuer; private final List allowedOrigins; private final List scopesSupported; + private final List middleware; private McpConfig(Builder b) { this.name = b.name; @@ -40,6 +43,7 @@ public final class McpConfig { this.authorizationServerIssuer = b.authorizationServerIssuer; this.allowedOrigins = List.copyOf(b.allowedOrigins); this.scopesSupported = List.copyOf(b.scopesSupported); + this.middleware = List.copyOf(b.middleware); } String name() { return name; } @@ -52,6 +56,7 @@ public final class McpConfig { String authorizationServerIssuer() { return authorizationServerIssuer; } List allowedOrigins() { return allowedOrigins; } List scopesSupported() { return scopesSupported; } + List middleware() { return middleware; } public static Builder builder(String name) { return new Builder(name); } @@ -66,6 +71,7 @@ public final class McpConfig { private String authorizationServerIssuer; private final List allowedOrigins = new ArrayList<>(); private final List scopesSupported = new ArrayList<>(); + private final List middleware = new ArrayList<>(); private Builder(String name) { if (name == null || name.isBlank()) @@ -128,6 +134,22 @@ public final class McpConfig { */ public Builder scopesSupported(String... scopes) { this.scopesSupported.addAll(List.of(scopes)); return this; } + /** + * Middleware to run on the MCP route, in the order given, after the transport guards and + * after whatever {@link McpSecurity} resolved to. Rate limiting, audit logging, tracing — + * anything that is routine on every other Flash route and had no way in here. + * + *

    It runs on an authenticated request when OAuth2 protection is active, and is the only + * thing standing in front of the endpoint when it is not: {@link McpSecurity#NONE} plus a + * middleware of your own is how an app that authenticates some other way guards + * {@code /mcp}. It never satisfies {@link McpSecurity#REQUIRED}, which still asks for a + * real authorization server. + */ + public Builder middleware(Middleware... middleware) { + this.middleware.addAll(List.of(middleware)); + return this; + } + public McpConfig build() { if (toolsPackage == null || toolsPackage.isBlank()) throw new IllegalStateException( 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 3126f99..a7a81c8 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 @@ -65,10 +65,11 @@ public class McpExtension implements FlashExtension { secured == null ? null : secured.rolesClaimPath()); McpDispatcher dispatcher = new McpDispatcher(registry, config.name(), config.version(), config.instructions()); - List chain = new ArrayList<>(3); + List chain = new ArrayList<>(3 + config.middleware().size()); chain.add(McpTransportGuards.httpExceptionGuard()); chain.add(McpTransportGuards.originGuard(config.allowedOrigins())); if (secured != null) chain.add(secured.security()); + chain.addAll(config.middleware()); app.post(config.rootPath(), (req, res) -> { dispatcher.handle(req, res); return null; }, chain.toArray(Middleware[]::new)); diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpConfigMiddlewareTest.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpConfigMiddlewareTest.java new file mode 100644 index 0000000..cf1f253 --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpConfigMiddlewareTest.java @@ -0,0 +1,61 @@ +package dev.relism.flash.ext.mcp; + +import dev.relism.flash.exceptions.HttpException; +import dev.relism.flash.testing.FlashTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Application middleware on the MCP route. Until this existed a consumer had no way to put rate + * limiting, audit logging or tracing in front of {@code /mcp} — the chain was assembled entirely + * inside the extension. + */ +class McpConfigMiddlewareTest { + + private static final AtomicInteger CALLS = new AtomicInteger(); + + @RegisterExtension + static FlashTest mcp = FlashTest.of(app -> app.install(new McpExtension( + McpConfig.builder("middleware-server") + .version("1.0.0") + .toolsPackage("dev.relism.flash.ext.mcp.fixtures") + .security(McpSecurity.NONE) + .middleware( + next -> (req, res) -> { + CALLS.incrementAndGet(); + return next.handle(req, res); + }, + next -> (req, res) -> { + if ("deny".equals(req.header("X-Test-Gate"))) throw HttpException.forbidden(); + return next.handle(req, res); + }) + .build()))); + + @Test + void appMiddlewareRunsOnTheMcpRoute() { + int before = CALLS.get(); + mcp.request() + .json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}") + .post("/mcp") + .expectStatus(200); + assertEquals(before + 1, CALLS.get()); + } + + /** + * The point of the hook: with {@link McpSecurity#NONE}, an app's own guard is the only thing + * in front of the endpoint — which is how an app that does not authenticate with OAuth2 + * protects {@code /mcp} at all. + */ + @Test + void appMiddlewareCanRejectTheRequest() { + mcp.request() + .header("X-Test-Gate", "deny") + .json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}") + .post("/mcp") + .expectStatus(403); + } +} -- 2.54.0 From 096098b33c8116b125272132e892eff8c056e4e1 Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Wed, 16 Sep 2026 15:54:10 +0000 Subject: [PATCH 6/9] fix(ext-openapi): allow contributors to enrich default responses --- .../flash/ext/openapi/OpenApiBuilder.java | 3 +- .../flash/ext/openapi/OpenApiBuilderTest.java | 37 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java index a420587..63c18c4 100644 --- a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java +++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/flash/ext/openapi/OpenApiBuilder.java @@ -176,7 +176,8 @@ public final class OpenApiBuilder { } if (responseByCode.isEmpty()) { - responseByCode.put(200, Map.of("description", "OK")); + // Mutable: contributors merge descriptions and headers into it. + responseByCode.put(200, new LinkedHashMap<>(Map.of("description", "OK"))); } applyContributorResponses(responseByCode, cls); diff --git a/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiBuilderTest.java b/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiBuilderTest.java index 4362b46..b3f79aa 100644 --- a/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiBuilderTest.java +++ b/flash-extensions/flash-ext-openapi/src/test/java/dev/relism/flash/ext/openapi/OpenApiBuilderTest.java @@ -101,6 +101,15 @@ class OpenApiBuilderTest { } } + @GET("/plain") + @ApiOperation(summary = "Plain") + static class PlainHandler extends RequestHandler { + @Override + public Object handle(Request request, Response response) { + return null; + } + } + @Schema(name = "UserDTO", title = "User model", description = "DTO", deprecated = true) @JsonIgnoreProperties({"ignoredByType"}) static class UserDto { @@ -341,6 +350,34 @@ class OpenApiBuilderTest { assertEquals("new", header.get("description")); } + @Test + void contributor_merges_into_the_default_response_of_an_operation_without_annotations() { + OpenApiBuilder b = new OpenApiBuilder(); + OpenApiContributorRegistry registry = new OpenApiContributorRegistry(); + registry.add(new OpenApiContributor() { + @Override + public OpenApiOperationContribution operationFor(Class handlerClass) { + return OpenApiOperationContribution.builder() + .allResponses(OpenApiResponseContribution.builder() + .header("X-Trace", Map.of("schema", Map.of("type", "string"))) + .build()) + .response(401, OpenApiResponseContribution.of("Authentication required")) + .build(); + } + }); + b.setContributorRegistry(registry); + b.addOperation(OpenApiBuilder.routeOf(PlainHandler.class), PlainHandler.class.getAnnotation(ApiOperation.class), PlainHandler.class); + + Map spec = b.build(); + Map responses = cast(getOperation(spec, "/plain", "get").get("responses")); + Map resp200 = cast(responses.get("200")); + Map resp401 = cast(responses.get("401")); + Map headers = cast(resp200.get("headers")); + assertEquals("OK", resp200.get("description")); + assertTrue(headers.containsKey("X-Trace")); + assertEquals("Authentication required", resp401.get("description")); + } + private static Map getOperation(Map spec, String path, String method) { Map paths = cast(spec.get("paths")); Map pathItem = cast(paths.get(path)); -- 2.54.0 From b30a4af1d67a192609adcbd0296ecc46a5428bb8 Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Wed, 16 Sep 2026 15:54:10 +0000 Subject: [PATCH 7/9] feat(core): add request helpers for security flows --- .../flash/exceptions/HttpException.java | 7 ++++- .../java/dev/relism/flash/models/Request.java | 29 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/flash/src/main/java/dev/relism/flash/exceptions/HttpException.java b/flash/src/main/java/dev/relism/flash/exceptions/HttpException.java index a7084b5..9dda050 100644 --- a/flash/src/main/java/dev/relism/flash/exceptions/HttpException.java +++ b/flash/src/main/java/dev/relism/flash/exceptions/HttpException.java @@ -32,7 +32,12 @@ public class HttpException extends RuntimeException { } public static HttpException forbidden() { - return new HttpException(403, "Forbidden"); + return forbidden("Forbidden"); + } + + /** Refused with a reason the caller may be told — why the credential is not enough, never what it would take. */ + public static HttpException forbidden(String message) { + return new HttpException(403, message); } public static HttpException notFound(String what) { diff --git a/flash/src/main/java/dev/relism/flash/models/Request.java b/flash/src/main/java/dev/relism/flash/models/Request.java index b7b0f03..9c45280 100644 --- a/flash/src/main/java/dev/relism/flash/models/Request.java +++ b/flash/src/main/java/dev/relism/flash/models/Request.java @@ -194,6 +194,35 @@ public class Request { */ public List headers() { checkActive(); return requestLine.getHeaders().all(); } + /** + * The scheme and authority the client addressed, e.g. {@code https://example.com}: from + * {@code X-Forwarded-Proto}/{@code X-Forwarded-Host} when present, otherwise the connection and + * the {@code Host} header. Only meaningful behind a proxy that sets or strips those headers. + */ + public String origin() { + String proto = header("X-Forwarded-Proto"); + String host = header("X-Forwarded-Host"); + return (proto != null ? proto : isSecure() ? "https" : "http") + "://" + (host != null ? host : header("Host")); + } + + /** + * Returns the value of cookie {@code name}, or {@code null} if the request does not carry it. + * Reads the {@code Cookie} header in place; only the returned value is allocated. + */ + public String cookie(String name) { + String cookies = header("Cookie"); + if (cookies == null) return null; + for (int at = cookies.indexOf(name); at >= 0; at = cookies.indexOf(name, at + 1)) { + int eq = at + name.length(); + if (eq < cookies.length() && cookies.charAt(eq) == '=' + && (at == 0 || cookies.charAt(at - 1) == ' ' || cookies.charAt(at - 1) == ';')) { + int end = cookies.indexOf(';', eq + 1); + return cookies.substring(eq + 1, end < 0 ? cookies.length() : end); + } + } + return null; + } + // ── Path parameters ─────────────────────────────────────────────────────── /** -- 2.54.0 From 017c2443f44c586670d2a1e6995262f5ff5a1cb9 Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Wed, 16 Sep 2026 15:54:10 +0000 Subject: [PATCH 8/9] feat(testing): support reusable request customizers --- .../main/java/dev/relism/flash/testing/FlashRequest.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/flash-testing/src/main/java/dev/relism/flash/testing/FlashRequest.java b/flash-testing/src/main/java/dev/relism/flash/testing/FlashRequest.java index b55b01b..51571d6 100644 --- a/flash-testing/src/main/java/dev/relism/flash/testing/FlashRequest.java +++ b/flash-testing/src/main/java/dev/relism/flash/testing/FlashRequest.java @@ -7,6 +7,7 @@ import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Objects; +import java.util.function.Consumer; /** * A request being built against a {@link FlashTest} server. The HTTP verb is terminal — it @@ -41,6 +42,12 @@ public final class FlashRequest { return this; } + /** Applies {@code customizer} — how an extension's test kit attaches a credential, for instance. */ + public FlashRequest with(Consumer customizer) { + customizer.accept(this); + return this; + } + /** Sets a UTF-8 request body. */ public FlashRequest body(String text) { this.body = Objects.requireNonNull(text, "text").getBytes(StandardCharsets.UTF_8); -- 2.54.0 From e0795299fc52cf78c5bb7357f1c9d54e1086d296 Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Wed, 16 Sep 2026 15:54:19 +0000 Subject: [PATCH 9/9] refactor(ext-oidc): replace auth modules with security extensions --- .idea/encodings.xml | 4 +- README.md | 16 +- .../flash-ext-auth-core/docs/README.md | 105 ---- .../docs/credential-sources.md | 95 ---- .../flash-ext-auth-core/docs/sessions.md | 49 -- .../dev/relism/flash/ext/auth/AuthConfig.java | 43 -- .../relism/flash/ext/auth/AuthMiddleware.java | 309 ------------ .../dev/relism/flash/ext/auth/AuthPolicy.java | 98 ---- .../relism/flash/ext/auth/Authenticated.java | 40 -- .../dev/relism/flash/ext/auth/Claims.java | 230 --------- .../relism/flash/ext/auth/ClaimsHolder.java | 64 --- .../flash/ext/auth/CredentialSource.java | 48 -- .../flash/ext/auth/InMemorySessionStore.java | 20 - .../relism/flash/ext/auth/RolesAllowed.java | 31 -- .../relism/flash/ext/auth/ScopesAllowed.java | 45 -- .../dev/relism/flash/ext/auth/Session.java | 53 -- .../relism/flash/ext/auth/SessionStore.java | 13 - .../relism/flash/ext/auth/AuthPolicyTest.java | 94 ---- .../flash/ext/auth/ClaimMatchingTest.java | 209 -------- .../flash/ext/auth/ClaimsScopesTest.java | 47 -- .../relism/flash/ext/auth/SessionTest.java | 58 --- .../flash-ext-auth-oidc/README.md | 474 ------------------ .../flash-ext-auth-oidc/docs/README.md | 98 ---- .../flash-ext-auth-oidc/docs/interop.md | 88 ---- .../flash/ext/oidc/ClientAuthMethod.java | 18 - .../flash/ext/oidc/DiscoveryClient.java | 50 -- .../dev/relism/flash/ext/oidc/JwtUtils.java | 38 -- .../relism/flash/ext/oidc/JwtValidator.java | 184 ------- .../dev/relism/flash/ext/oidc/OidcConfig.java | 264 ---------- .../flash/ext/oidc/OidcCredentialSource.java | 333 ------------ .../relism/flash/ext/oidc/OidcExtension.java | 343 ------------- .../flash/ext/oidc/OidcProviderMetadata.java | 15 - .../relism/flash/ext/oidc/OidcStateStore.java | 39 -- .../flash/ext/oidc/OidcTokenResponse.java | 10 - .../ext/oidc/OidcValidationException.java | 14 - .../dev/relism/flash/ext/oidc/PkceUtils.java | 36 -- .../relism/flash/ext/oidc/TokenClient.java | 112 ----- .../ext/oidc/OidcCredentialSourceTest.java | 43 -- .../ext/oidc/OidcOpenApiInteropTest.java | 128 ----- .../flash-ext-limiter/docs/README.md | 2 +- .../flash-ext-limiter/docs/key-resolvers.md | 6 +- .../relism/flash/ext/limiter/KeyResolver.java | 2 +- .../flash/ext/limiter/LimiterConfig.java | 4 +- .../flash/ext/limiter/LimiterExtension.java | 2 +- flash-extensions/flash-ext-mcp/docs/README.md | 6 +- .../flash-ext-mcp/docs/jackson-interop.md | 5 +- .../flash-ext-mcp/docs/keycloak.md | 107 ---- .../flash-ext-mcp/docs/security.md | 211 ++------ flash-extensions/flash-ext-mcp/pom.xml | 19 +- .../relism/flash/ext/mcp/McpAuthPolicy.java | 23 - .../dev/relism/flash/ext/mcp/McpConfig.java | 62 +-- .../relism/flash/ext/mcp/McpDispatcher.java | 18 +- .../relism/flash/ext/mcp/McpExtension.java | 151 +++--- .../dev/relism/flash/ext/mcp/McpJson.java | 2 +- .../flash/ext/mcp/McpOidcIntegration.java | 186 ------- .../dev/relism/flash/ext/mcp/McpRegistry.java | 37 +- .../flash/ext/mcp/McpResourceMetadata.java | 10 +- .../dev/relism/flash/ext/mcp/McpSecurity.java | 12 +- .../flash/ext/mcp/McpTransportGuards.java | 4 +- .../flash/ext/mcp/FakeOidcProvider.java | 109 ---- .../flash/ext/mcp/McpAuthPolicyTest.java | 141 ------ .../ext/mcp/McpExtensionSecurityTest.java | 179 ------- .../relism/flash/ext/mcp/McpRegistryTest.java | 4 +- .../relism/flash/ext/mcp/McpSecurityTest.java | 109 ++++ .../authenticatedonly/PointlessAuthTool.java | 20 - .../authfixtures/secured/AdminOnlyTool.java | 4 +- .../authfixtures/secured/WriteScopeTool.java | 2 +- flash-extensions/flash-ext-openapi/README.md | 12 +- .../ext/routeviewer/model/RouteRecord.java | 2 +- .../flash-ext-security-apikey/docs/README.md | 20 + .../flash-ext-security-apikey/pom.xml | 30 ++ .../flash/ext/security/apikey/ApiKey.java | 17 + .../ext/security/apikey/ApiKeyExtension.java | 97 ++++ .../ext/security/apikey/ApiKeyPrincipal.java | 6 + .../ext/security/apikey/ApiKeyStore.java | 9 + .../ext/security/apikey/GeneratedApiKey.java | 7 + .../security/apikey/ApiKeyExtensionTest.java | 57 +++ .../flash-ext-security-core/docs/README.md | 88 ++++ .../pom.xml | 24 +- .../flash/ext/security/Authenticated.java | 12 + .../security/AuthenticationEntryPoint.java | 11 + .../AuthenticationFailedException.java | 24 + .../ext/security/AuthenticationMechanism.java | 21 + .../ext/security/InMemorySessionStore.java | 26 + .../flash/ext/security/LoginMethod.java | 12 + .../relism/flash/ext/security/PermitAll.java | 15 + .../relism/flash/ext/security/Principal.java | 20 + .../flash/ext/security/RoleResolver.java | 11 + .../flash/ext/security/RolesAllowed.java | 24 + .../flash/ext/security/ScopesAllowed.java | 15 + .../flash/ext/security/SecurityExtension.java | 321 ++++++++++++ .../flash/ext/security/SecurityIdentity.java | 65 +++ .../flash/ext/security/SecurityPolicy.java | 63 +++ .../flash/ext/security/SecurityScheme.java | 24 + .../relism/flash/ext/security/Session.java | 6 + .../flash/ext/security/SessionRefresher.java | 8 + .../flash/ext/security/SessionStore.java | 12 + .../dev/relism/flash/ext/security/Target.java | 15 + .../flash/ext/security/UserResolver.java | 8 + .../ext/security/SecurityExtensionTest.java | 132 +++++ .../ext/security/SecurityPolicyTest.java | 44 ++ .../ext/security/fixtures/OpenHandler.java | 18 + .../ext/security/fixtures/ProjectHandler.java | 19 + .../ext/security/fixtures/WriteHandler.java | 18 + .../flash-ext-security-form/docs/README.md | 17 + .../pom.xml | 9 +- .../ext/security/form/FormLoginExtension.java | 74 +++ .../ext/security/form/PasswordEncoder.java | 58 +++ .../ext/security/form/PasswordStore.java | 14 + .../security/form/FormLoginExtensionTest.java | 63 +++ .../flash-ext-security-oidc/docs/README.md | 71 +++ .../flash-ext-security-oidc/pom.xml | 33 ++ .../flash/ext/security/oidc/ClaimRoles.java | 38 ++ .../ext/security/oidc/OidcExtension.java | 239 +++++++++ .../ext/security/oidc/OidcPrincipal.java | 49 ++ .../flash/ext/security/oidc/OidcProvider.java | 32 ++ .../flash/ext/security/oidc/Provider.java | 164 ++++++ .../ext/security/oidc/OidcExtensionTest.java | 167 ++++++ .../flash-ext-security-test/docs/README.md | 15 + .../flash-ext-security-test/pom.xml | 34 ++ .../ext/security/test/FakeOidcProvider.java | 150 ++++++ .../flash/ext/security/test/OidcTokens.java | 47 ++ .../flash/ext/security/test/TestSecurity.java | 46 ++ .../ext/security/test/TestSecurityTest.java | 29 ++ flash-extensions/pom.xml | 15 +- pom.xml | 2 +- 126 files changed, 2944 insertions(+), 5130 deletions(-) delete mode 100644 flash-extensions/flash-ext-auth-core/docs/README.md delete mode 100644 flash-extensions/flash-ext-auth-core/docs/credential-sources.md delete mode 100644 flash-extensions/flash-ext-auth-core/docs/sessions.md delete mode 100644 flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthConfig.java delete mode 100644 flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthMiddleware.java delete mode 100644 flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthPolicy.java delete mode 100644 flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/Authenticated.java delete mode 100644 flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/Claims.java delete mode 100644 flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/ClaimsHolder.java delete mode 100644 flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/CredentialSource.java delete mode 100644 flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/InMemorySessionStore.java delete mode 100644 flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/RolesAllowed.java delete mode 100644 flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/ScopesAllowed.java delete mode 100644 flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/Session.java delete mode 100644 flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/SessionStore.java delete mode 100644 flash-extensions/flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/AuthPolicyTest.java delete mode 100644 flash-extensions/flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/ClaimMatchingTest.java delete mode 100644 flash-extensions/flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/ClaimsScopesTest.java delete mode 100644 flash-extensions/flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/SessionTest.java delete mode 100644 flash-extensions/flash-ext-auth-oidc/README.md delete mode 100644 flash-extensions/flash-ext-auth-oidc/docs/README.md delete mode 100644 flash-extensions/flash-ext-auth-oidc/docs/interop.md delete mode 100644 flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/ClientAuthMethod.java delete mode 100644 flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/DiscoveryClient.java delete mode 100644 flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/JwtUtils.java delete mode 100644 flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/JwtValidator.java delete mode 100644 flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcConfig.java delete mode 100644 flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcCredentialSource.java delete mode 100644 flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcExtension.java delete mode 100644 flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcProviderMetadata.java delete mode 100644 flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcStateStore.java delete mode 100644 flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcTokenResponse.java delete mode 100644 flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcValidationException.java delete mode 100644 flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/PkceUtils.java delete mode 100644 flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/TokenClient.java delete mode 100644 flash-extensions/flash-ext-auth-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcCredentialSourceTest.java delete mode 100644 flash-extensions/flash-ext-auth-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcOpenApiInteropTest.java delete mode 100644 flash-extensions/flash-ext-mcp/docs/keycloak.md delete mode 100644 flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpAuthPolicy.java delete mode 100644 flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpOidcIntegration.java delete mode 100644 flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/FakeOidcProvider.java delete mode 100644 flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpAuthPolicyTest.java delete mode 100644 flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionSecurityTest.java create mode 100644 flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpSecurityTest.java delete mode 100644 flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/authenticatedonly/PointlessAuthTool.java create mode 100644 flash-extensions/flash-ext-security-apikey/docs/README.md create mode 100644 flash-extensions/flash-ext-security-apikey/pom.xml create mode 100644 flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/ApiKey.java create mode 100644 flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/ApiKeyExtension.java create mode 100644 flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/ApiKeyPrincipal.java create mode 100644 flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/ApiKeyStore.java create mode 100644 flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/GeneratedApiKey.java create mode 100644 flash-extensions/flash-ext-security-apikey/src/test/java/dev/relism/flash/ext/security/apikey/ApiKeyExtensionTest.java create mode 100644 flash-extensions/flash-ext-security-core/docs/README.md rename flash-extensions/{flash-ext-auth-oidc => flash-ext-security-core}/pom.xml (66%) create mode 100644 flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/Authenticated.java create mode 100644 flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/AuthenticationEntryPoint.java create mode 100644 flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/AuthenticationFailedException.java create mode 100644 flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/AuthenticationMechanism.java create mode 100644 flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/InMemorySessionStore.java create mode 100644 flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/LoginMethod.java create mode 100644 flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/PermitAll.java create mode 100644 flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/Principal.java create mode 100644 flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/RoleResolver.java create mode 100644 flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/RolesAllowed.java create mode 100644 flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/ScopesAllowed.java create mode 100644 flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SecurityExtension.java create mode 100644 flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SecurityIdentity.java create mode 100644 flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SecurityPolicy.java create mode 100644 flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SecurityScheme.java create mode 100644 flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/Session.java create mode 100644 flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SessionRefresher.java create mode 100644 flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SessionStore.java create mode 100644 flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/Target.java create mode 100644 flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/UserResolver.java create mode 100644 flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/SecurityExtensionTest.java create mode 100644 flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/SecurityPolicyTest.java create mode 100644 flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/fixtures/OpenHandler.java create mode 100644 flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/fixtures/ProjectHandler.java create mode 100644 flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/fixtures/WriteHandler.java create mode 100644 flash-extensions/flash-ext-security-form/docs/README.md rename flash-extensions/{flash-ext-auth-core => flash-ext-security-form}/pom.xml (72%) create mode 100644 flash-extensions/flash-ext-security-form/src/main/java/dev/relism/flash/ext/security/form/FormLoginExtension.java create mode 100644 flash-extensions/flash-ext-security-form/src/main/java/dev/relism/flash/ext/security/form/PasswordEncoder.java create mode 100644 flash-extensions/flash-ext-security-form/src/main/java/dev/relism/flash/ext/security/form/PasswordStore.java create mode 100644 flash-extensions/flash-ext-security-form/src/test/java/dev/relism/flash/ext/security/form/FormLoginExtensionTest.java create mode 100644 flash-extensions/flash-ext-security-oidc/docs/README.md create mode 100644 flash-extensions/flash-ext-security-oidc/pom.xml create mode 100644 flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/ClaimRoles.java create mode 100644 flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/OidcExtension.java create mode 100644 flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/OidcPrincipal.java create mode 100644 flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/OidcProvider.java create mode 100644 flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/Provider.java create mode 100644 flash-extensions/flash-ext-security-oidc/src/test/java/dev/relism/flash/ext/security/oidc/OidcExtensionTest.java create mode 100644 flash-extensions/flash-ext-security-test/docs/README.md create mode 100644 flash-extensions/flash-ext-security-test/pom.xml create mode 100644 flash-extensions/flash-ext-security-test/src/main/java/dev/relism/flash/ext/security/test/FakeOidcProvider.java create mode 100644 flash-extensions/flash-ext-security-test/src/main/java/dev/relism/flash/ext/security/test/OidcTokens.java create mode 100644 flash-extensions/flash-ext-security-test/src/main/java/dev/relism/flash/ext/security/test/TestSecurity.java create mode 100644 flash-extensions/flash-ext-security-test/src/test/java/dev/relism/flash/ext/security/test/TestSecurityTest.java diff --git a/.idea/encodings.xml b/.idea/encodings.xml index cf88791..278fb63 100644 --- a/.idea/encodings.xml +++ b/.idea/encodings.xml @@ -17,8 +17,8 @@ - - + + diff --git a/README.md b/README.md index 6fa1051..bb94243 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,12 @@ 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-auth-core` | Authentication seam + role/scope authorization | -| `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-security-core` | Security: authentication chain, annotations, sessions, OpenAPI | +| `flash-extensions/flash-ext-security-oidc` | OpenID Connect: bearer tokens, code flow + PKCE | +| `flash-extensions/flash-ext-security-apikey` | API keys | +| `flash-extensions/flash-ext-security-form` | Password sign-in | +| `flash-extensions/flash-ext-security-test` | Test identities, fake OpenID Provider | +| `flash-extensions/flash-ext-mcp` | MCP (Model Context Protocol) server — Streamable HTTP, secured by flash-ext-security-core | | `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 | @@ -147,8 +150,11 @@ 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-auth-core`](flash-extensions/flash-ext-auth-core/docs/README.md) -- [`flash-ext-auth-oidc`](flash-extensions/flash-ext-auth-oidc/docs/README.md) +- [`flash-ext-security-core`](flash-extensions/flash-ext-security-core/docs/README.md) +- [`flash-ext-security-oidc`](flash-extensions/flash-ext-security-oidc/docs/README.md) +- [`flash-ext-security-apikey`](flash-extensions/flash-ext-security-apikey/docs/README.md) +- [`flash-ext-security-form`](flash-extensions/flash-ext-security-form/docs/README.md) +- [`flash-ext-security-test`](flash-extensions/flash-ext-security-test/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-thymeleaf`](flash-extensions/flash-ext-view-thymeleaf/README.md) diff --git a/flash-extensions/flash-ext-auth-core/docs/README.md b/flash-extensions/flash-ext-auth-core/docs/README.md deleted file mode 100644 index be13f11..0000000 --- a/flash-extensions/flash-ext-auth-core/docs/README.md +++ /dev/null @@ -1,105 +0,0 @@ -# flash-ext-auth-core - -Authorization, and the plumbing that carries a caller's identity through a request. It does not -know how anyone signed in — that is a `CredentialSource`, and `flash-ext-auth-oidc` ships the -OpenID Connect one. - -The split follows the same shape as `flash-ext-cache-core`/`-caffeine` and -`flash-ext-data-core`/`-hibernate`: the abstract half here, the implementations beside it. - -## The model - -``` -request ──► CredentialSource.authenticate(req, res) ──► claims - │ - ClaimsHolder.set (this module only) - │ - AuthMiddleware matches roles / scopes - │ - handler -``` - -| Type | What it is | -|---|---| -| `CredentialSource` | Turns what a request carries into claims, or rejects it. One per mechanism. | -| `AuthMiddleware` | Publishes the claims, enforces `@RolesAllowed`/`@ScopesAllowed`, clears up. | -| `ClaimsHolder` | The current request's claims. Read from anywhere; written only from here. | -| `Claims` | Typed view over a claims map — `sub()`, `email()`, `roles(path)`, `scopes()`. | -| `AuthPolicy` | What a handler's annotations compiled to, resolved once at boot. | -| `Session`, `SessionStore` | Server-side sessions for sources that keep them. | - -Nothing outside this module can write `ClaimsHolder`. A source *returns* claims and the middleware -publishes them, so no code can put claims on a request that did not carry them. - -## Using it - -You rarely install this module directly — an extension that contributes a source does it for you: - -```java -// inside your extension's configure(...) -AuthMiddleware auth = AuthMiddleware.install(ctx, AuthConfig.builder() - .rolesClaimPath("realm_access.roles") - .scopeClaimPaths("scope,scp") - .build(), mySource); -``` - -`install` publishes the middleware in the context and registers the annotation processor, so every -scanned handler carrying an auth annotation is mounted behind it. See -[`credential-sources.md`](credential-sources.md) to write a source of your own. - -On lambda routes, take the middleware out of the context: - -```java -AuthMiddleware auth = app.ctx().require(AuthMiddleware.class); - -app.get("/api/me", (req, res) -> ClaimsHolder.claim("sub"), auth.protect()); -app.get("/", homeHandler, auth.optional()); -app.delete("/admin/users/{id}", deleteHandler, auth.requireRole("admin")); -app.post("/orders", createOrder, auth.requireScopes("orders:write")); -``` - -## Annotations - -On a scanned handler class, and mounted automatically: - -| Annotation | Effect | -|---|---| -| `@Authenticated` | Any accepted credential. No role check. | -| `@Authenticated(optional = true)` | Never rejects; publishes claims when there are some. | -| `@RolesAllowed({"a","b"})` | Authenticated **and** holding at least one of the roles. | -| `@ScopesAllowed({"x","y"})` | Authenticated **and** holding all of the scopes. | -| `@ScopesAllowed(value = {...}, match = ANY)` | …at least one of them. | - -`@Authenticated(optional = true)` cannot be combined with a role or scope requirement — asking for -a role on a route that admits anonymous callers is a contradiction, and it fails at boot rather -than at 3am. - -## Where roles and scopes are read from - -`AuthConfig` names the claim paths, because every provider spells them differently: - -| | Default | Common alternatives | -|---|---|---| -| `rolesClaimPath` | `roles` | `realm_access.roles` (Keycloak), `groups` (Authelia) | -| `scopeClaimPaths` | `scope,scp` | plus e.g. `permissions.scopes` | - -Paths are dot-separated and walk nested maps. Scope paths are a comma-separated list tried in -order, so a token that puts scopes in `scp` and a legacy one that uses `scope` both work. - -Matching is deliberate about a distinction that bites otherwise: - -- a **string** claim is split on spaces, tabs, newlines and commas — `"openid orders:read"` is two - scopes; -- a **list** claim is compared entry by entry, whole and trimmed — `["a b"]` is one role named - `a b`, not two. - -Prefix matches never count: `administrator` does not satisfy `admin`. - -## Ordering around authentication - -`AuthMiddleware.POLICY` is the boot-time key the annotation-driven node mounts under. An extension -contributing its own middleware can order itself against it: - -```java -MiddlewareNode.of(MY_KEY, myMiddleware).afterIfPresent(AuthMiddleware.POLICY); -``` diff --git a/flash-extensions/flash-ext-auth-core/docs/credential-sources.md b/flash-extensions/flash-ext-auth-core/docs/credential-sources.md deleted file mode 100644 index efabb8c..0000000 --- a/flash-extensions/flash-ext-auth-core/docs/credential-sources.md +++ /dev/null @@ -1,95 +0,0 @@ -# Writing a credential source - -A `CredentialSource` is the only thing that stands between a request and its claims. Everything -else in this module — annotations, policy, matching, the holder — works the same regardless of -which one is installed. - -```java -public interface CredentialSource { - Map authenticate(Request req, Response res); - Map peek(Request req); - default String insufficientScopeChallenge(String[] requiredScopes) { return null; } -} -``` - -## `authenticate` has three outcomes, and the last two are not the same - -| Return | Means | The middleware then | -|---|---|---| -| claims | a valid credential was presented | publishes them and calls the handler | -| `null` | **no** credential, and the source has already answered the request | stops, writes nothing more | -| throws `HttpException` | a credential **was** presented and is invalid | propagates it | - -Flattening the last two is the single easiest way to get this wrong. "No session, send the browser -to the sign-in page" and "this token is forged" are different answers, and a caller can tell: -the first is a `302` to a login screen, the second a `401` the client must not retry blindly. - -A source that returns `null` owns the response by then — it has redirected, or written a `401` with -its own `WWW-Authenticate` header. A source that throws sets any challenge header it owes *before* -throwing, because the exception unwinds past the middleware. - -`peek` is the same resolution with every rejection removed: no throwing, no redirecting, `null` -when there is nothing valid. It backs `@Authenticated(optional = true)`, where an anonymous caller -is a normal outcome. Never make `peek` refresh state that `authenticate` would not have. - -## A minimal source - -```java -public final class ApiKeySource implements CredentialSource { - - private final Map> keys; // key -> claims - - @Override - public Map authenticate(Request req, Response res) { - String key = req.header("X-Api-Key"); - if (key == null) { - res.header("WWW-Authenticate", "ApiKey realm=\"api\""); - throw HttpException.unauthorized(); - } - Map claims = keys.get(key); - if (claims == null) throw HttpException.unauthorized(); // presented and wrong - return claims; - } - - @Override - public Map peek(Request req) { - String key = req.header("X-Api-Key"); - return key != null ? keys.get(key) : null; - } -} -``` - -This one never returns `null` from `authenticate` — it has no sign-in flow to redirect into, so -"absent" and "invalid" both mean `401`. That is a legitimate shape; the three outcomes are what -the interface *allows*, not a checklist. - -## Claims are yours to shape - -The claims map is whatever your mechanism produces. `Claims` reads a few conventional keys — -`sub`, `email`, `name`, `preferred_username` — so populating those makes your source work with -code written against any other. Roles and scopes are read from wherever `AuthConfig` points, so -they can live under any key you like as long as the two agree. - -## Installing it - -```java -public final class ApiKeyExtension implements FlashExtension { - @Override - public void configure(FlashRegistrar app, FlashContext ctx) { - ctx.provide(ApiKeySource.class, source); - AuthMiddleware.install(ctx, AuthConfig.builder() - .rolesClaimPath("roles") - .build(), source); - } -} -``` - -`AuthMiddleware.install` also registers the annotation processor, so scanned handlers carrying -`@Authenticated` and friends are mounted behind your source with nothing further to do. - -## One source at a time - -`AuthMiddleware` is published in the context under its own type, so installing two extensions that -each call `install` leaves the last one winning — quietly. If an app genuinely needs to accept two -kinds of credential, that is one source that tries both, not two sources: the order they are tried -in, and what happens when the first rejects, are decisions that have to live somewhere explicit. diff --git a/flash-extensions/flash-ext-auth-core/docs/sessions.md b/flash-extensions/flash-ext-auth-core/docs/sessions.md deleted file mode 100644 index e17fb67..0000000 --- a/flash-extensions/flash-ext-auth-core/docs/sessions.md +++ /dev/null @@ -1,49 +0,0 @@ -# Sessions - -A `Session` is what a credential source keeps server-side between requests, looked up by a cookie. -Core owns the container; what goes in it is the source's business. - -```java -public final class Session { - String id(); - Map claims(); - Instant expiresAt(); - Map attributes(); - boolean isExpired(); - Object attribute(String key); - String attributeAsString(String key); -} -``` - -## Why it expires early - -`isExpired()` returns true **30 seconds before** `expiresAt`. Without that window a session can -pass the check at the top of a request and be dead by the time the handler uses it — a class of -failure that reproduces once a day and never in a test. Renewal is therefore always slightly -premature, on purpose. - -## Attributes - -`attributes()` is opaque to this module. `flash-ext-auth-oidc` keeps its access, id and refresh -tokens there under its own keys, which is what lets renewal stay entirely inside that extension -while the session itself carries no OAuth2 vocabulary. - -Store what your source needs to renew or revoke, and nothing a handler should be reading — handlers -read `claims()`. - -## The store - -```java -public interface SessionStore { - void save(Session session); - Optional find(String sessionId); - void delete(String sessionId); -} -``` - -`InMemorySessionStore` is the default: a `ConcurrentHashMap`, fine for a single instance, and it -loses every session on restart. Supply your own for Redis or JDBC when sessions have to survive a -deploy or be shared across nodes. - -Sessions are immutable. Renewing one builds a new instance with the same `id()` and `save`s it -over the old — there is no mutate-in-place path, so a store can cache or serialise freely. diff --git a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthConfig.java b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthConfig.java deleted file mode 100644 index 6df58af..0000000 --- a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthConfig.java +++ /dev/null @@ -1,43 +0,0 @@ -package dev.relism.flash.ext.auth; - -/** - * Where authorization reads its inputs from. Deliberately small: everything about *obtaining* a - * credential belongs to the {@link CredentialSource} that produced it, and everything about - * *checking* one is right here. - * - *

    Defaults are the generic spelling, not any one provider's. A source that knows better — - * {@code flash-ext-auth-oidc} defaults roles to Keycloak's {@code realm_access.roles} — builds - * its own {@code AuthConfig} with the paths its provider actually uses. - */ -public final class AuthConfig { - - private final String rolesClaimPath; - private final String scopeClaimPaths; - - private AuthConfig(Builder b) { - this.rolesClaimPath = b.rolesClaimPath; - this.scopeClaimPaths = b.scopeClaimPaths; - } - - /** Dot-separated path to the roles list in the claims (default: {@code roles}). */ - public String rolesClaimPath() { return rolesClaimPath; } - - /** Comma-separated claim paths scopes are read from, in order (default: {@code scope,scp}). */ - public String scopeClaimPaths() { return scopeClaimPaths; } - - public static Builder builder() { return new Builder(); } - - public static final class Builder { - private String rolesClaimPath = "roles"; - private String scopeClaimPaths = "scope,scp"; - - private Builder() {} - - /** Dot-separated path to the roles list — e.g. {@code realm_access.roles}, {@code groups}. */ - public Builder rolesClaimPath(String path) { this.rolesClaimPath = path; return this; } - /** Comma-separated claim paths scopes are read from, tried in order. */ - public Builder scopeClaimPaths(String paths) { this.scopeClaimPaths = paths; return this; } - - public AuthConfig build() { return new AuthConfig(this); } - } -} 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 deleted file mode 100644 index 2c4a104..0000000 --- a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthMiddleware.java +++ /dev/null @@ -1,309 +0,0 @@ -package dev.relism.flash.ext.auth; - -import dev.relism.flash.exceptions.HttpException; -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; -import java.util.Map; - -/** - * Turns claims into a yes or a no. Exposed in the {@link FlashContext} for manual use on lambda - * routes, and injected automatically for handlers annotated with {@link Authenticated}, - * {@link RolesAllowed} or {@link ScopesAllowed}. - * - *

    It knows nothing about how the caller proved who they are — that is the - * {@link CredentialSource} it is built with. What lives here is the half that is the same for - * every mechanism: publish the claims for the request, match roles and scopes against them, clear - * up afterwards. - * - *

    {@code
    - * AuthMiddleware auth = app.ctx().require(AuthMiddleware.class);
    - * app.get("/api/me", (req, res) -> ClaimsHolder.claim("sub"), auth.protect());
    - * app.delete("/admin/users/{id}", handler, auth.requireRole("admin"));
    - * }
    - */ -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; - private final String[][] scopeClaimPathParts; - - public AuthMiddleware(AuthConfig config, CredentialSource source) { - this.config = config; - this.source = source; - this.roleClaimPathParts = splitClaimPath(config.rolesClaimPath()); - 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. */ - public String rolesClaimPath() { return config.rolesClaimPath(); } - - /** The source this middleware authenticates with. */ - public CredentialSource source() { return source; } - - /** - * The same authorization rules against a different credential source. Used where one route - * needs a variant of an installed source — {@code flash-ext-mcp} protects {@code /mcp} with an - * OIDC source whose challenges carry RFC 9728 resource metadata, while every other route keeps - * the plain one. - */ - public AuthMiddleware withSource(CredentialSource source) { - return new AuthMiddleware(config, source); - } - - /** - * Rejects the request unless the caller is authenticated. How it is rejected — a 401 with a - * challenge, a redirect into a sign-in flow — is the source's decision, not this one's. - */ - public Middleware protect() { - return next -> (req, res) -> { - Map claims = source.authenticate(req, res); - if (claims == null) return null; // the source already answered the request - ClaimsHolder.set(claims); - try { - return next.handle(req, res); - } finally { - ClaimsHolder.clear(); - } - }; - } - - /** - * Publishes claims when the caller happens to be authenticated and never rejects anyone. Use - * it on public routes that personalise their response for signed-in callers. - * - *

    {@code
    -     * app.get("/", handler, auth.optional());
    -     * // Inside handler: ClaimsHolder.current() is non-null iff the caller is signed in.
    -     * }
    - */ - public Middleware optional() { - return next -> (req, res) -> { - Map claims = source.peek(req); - if (claims != null) ClaimsHolder.set(claims); - try { - return next.handle(req, res); - } finally { - ClaimsHolder.clear(); - } - }; - } - - /** - * Applies a policy compiled once at boot from a handler's annotations. This is the path - * annotation-driven mounting takes. - */ - public Middleware authorize(AuthPolicy policy) { - if (policy.optionalAuth()) return optional(); - return next -> (req, res) -> { - Map claims = source.authenticate(req, res); - if (claims == null) return null; - enforcePolicy(claims, policy, res); - ClaimsHolder.set(claims); - try { - return next.handle(req, res); - } finally { - ClaimsHolder.clear(); - } - }; - } - - /** {@link #protect()} plus at least one of the given roles (OR semantics). */ - public Middleware requireRole(String... roles) { - return authorize(AuthPolicy.rolesAny(roles)); - } - - /** {@link #protect()} plus every one of the given scopes. */ - public Middleware requireScopes(String... scopes) { - return authorize(AuthPolicy.scopes(scopes, ScopesAllowed.Match.ALL)); - } - - /** {@link #protect()} plus at least one of the given scopes. */ - public Middleware requireAnyScope(String... scopes) { - return authorize(AuthPolicy.scopes(scopes, ScopesAllowed.Match.ANY)); - } - - // -- Policy enforcement --------------------------------------------------- - - private void enforcePolicy(Map claims, AuthPolicy policy, Response res) { - checkRoles(claims, policy.requiredRoles()); - checkScopes(claims, policy.requiredScopes(), policy.scopeMatch(), res); - } - - private void checkRoles(Map claims, String[] required) { - if (required.length == 0) return; - if (rolesAllowed(claims, required)) return; - throw HttpException.forbidden(); - } - - private void checkScopes(Map claims, String[] required, ScopesAllowed.Match match, - Response res) { - if (required.length == 0) return; - if (scopesAllowed(claims, required, match)) return; - String challenge = source.insufficientScopeChallenge(required); - if (challenge != null) res.header("WWW-Authenticate", challenge); - throw HttpException.forbidden(); - } - - // -- Claim matching ------------------------------------------------------- - - boolean rolesAllowed(Map claims, String[] required) { - Object actual = valueAtPath(claims, roleClaimPathParts); - if (actual == null) return false; - for (String role : required) { - if (containsToken(actual, role)) return true; - } - return false; - } - - boolean scopesAllowed(Map claims, String[] required, ScopesAllowed.Match match) { - if (match == ScopesAllowed.Match.ALL) { - for (String scope : required) { - if (!hasScope(claims, scope)) return false; - } - return true; - } - for (String scope : required) { - if (hasScope(claims, scope)) return true; - } - return false; - } - - private boolean hasScope(Map claims, String scope) { - for (String[] pathParts : scopeClaimPathParts) { - Object value = valueAtPath(claims, pathParts); - if (value != null && containsToken(value, scope)) return true; - } - return false; - } - - private static Object valueAtPath(Map claims, String[] pathParts) { - Object current = claims; - for (String part : pathParts) { - if (!(current instanceof Map map)) return null; - current = map.get(part); - if (current == null) return null; - } - return current; - } - - private static boolean containsToken(Object source, String token) { - if (source instanceof String s) return containsDelimitedToken(s, token); - if (source instanceof List list) { - for (Object item : list) { - if (item == null) continue; - if (tokenEquals(item.toString(), token)) return true; - } - return false; - } - if (source instanceof Object[] arr) { - for (Object item : arr) { - if (item == null) continue; - if (tokenEquals(item.toString(), token)) return true; - } - return false; - } - return tokenEquals(source.toString(), token); - } - - private static boolean containsDelimitedToken(String value, String token) { - int len = value.length(); - int i = 0; - while (i < len) { - while (i < len && isScopeDelimiter(value.charAt(i))) i++; - int start = i; - while (i < len && !isScopeDelimiter(value.charAt(i))) i++; - int end = i; - if (end > start && end - start == token.length() && value.regionMatches(start, token, 0, token.length())) { - return true; - } - } - return false; - } - - private static boolean tokenEquals(String value, String token) { - int start = 0; - int end = value.length(); - while (start < end && Character.isWhitespace(value.charAt(start))) start++; - while (end > start && Character.isWhitespace(value.charAt(end - 1))) end--; - return end - start == token.length() && value.regionMatches(start, token, 0, token.length()); - } - - private static boolean isScopeDelimiter(char c) { - return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ','; - } - - private static String[] splitClaimPath(String path) { - if (path == null || path.isBlank()) { - throw new IllegalStateException("Claim path cannot be blank"); - } - List parts = new ArrayList<>(4); - int start = 0; - int len = path.length(); - for (int i = 0; i <= len; i++) { - if (i == len || path.charAt(i) == '.') { - String p = path.substring(start, i).trim(); - if (!p.isEmpty()) parts.add(p); - start = i + 1; - } - } - if (parts.isEmpty()) { - throw new IllegalStateException("Claim path cannot be blank"); - } - return parts.toArray(String[]::new); - } - - private static String[][] splitClaimPaths(String paths) { - String source = (paths == null || paths.isBlank()) ? "scope,scp" : paths; - List out = new ArrayList<>(4); - int start = 0; - int len = source.length(); - for (int i = 0; i <= len; i++) { - if (i == len || source.charAt(i) == ',') { - String raw = source.substring(start, i).trim(); - if (!raw.isEmpty()) out.add(splitClaimPath(raw)); - start = i + 1; - } - } - if (out.isEmpty()) { - return new String[][]{ splitClaimPath("scope"), splitClaimPath("scp") }; - } - return out.toArray(String[][]::new); - } -} diff --git a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthPolicy.java b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthPolicy.java deleted file mode 100644 index 8cdb741..0000000 --- a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/AuthPolicy.java +++ /dev/null @@ -1,98 +0,0 @@ -package dev.relism.flash.ext.auth; - -import java.util.LinkedHashSet; -import java.util.List; - -/** - * Compiled authorization policy derived from handler annotations at mount time. - * Immutable and allocation-free on the request hot path. - */ -public final class AuthPolicy { - - private static final String[] EMPTY = new String[0]; - - private static final AuthPolicy AUTH_REQUIRED = new AuthPolicy( - false, EMPTY, EMPTY, ScopesAllowed.Match.ALL); - private static final AuthPolicy AUTH_OPTIONAL = new AuthPolicy( - true, EMPTY, EMPTY, ScopesAllowed.Match.ALL); - - private final boolean optionalAuth; - private final String[] requiredRoles; - private final String[] requiredScopes; - private final ScopesAllowed.Match scopeMatch; - - private AuthPolicy(boolean optionalAuth, - String[] requiredRoles, - String[] requiredScopes, - ScopesAllowed.Match scopeMatch) { - this.optionalAuth = optionalAuth; - this.requiredRoles = requiredRoles; - this.requiredScopes = requiredScopes; - this.scopeMatch = scopeMatch; - } - - public static AuthPolicy authenticated() { return AUTH_REQUIRED; } - - public static AuthPolicy optional() { return AUTH_OPTIONAL; } - - public static AuthPolicy rolesAny(String... roles) { - return new AuthPolicy(false, normalizeRequired("RolesAllowed", roles), EMPTY, ScopesAllowed.Match.ALL); - } - - public static AuthPolicy scopes(String[] scopes, ScopesAllowed.Match match) { - return new AuthPolicy(false, EMPTY, normalizeRequired("ScopesAllowed", scopes), match); - } - - public static AuthPolicy compileFromAnnotations(Class handlerClass) { - Authenticated auth = handlerClass.getAnnotation(Authenticated.class); - RolesAllowed roles = handlerClass.getAnnotation(RolesAllowed.class); - ScopesAllowed scopes = handlerClass.getAnnotation(ScopesAllowed.class); - - if (auth == null && roles == null && scopes == null) return null; - - boolean optionalAuth = auth != null && auth.optional(); - String[] requiredRoles = roles != null ? normalizeRequired("RolesAllowed", roles.value()) : EMPTY; - String[] requiredScopes = scopes != null ? normalizeRequired("ScopesAllowed", scopes.value()) : EMPTY; - ScopesAllowed.Match scopeMatch = scopes != null ? scopes.match() : ScopesAllowed.Match.ALL; - - if (optionalAuth && (requiredRoles.length > 0 || requiredScopes.length > 0)) { - throw new IllegalStateException("@Authenticated(optional = true) cannot be combined with @RolesAllowed/@ScopesAllowed on " - + handlerClass.getName()); - } - - return new AuthPolicy(optionalAuth, requiredRoles, requiredScopes, scopeMatch); - } - - public static List openApiScopesFor(Class handlerClass) { - Authenticated auth = handlerClass.getAnnotation(Authenticated.class); - RolesAllowed roles = handlerClass.getAnnotation(RolesAllowed.class); - ScopesAllowed scopes = handlerClass.getAnnotation(ScopesAllowed.class); - if (auth == null && roles == null && scopes == null) return null; - if (scopes == null) return List.of(); - return List.of(normalizeRequired("ScopesAllowed", scopes.value())); - } - - public boolean optionalAuth() { return optionalAuth; } - - public String[] requiredRoles() { return requiredRoles; } - - public String[] requiredScopes() { return requiredScopes; } - - public ScopesAllowed.Match scopeMatch() { return scopeMatch; } - - private static String[] normalizeRequired(String annotation, String[] values) { - if (values == null || values.length == 0) - throw new IllegalStateException("@" + annotation + " requires at least one value"); - - LinkedHashSet normalized = new LinkedHashSet<>(values.length); - for (String raw : values) { - if (raw == null) continue; - String trimmed = raw.trim(); - if (!trimmed.isEmpty()) normalized.add(trimmed); - } - if (normalized.isEmpty()) - throw new IllegalStateException("@" + annotation + " requires at least one non-empty value"); - - return normalized.toArray(String[]::new); - } -} diff --git a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/Authenticated.java b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/Authenticated.java deleted file mode 100644 index 7ef6faa..0000000 --- a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/Authenticated.java +++ /dev/null @@ -1,40 +0,0 @@ -package dev.relism.flash.ext.auth; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * Marks a handler as requiring an authenticated caller. Any credential a registered source - * accepts is enough — no role or scope check is performed. - * - *

    For role-based access use {@link RolesAllowed} instead (it implies authentication). - * - *

    Set {@code optional = true} on public routes that personalise their response when the caller - * happens to be signed in but should remain reachable by guests. The middleware populates - * {@link ClaimsHolder} when a credential is present and silently skips it otherwise — the request - * is never rejected. - * - *

    {@code
    - * // Hard auth — 401 or a redirect when unauthenticated:
    - * @Route(method = HttpMethod.GET, path = "/api/profile")
    - * @Authenticated
    - * public class GetProfile extends JacksonHandler { ... }
    - *
    - * // Soft auth — guest-friendly, ClaimsHolder populated only when signed in:
    - * @Route(method = HttpMethod.GET, path = "/")
    - * @Authenticated(optional = true)
    - * public class HomePage extends HtmlHandler { ... }
    - * }
    - */ -@Retention(RetentionPolicy.RUNTIME) -@Target(ElementType.TYPE) -public @interface Authenticated { - /** - * When {@code true} the middleware never rejects unauthenticated requests — it only - * populates {@link ClaimsHolder} when valid credentials are present. - * Defaults to {@code false} (hard authentication required). - */ - boolean optional() default false; -} diff --git a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/Claims.java b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/Claims.java deleted file mode 100644 index bc71286..0000000 --- a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/Claims.java +++ /dev/null @@ -1,230 +0,0 @@ -package dev.relism.flash.ext.auth; - -import java.util.List; -import java.util.Map; -import java.util.ArrayList; - -/** - * A typed view over one request's claims — whatever the {@link CredentialSource} that - * authenticated it produced. Obtained from {@link ClaimsHolder#current()}. - * - *

    The accessors name claim keys, not a protocol: {@code sub} is RFC 7519, and - * {@code email}, {@code name} and {@code preferred_username} are spelled the same way by every - * token issuer worth integrating. A source that uses different keys exposes them through - * {@link #claim(String)} or {@link #roles(String)}. - * - *

    {@code
    - * app.get("/api/whoami", (req, res) -> {
    - *     Claims c = ClaimsHolder.current();
    - *     return Map.of("sub", c.sub(), "email", c.email(), "roles", c.roles("realm_access.roles"));
    - * }, auth.protect());
    - * }
    - */ -public final class Claims { - - private final Map claims; - - Claims(Map claims) { - this.claims = claims; - } - - // ── Common claims ───────────────────────────────────────────────────────── - - /** Subject identifier — the stable, unique id of the caller. */ - public String sub() { return str("sub"); } - - /** User's email address ({@code email} claim). */ - public String email() { return str("email"); } - - /** Human-readable username ({@code preferred_username} claim). */ - public String username() { return str("preferred_username"); } - - /** Full display name ({@code name} claim). */ - public String name() { return str("name"); } - - // ── Roles ──────────────────────────────────────────────────────────────── - - /** - * Extracts the roles list by traversing a dot-separated claim path. - * - *

    Example paths: - *

      - *
    • {@code "realm_access.roles"} — Keycloak realm roles
    • - *
    • {@code "resource_access.my-client.roles"} — Keycloak client roles
    • - *
    • {@code "groups"} — Authelia / generic IdPs
    • - *
    - * - * @return list of role strings, or an empty list if the path doesn't exist - */ - @SuppressWarnings("unchecked") - public List roles(String claimPath) { - String[] parts = claimPath.split("\\."); - Object current = claims; - for (String part : parts) { - if (!(current instanceof Map m)) return List.of(); - current = m.get(part); - } - if (current instanceof List list) - return list.stream().map(Object::toString).toList(); - return List.of(); - } - - /** Returns {@code true} if the user holds {@code role} at the given claim path. */ - public boolean hasRole(String claimPath, String role) { - return roles(claimPath).contains(role); - } - - // -- Scopes --------------------------------------------------------------- - - /** - * Resolves scopes using the conventional fallback order: - * {@code scope} then {@code scp}. Supports both space-separated string and list forms. - */ - public List scopes() { - return scopes("scope,scp"); - } - - /** - * Resolves scopes from comma-separated claim paths (example: {@code "scope,scp,permissions.scopes"}). - */ - public List scopes(String claimPaths) { - List out = new ArrayList<>(); - for (String[] path : splitClaimPaths(claimPaths)) { - Object value = valueAtPath(path); - if (value == null) continue; - if (value instanceof String s) { - appendDelimitedTokens(out, s); - continue; - } - if (value instanceof List list) { - for (Object item : list) { - if (item == null) continue; - String token = item.toString().trim(); - if (!token.isEmpty()) out.add(token); - } - continue; - } - String token = value.toString().trim(); - if (!token.isEmpty()) out.add(token); - } - return out.isEmpty() ? List.of() : List.copyOf(out); - } - - /** Returns {@code true} if the user has {@code scope}, searching default claim paths {@code scope,scp}. */ - public boolean hasScope(String scope) { - return hasScope("scope,scp", scope); - } - - /** Returns {@code true} if the user has {@code scope} in any of {@code claimPaths}. */ - public boolean hasScope(String claimPaths, String scope) { - if (scope == null || scope.isBlank()) return false; - String target = scope.trim(); - for (String[] path : splitClaimPaths(claimPaths)) { - Object value = valueAtPath(path); - if (value == null) continue; - if (value instanceof String s && containsDelimitedToken(s, target)) return true; - if (value instanceof List list) { - for (Object item : list) { - if (item == null) continue; - if (target.equals(item.toString().trim())) return true; - } - continue; - } - if (target.equals(value.toString().trim())) return true; - } - return false; - } - - // ── Arbitrary claim access ───────────────────────────────────────────── - - /** - * Returns the value of any claim, cast to {@code T}. - * - * @throws ClassCastException if the stored value is not assignable to {@code type} - */ - public T claim(String key, Class type) { - return type.cast(claims.get(key)); - } - - /** Returns the raw claim value, or {@code null} if absent. */ - public Object claim(String key) { return claims.get(key); } - - /** Escape hatch — returns the full unmodified claims map. */ - public Map claims() { return claims; } - - // ── Internals ───────────────────────────────────────────────────────── - - private String str(String key) { - Object v = claims.get(key); - return v != null ? v.toString() : null; - } - - private Object valueAtPath(String[] path) { - Object current = claims; - for (String part : path) { - if (!(current instanceof Map m)) return null; - current = m.get(part); - if (current == null) return null; - } - return current; - } - - private static String[][] splitClaimPaths(String claimPaths) { - String source = (claimPaths == null || claimPaths.isBlank()) ? "scope,scp" : claimPaths; - List out = new ArrayList<>(4); - int start = 0; - int len = source.length(); - for (int i = 0; i <= len; i++) { - if (i == len || source.charAt(i) == ',') { - String raw = source.substring(start, i).trim(); - if (!raw.isEmpty()) out.add(splitPath(raw)); - start = i + 1; - } - } - return out.isEmpty() ? new String[][]{ splitPath("scope"), splitPath("scp") } : out.toArray(String[][]::new); - } - - private static String[] splitPath(String path) { - List out = new ArrayList<>(4); - int start = 0; - int len = path.length(); - for (int i = 0; i <= len; i++) { - if (i == len || path.charAt(i) == '.') { - String raw = path.substring(start, i).trim(); - if (!raw.isEmpty()) out.add(raw); - start = i + 1; - } - } - return out.isEmpty() ? new String[]{ path } : out.toArray(String[]::new); - } - - private static void appendDelimitedTokens(List target, String source) { - int len = source.length(); - int i = 0; - while (i < len) { - while (i < len && isDelimiter(source.charAt(i))) i++; - int start = i; - while (i < len && !isDelimiter(source.charAt(i))) i++; - if (i > start) target.add(source.substring(start, i)); - } - } - - private static boolean containsDelimitedToken(String source, String token) { - int len = source.length(); - int i = 0; - while (i < len) { - while (i < len && isDelimiter(source.charAt(i))) i++; - int start = i; - while (i < len && !isDelimiter(source.charAt(i))) i++; - int end = i; - if (end > start && end - start == token.length() && source.regionMatches(start, token, 0, token.length())) { - return true; - } - } - return false; - } - - private static boolean isDelimiter(char c) { - return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ','; - } -} diff --git a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/ClaimsHolder.java b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/ClaimsHolder.java deleted file mode 100644 index a8cf400..0000000 --- a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/ClaimsHolder.java +++ /dev/null @@ -1,64 +0,0 @@ -package dev.relism.flash.ext.auth; - -import java.util.Map; - -/** - * The current request's claims, published by {@link AuthMiddleware} before the handler runs and - * cleared in a {@code finally} afterwards. - * - *

    Safe with virtual threads: each request gets its own, so a {@link ThreadLocal} is naturally - * isolated per request. - * - *

    Writing is deliberately not public. A {@link CredentialSource} returns claims and the - * middleware publishes them, so no code outside this module can put claims on a request that did - * not carry them. - * - *

    {@code
    - * // Inside any handler behind @Authenticated or @RolesAllowed:
    - * Claims caller = ClaimsHolder.current();
    - * String email  = caller.email();
    - * List roles = caller.roles("realm_access.roles");
    - *
    - * // Raw escape hatch:
    - * Map all = ClaimsHolder.map();
    - * }
    - */ -public final class ClaimsHolder { - - private static final ThreadLocal> HOLDER = new ThreadLocal<>(); - - private ClaimsHolder() {} - - /** Called by {@link AuthMiddleware} once a source has authenticated the request. */ - static void set(Map claims) { - HOLDER.set(claims); - } - - /** Called by {@link AuthMiddleware} in the {@code finally} block. */ - static void clear() { - HOLDER.remove(); - } - - /** - * A typed view of the current request's claims, or {@code null} when the route carries no - * authentication middleware or the caller is anonymous under - * {@link Authenticated}{@code (optional = true)}. - */ - public static Claims current() { - Map claims = HOLDER.get(); - return claims != null ? new Claims(claims) : null; - } - - /** The raw claims map for the current request, or {@code null}. @see #current() */ - public static Map map() { - return HOLDER.get(); - } - - /** A single claim as a String, or {@code null} when absent or the caller is anonymous. */ - public static String claim(String key) { - Map claims = HOLDER.get(); - if (claims == null) return null; - Object v = claims.get(key); - return v != null ? v.toString() : null; - } -} diff --git a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/CredentialSource.java b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/CredentialSource.java deleted file mode 100644 index 4ceb2f5..0000000 --- a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/CredentialSource.java +++ /dev/null @@ -1,48 +0,0 @@ -package dev.relism.flash.ext.auth; - -import dev.relism.flash.exceptions.HttpException; -import dev.relism.flash.models.Request; -import dev.relism.flash.models.Response; - -import java.util.Map; - -/** - * Turns whatever a request carries — a bearer token, a session cookie, an API key — into the - * claims authorization runs on. One is installed per authentication mechanism; - * {@code flash-ext-auth-oidc} contributes the OpenID Connect one. - * - *

    Implementations never touch {@link ClaimsHolder}: they produce claims and {@link - * AuthMiddleware} publishes them for the duration of the request. Nothing outside this module can - * inject claims into a request, which is the point. - */ -public interface CredentialSource { - - /** - * Resolves the caller's claims, rejecting the request when it cannot. - * - *

    Three outcomes, and the difference between the last two matters: - *

      - *
    • claims — the caller presented a valid credential;
    • - *
    • {@code null} — no credential was presented and this source has already answered the - * request itself (typically a redirect into a sign-in flow). The middleware stops and - * writes nothing more;
    • - *
    • {@link HttpException} — a credential was presented and is invalid. The source - * sets any challenge header it owes the caller before throwing.
    • - *
    - */ - Map authenticate(Request req, Response res); - - /** - * Resolves claims without ever rejecting: {@code null} when no valid credential is present. - * Backs {@link Authenticated}{@code (optional = true)}, where an anonymous caller is a normal - * outcome rather than a failure. - */ - Map peek(Request req); - - /** - * The {@code WWW-Authenticate} value to send with a 403 caused by missing scopes, or - * {@code null} when this source has no such concept. Only consulted after authentication has - * already succeeded. - */ - default String insufficientScopeChallenge(String[] requiredScopes) { return null; } -} 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 deleted file mode 100644 index eff0fa1..0000000 --- a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/InMemorySessionStore.java +++ /dev/null @@ -1,20 +0,0 @@ -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/RolesAllowed.java b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/RolesAllowed.java deleted file mode 100644 index b37020d..0000000 --- a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/RolesAllowed.java +++ /dev/null @@ -1,31 +0,0 @@ -package dev.relism.flash.ext.auth; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * Restricts a handler to callers holding at least one of the named roles. Authentication is - * implied — there is no need to combine it with {@link Authenticated}. - * - *

    Roles are read from the claim path the installed credential source is configured with - * (Keycloak's is {@code realm_access.roles}; many providers use a flat {@code roles} or - * {@code groups}). Nested paths use dot notation. - * - *

    {@code
    - * @Route(method = HttpMethod.DELETE, path = "/api/admin/blogs/{id}")
    - * @RolesAllowed("admin")
    - * public class DeleteBlog extends JacksonHandler { ... }
    - *
    - * // Multiple accepted roles (OR semantics — any one is sufficient):
    - * @RolesAllowed({"admin", "editor"})
    - * public class UpdateBlog extends JacksonHandler { ... }
    - * }
    - */ -@Retention(RetentionPolicy.RUNTIME) -@Target(ElementType.TYPE) -public @interface RolesAllowed { - /** One or more role names. Access is granted if the caller has any of them. */ - String[] value(); -} diff --git a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/ScopesAllowed.java b/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/ScopesAllowed.java deleted file mode 100644 index 40cd8c5..0000000 --- a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/ScopesAllowed.java +++ /dev/null @@ -1,45 +0,0 @@ -package dev.relism.flash.ext.auth; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -/** - * Restricts a handler to callers whose credential carries the required scopes. - * Authentication is implicitly required. - * - *

    Scopes are resolved from the configured claim paths in - * {@link AuthConfig#scopeClaimPaths()} (default: {@code "scope,scp"}) and support - * both standard formats: - *

      - *
    • {@code scope}: space-separated string
    • - *
    • {@code scp}: string list (or string)
    • - *
    - * - *
    {@code
    - * @Route(method = HttpMethod.GET, path = "/api/orders")
    - * @ScopesAllowed("orders:read")
    - * public class ListOrders extends JacksonHandler { ... }
    - *
    - * @Route(method = HttpMethod.POST, path = "/api/orders")
    - * @ScopesAllowed(value = {"orders:write", "payments:write"}, match = ScopesAllowed.Match.ANY)
    - * public class CreateOrder extends JacksonHandler { ... }
    - * }
    - */ -@Retention(RetentionPolicy.RUNTIME) -@Target(ElementType.TYPE) -public @interface ScopesAllowed { - /** Required scopes. */ - String[] value(); - - /** Matching mode for {@link #value()}. */ - Match match() default Match.ALL; - - enum Match { - /** Any one required scope is sufficient. */ - ANY, - /** All required scopes must be present. */ - ALL - } -} 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 deleted file mode 100644 index 0a77824..0000000 --- a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/Session.java +++ /dev/null @@ -1,53 +0,0 @@ -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 deleted file mode 100644 index 2e0a058..0000000 --- a/flash-extensions/flash-ext-auth-core/src/main/java/dev/relism/flash/ext/auth/SessionStore.java +++ /dev/null @@ -1,13 +0,0 @@ -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/AuthPolicyTest.java b/flash-extensions/flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/AuthPolicyTest.java deleted file mode 100644 index a2e8139..0000000 --- a/flash-extensions/flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/AuthPolicyTest.java +++ /dev/null @@ -1,94 +0,0 @@ -package dev.relism.flash.ext.auth; - -import org.junit.jupiter.api.Test; - -import java.util.List; - -import static org.junit.jupiter.api.Assertions.*; - -class AuthPolicyTest { - - static class PlainHandler {} - - @Authenticated - static class AuthenticatedHandler {} - - @Authenticated(optional = true) - static class OptionalHandler {} - - @RolesAllowed({"admin", " editor ", "admin"}) - static class RolesHandler {} - - @ScopesAllowed(value = {"orders:write", " payments:write ", "orders:write"}, match = ScopesAllowed.Match.ANY) - static class ScopesHandler {} - - @Authenticated - @RolesAllowed("admin") - @ScopesAllowed(value = {"orders:read", "payments:read"}, match = ScopesAllowed.Match.ALL) - static class CombinedHandler {} - - @Authenticated(optional = true) - @ScopesAllowed("orders:read") - static class InvalidOptionalHandler {} - - @Test - void compileFromAnnotations_noSecurityAnnotations_returnsNull() { - assertNull(AuthPolicy.compileFromAnnotations(PlainHandler.class)); - } - - @Test - void compileFromAnnotations_authenticated_createsRequiredAuthPolicy() { - AuthPolicy policy = AuthPolicy.compileFromAnnotations(AuthenticatedHandler.class); - assertNotNull(policy); - assertFalse(policy.optionalAuth()); - assertEquals(0, policy.requiredRoles().length); - assertEquals(0, policy.requiredScopes().length); - } - - @Test - void compileFromAnnotations_optionalAuth_createsOptionalPolicy() { - AuthPolicy policy = AuthPolicy.compileFromAnnotations(OptionalHandler.class); - assertNotNull(policy); - assertTrue(policy.optionalAuth()); - } - - @Test - void compileFromAnnotations_rolesAndScopes_areNormalizedAndMerged() { - AuthPolicy policy = AuthPolicy.compileFromAnnotations(CombinedHandler.class); - assertNotNull(policy); - assertFalse(policy.optionalAuth()); - assertArrayEquals(new String[]{"admin"}, policy.requiredRoles()); - assertArrayEquals(new String[]{"orders:read", "payments:read"}, policy.requiredScopes()); - assertEquals(ScopesAllowed.Match.ALL, policy.scopeMatch()); - } - - @Test - void compileFromAnnotations_scopesAny_preservesMatchModeAndDedupes() { - AuthPolicy policy = AuthPolicy.compileFromAnnotations(ScopesHandler.class); - assertNotNull(policy); - assertArrayEquals(new String[]{"orders:write", "payments:write"}, policy.requiredScopes()); - assertEquals(ScopesAllowed.Match.ANY, policy.scopeMatch()); - } - - @Test - void compileFromAnnotations_optionalCannotBeCombinedWithConstraints() { - assertThrows(IllegalStateException.class, - () -> AuthPolicy.compileFromAnnotations(InvalidOptionalHandler.class)); - } - - @Test - void openApiScopesFor_returnsScopesWhenPresent() { - assertEquals(List.of("orders:write", "payments:write"), - AuthPolicy.openApiScopesFor(ScopesHandler.class)); - } - - @Test - void openApiScopesFor_rolesOnly_returnsEmptyList() { - assertEquals(List.of(), AuthPolicy.openApiScopesFor(RolesHandler.class)); - } - - @Test - void openApiScopesFor_noSecurity_returnsNull() { - assertNull(AuthPolicy.openApiScopesFor(PlainHandler.class)); - } -} diff --git a/flash-extensions/flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/ClaimMatchingTest.java b/flash-extensions/flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/ClaimMatchingTest.java deleted file mode 100644 index f74f63f..0000000 --- a/flash-extensions/flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/ClaimMatchingTest.java +++ /dev/null @@ -1,209 +0,0 @@ -package dev.relism.flash.ext.auth; - -import org.junit.jupiter.api.Test; - -import java.util.Arrays; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** - * Characterisation tests for claim matching — the part of authorization that has nothing to do - * with OIDC: given a claims map, does the caller hold a role or a scope. - * - *

    Written to pin the current behaviour, including the edges that are easy to change by - * accident: which characters separate scopes in a string claim, whether a list entry is trimmed - * before comparison, what an empty requirement means under each match mode, and how a claim path - * that walks into a non-map resolves. Every assertion here reflects what the code does today, not - * what it arguably should do. - */ -class ClaimMatchingTest { - - /** No credential source: every assertion here is about claims that are already resolved. */ - private static AuthMiddleware middleware(String rolesPath, String scopePaths) { - return new AuthMiddleware(AuthConfig.builder() - .rolesClaimPath(rolesPath) - .scopeClaimPaths(scopePaths) - .build(), null); - } - - private static AuthMiddleware middleware() { - return middleware("realm_access.roles", "scope,scp"); - } - - // ── Claim path traversal ───────────────────────────────────────────────── - - @Test - void aPathWalksNestedMaps() { - Map claims = Map.of("a", Map.of("b", Map.of("c", List.of("x")))); - assertTrue(middleware("a.b.c", "scope").rolesAllowed(claims, new String[]{"x"})); - } - - @Test - void aPathThatWalksIntoANonMapResolvesToNothing() { - // "a" is a string, so "a.b" has nowhere to go — not an error, just no match. - Map claims = Map.of("a", "not-a-map"); - assertFalse(middleware("a.b", "scope").rolesAllowed(claims, new String[]{"anything"})); - } - - @Test - void aMissingPathResolvesToNothing() { - assertFalse(middleware().rolesAllowed(Map.of("other", "value"), new String[]{"admin"})); - } - - @Test - void emptySegmentsInAPathAreSkipped() { - // "realm_access..roles" collapses to the same two segments. - Map claims = Map.of("realm_access", Map.of("roles", List.of("admin"))); - assertTrue(middleware("realm_access..roles", "scope").rolesAllowed(claims, new String[]{"admin"})); - } - - @Test - void segmentsAreTrimmed() { - Map claims = Map.of("realm_access", Map.of("roles", List.of("admin"))); - assertTrue(middleware(" realm_access . roles ", "scope").rolesAllowed(claims, new String[]{"admin"})); - } - - @Test - void aBlankRolesPathIsRejectedAtConstruction() { - assertThrows(IllegalStateException.class, () -> middleware(" ", "scope")); - } - - @Test - void aNullClaimValueResolvesToNothing() { - Map nested = new HashMap<>(); - nested.put("roles", null); - Map claims = Map.of("realm_access", nested); - assertFalse(middleware().rolesAllowed(claims, new String[]{"admin"})); - } - - // ── Roles: ANY semantics ───────────────────────────────────────────────── - - @Test - void anyOneOfTheRequiredRolesIsEnough() { - Map claims = Map.of("realm_access", Map.of("roles", List.of("user"))); - assertTrue(middleware().rolesAllowed(claims, new String[]{"admin", "user"})); - assertFalse(middleware().rolesAllowed(claims, new String[]{"admin", "ops"})); - } - - @Test - void requiringNoRoleAtAllMatchesNothing() { - // The loop never runs, so the answer is false even when the claim is present. - Map claims = Map.of("realm_access", Map.of("roles", List.of("admin"))); - assertFalse(middleware().rolesAllowed(claims, new String[0])); - } - - // ── What counts as "contains" ──────────────────────────────────────────── - - @Test - void aListClaimMatchesEntrywiseAndTrimsEachEntry() { - Map claims = Map.of("realm_access", Map.of("roles", List.of(" admin ", "user"))); - assertTrue(middleware().rolesAllowed(claims, new String[]{"admin"})); - } - - @Test - void aListEntryIsNeverSplitOnDelimiters() { - // Unlike a string claim, a list entry is compared whole: "a b" is one role named "a b". - Map claims = Map.of("realm_access", Map.of("roles", List.of("a b"))); - assertFalse(middleware().rolesAllowed(claims, new String[]{"a"})); - assertTrue(middleware().rolesAllowed(claims, new String[]{"a b"})); - } - - @Test - void nullEntriesInAListAreSkipped() { - Map claims = Map.of("realm_access", - Map.of("roles", Arrays.asList(null, "admin"))); - assertTrue(middleware().rolesAllowed(claims, new String[]{"admin"})); - } - - @Test - void anArrayClaimBehavesLikeAList() { - Map claims = Map.of("realm_access", - Map.of("roles", (Object) new String[]{"admin", "user"})); - assertTrue(middleware().rolesAllowed(claims, new String[]{"user"})); - } - - @Test - void aScalarClaimIsComparedWhole() { - Map claims = Map.of("realm_access", Map.of("roles", 42)); - assertTrue(middleware().rolesAllowed(claims, new String[]{"42"})); - } - - @Test - void aStringClaimIsSplitOnSpacesTabsNewlinesAndCommas() { - for (String separator : List.of(" ", "\t", "\n", "\r", ",")) { - Map claims = Map.of("realm_access", - Map.of("roles", "admin" + separator + "user")); - assertTrue(middleware().rolesAllowed(claims, new String[]{"user"}), - "separator " + separator.strip().isEmpty() + " should split the claim"); - } - } - - @Test - void aStringClaimDoesNotMatchAPrefixOrASubstring() { - Map claims = Map.of("realm_access", Map.of("roles", "administrator")); - assertFalse(middleware().rolesAllowed(claims, new String[]{"admin"})); - } - - @Test - void repeatedDelimitersProduceNoEmptyTokens() { - Map claims = Map.of("realm_access", Map.of("roles", " ,, admin ,, ")); - assertTrue(middleware().rolesAllowed(claims, new String[]{"admin"})); - } - - // ── Scopes: ALL vs ANY, across several claim paths ─────────────────────── - - @Test - void allRequiresEveryScope() { - Map claims = Map.of("scope", "openid orders:read"); - assertTrue(middleware().scopesAllowed(claims, new String[]{"openid", "orders:read"}, ScopesAllowed.Match.ALL)); - assertFalse(middleware().scopesAllowed(claims, new String[]{"openid", "orders:write"}, ScopesAllowed.Match.ALL)); - } - - @Test - void anyRequiresOne() { - Map claims = Map.of("scope", "openid"); - assertTrue(middleware().scopesAllowed(claims, new String[]{"nope", "openid"}, ScopesAllowed.Match.ANY)); - assertFalse(middleware().scopesAllowed(claims, new String[]{"nope", "neither"}, ScopesAllowed.Match.ANY)); - } - - @Test - void requiringNoScopeIsVacuouslyTrueUnderAllAndFalseUnderAny() { - // The asymmetry falls out of the loops and is load-bearing for @ScopesAllowed's validation, - // which rejects an empty value list before it can ever reach here. - Map claims = Map.of("scope", "openid"); - assertTrue(middleware().scopesAllowed(claims, new String[0], ScopesAllowed.Match.ALL)); - assertFalse(middleware().scopesAllowed(claims, new String[0], ScopesAllowed.Match.ANY)); - } - - @Test - void scopesAreLookedForInEveryConfiguredPathUntilOneMatches() { - AuthMiddleware mw = middleware("roles", "scope, scp , permissions.scopes"); - Map claims = Map.of( - "scp", List.of("payments:write"), - "permissions", Map.of("scopes", "orders:approve")); - - assertTrue(mw.scopesAllowed(claims, new String[]{"payments:write"}, ScopesAllowed.Match.ALL)); - assertTrue(mw.scopesAllowed(claims, new String[]{"orders:approve"}, ScopesAllowed.Match.ALL)); - // ALL is satisfied even when the two scopes come from different claims. - assertTrue(mw.scopesAllowed(claims, - new String[]{"payments:write", "orders:approve"}, ScopesAllowed.Match.ALL)); - } - - @Test - void blankScopePathsFallBackToScopeAndScp() { - AuthMiddleware mw = middleware("roles", " "); - assertTrue(mw.scopesAllowed(Map.of("scope", "a"), new String[]{"a"}, ScopesAllowed.Match.ALL)); - assertTrue(mw.scopesAllowed(Map.of("scp", "b"), new String[]{"b"}, ScopesAllowed.Match.ALL)); - } - - @Test - void aScopePathListOfOnlySeparatorsFallsBackToScopeAndScp() { - AuthMiddleware mw = middleware("roles", " , , "); - assertTrue(mw.scopesAllowed(Map.of("scp", "b"), new String[]{"b"}, ScopesAllowed.Match.ALL)); - } -} diff --git a/flash-extensions/flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/ClaimsScopesTest.java b/flash-extensions/flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/ClaimsScopesTest.java deleted file mode 100644 index 6e6fe1e..0000000 --- a/flash-extensions/flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/ClaimsScopesTest.java +++ /dev/null @@ -1,47 +0,0 @@ -package dev.relism.flash.ext.auth; - -import org.junit.jupiter.api.Test; - -import java.util.List; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.*; - -class ClaimsScopesTest { - - @Test - void scopes_readsStandardScopeString() { - Claims user = new Claims(Map.of("scope", "openid profile orders:read")); - - assertEquals(List.of("openid", "profile", "orders:read"), user.scopes()); - assertTrue(user.hasScope("orders:read")); - assertFalse(user.hasScope("orders:write")); - } - - @Test - void scopes_fallsBackToScpArray() { - Claims user = new Claims(Map.of("scp", List.of("orders:write", "payments:write"))); - - assertEquals(List.of("orders:write", "payments:write"), user.scopes()); - assertTrue(user.hasScope("payments:write")); - } - - @Test - void scopes_supportsCustomClaimPaths() { - Claims user = new Claims(Map.of("permissions", Map.of("scopes", List.of("a", "b")))); - - assertEquals(List.of("a", "b"), user.scopes("permissions.scopes")); - assertTrue(user.hasScope("permissions.scopes", "a")); - assertFalse(user.hasScope("permissions.scopes", "x")); - } - - @Test - void scopes_combinesMultipleClaimPathsInOrder() { - Claims user = new Claims(Map.of( - "scope", "openid", - "scp", List.of("profile", "orders:read") - )); - - assertEquals(List.of("openid", "profile", "orders:read"), user.scopes("scope,scp")); - } -} 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 deleted file mode 100644 index 6aef288..0000000 --- a/flash-extensions/flash-ext-auth-core/src/test/java/dev/relism/flash/ext/auth/SessionTest.java +++ /dev/null @@ -1,58 +0,0 @@ -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-auth-oidc/README.md b/flash-extensions/flash-ext-auth-oidc/README.md deleted file mode 100644 index 960d20e..0000000 --- a/flash-extensions/flash-ext-auth-oidc/README.md +++ /dev/null @@ -1,474 +0,0 @@ -# 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. - -Standards alignment focuses on OIDC Core + OAuth2 bearer APIs while preserving Flash's -hot-path model (middleware compiled at mount time, no heavy runtime work). - -This extension is the OpenID Connect **credential source** for -[`flash-ext-auth-core`](../flash-ext-auth-core/docs/README.md), which owns everything downstream of -identifying the caller. Shorter guides live in [`docs/`](docs/README.md), including -[migration notes](docs/interop.md#migrating-from-flash-ext-oidc) from `flash-ext-oidc`. - -## What it provides - -| Component | Description | -|---|---| -| `GET {prefix}/login` | Starts the OIDC flow: builds the authorization URL with PKCE + state, redirects | -| `GET {prefix}/callback` | Exchanges the code, validates the ID token, creates a session, redirects | -| `POST {prefix}/logout` | Invalidates the session, redirects to the provider's `end_session_endpoint` | -| `OidcCredentialSource` | The `CredentialSource` this extension contributes to `flash-ext-auth-core` | -| `JwtValidator` | JWKS-backed JWT validator (PKCE + key rotation + caching) | - -`@Authenticated`, `@RolesAllowed`, `@ScopesAllowed`, `AuthMiddleware`, `ClaimsHolder` and `Claims` -belong to [`flash-ext-auth-core`](../flash-ext-auth-core/docs/README.md) and work the same behind -any credential source. Installing this extension brings them in and wires them up — you do not -install auth-core yourself. - -## Dependencies - -```xml - - dev.relism - flash-ext-auth-oidc - 2.1.0-SNAPSHOT - -``` - -Transitive: `flash-ext-auth-core`, `nimbus-jose-jwt`, `json-smart`. -Optional: `flash-ext-openapi` — if present, OIDC security schemes are added to the OpenAPI spec automatically. - -## Installation - -```java -FlashApp.create(8080) - .install(new JacksonExtension()) - .install(new OpenApiExtension(...)) // optional — enables Swagger security - .install(new OidcExtension( - OidcConfig.builder( - "https://idp.example.com", - "my-client", "my-secret", "/auth/callback") - .build() - )) - .start(); -``` - -Install order is irrelevant. The two-phase extension model guarantees all services -(including `OpenApiSecurityRegistry` from `flash-ext-openapi`) are registered before -any extension's routes phase runs. - -### Keycloak shortcut - -```java -OidcConfig.keycloak( - "https://keycloak.example.com", // server URL (no realm) - "myrealm", // realm - "my-client", "my-secret", // client credentials - "/auth/callback") // redirect URI (server-relative) - .https() // behind TLS - .build() -``` - -`keycloak()` pre-sets `rolesClaimPath("realm_access.roles")` and constructs the issuer as -`{serverUrl}/realms/{realm}`. - -### Authelia / generic IdP - -```java -OidcConfig.builder("https://auth.example.com", "my-client", "secret", "/auth/callback") - .rolesClaimPath("groups") - .build() -``` - -## OidcConfig reference - -### Required fields - -| Field | Description | -|---|---| -| `issuer` | Provider base URL — also used for OIDC discovery | -| `clientId` | OAuth2 client ID | -| `clientSecret` | OAuth2 client secret | -| `redirectUri` | Callback URI; server-relative paths (starting with `/`) are resolved at request time | - -### Builder options - -| Method | Default | Description | -|---|---|---| -| `.scopes("openid profile email")` | `"openid profile email"` | Space-separated requested scopes | -| `.routePrefix("/auth")` | `"/auth"` | Prefix for login/callback/logout routes | -| `.selfScheme("http")` | `"http"` | Scheme used when resolving server-relative redirect URIs | -| `.https()` | — | Shorthand for `.selfScheme("https")` | -| `.rolesClaimPath("realm_access.roles")` | `"realm_access.roles"` | Dot-path to the roles array in JWT claims | -| `.scopeClaimPaths("scope,scp")` | `"scope,scp"` | Comma-separated claim paths used to resolve OAuth scopes | -| `.algorithm("RS256")` | `"RS256"` | JWS algorithm for token validation | -| `.postLogoutRedirectUri("/")` | `"/"` | Where to redirect after logout | -| `.sessionStore(store)` | `InMemorySessionStore` | Custom session store (see below) | -| `.clientAuthMethod(ClientAuthMethod.POST)` | `POST` | `POST` = credentials in body; `BASIC` = `Authorization: Basic` | -| `.insecureTls()` | `false` | Disables TLS certificate verification — **development only** | -| `.schemeName("myscheme")` | derived from issuer | OpenAPI security scheme name | - -### Environment variables (`OidcConfig.fromEnv()`) - -``` -OIDC_ISSUER required -OIDC_CLIENT_ID required -OIDC_CLIENT_SECRET required -OIDC_REDIRECT_URI required e.g. /auth/callback -OIDC_SCOPES default: openid profile email -OIDC_ROUTE_PREFIX default: /auth -OIDC_SELF_SCHEME default: http -OIDC_ROLES_CLAIM default: realm_access.roles -OIDC_SCOPE_CLAIMS default: scope,scp -OIDC_ALGORITHM default: RS256 -OIDC_POST_LOGOUT_REDIRECT default: / -OIDC_CLIENT_AUTH_METHOD default: POST -``` - -## Protecting routes - -### Class-based handlers (annotations) - -```java -@Route(method = HttpMethod.GET, path = "/me") -@Authenticated -public class MePage extends JacksonHandler { - @Override - public Object handle(Request req, Response res) { - Claims u = ClaimsHolder.current(); - return json(res, Map.of("sub", u.sub(), "email", u.email())); - } -} - -@Route(method = HttpMethod.GET, path = "/admin") -@RolesAllowed("admin") // OR semantics: "admin" OR "superuser" -// @RolesAllowed({"admin", "superuser"}) -public class AdminPage extends JacksonHandler { ... } - -@Route(method = HttpMethod.POST, path = "/orders") -@ScopesAllowed("orders:write") // default = ALL semantics -public class CreateOrder extends JacksonHandler { ... } - -@Route(method = HttpMethod.POST, path = "/payments") -@ScopesAllowed(value = {"payments:write", "payments:admin"}, match = ScopesAllowed.Match.ANY) -public class PayOrder extends JacksonHandler { ... } - -@Route(method = HttpMethod.DELETE, path = "/admin/users/{id}") -@RolesAllowed("admin") -@ScopesAllowed("users:delete") // combined with AND semantics -public class DeleteUser extends JacksonHandler { ... } -``` - -The middleware is injected automatically by the annotation processor — no manual wiring needed. - -Annotation composition rules: - -- `@Authenticated` requires auth only -- `@RolesAllowed` implies authentication + role OR-check -- `@ScopesAllowed` implies authentication + scope check (`ALL`/`ANY`) -- combining `@RolesAllowed` + `@ScopesAllowed` uses AND semantics -- `@Authenticated(optional = true)` cannot be combined with role/scope constraints - -### Lambda routes (manual middleware) - -For lambda routes, pass the middleware as a varargs argument. Retrieve `AuthMiddleware` -from the context inside another extension's `routes()` phase, or after `start()`: - -```java -AuthMiddleware auth = app.ctx().require(AuthMiddleware.class); - -// Authentication only -app.get("/api/me", (req, res) -> { - Claims u = ClaimsHolder.current(); // never null here - return Map.of("sub", u.sub(), "email", u.email()); -}, auth.protect()); - -// Authentication + role check -app.delete("/api/admin/users/{id}", (req, res) -> { - Claims u = ClaimsHolder.current(); - // ... -}, auth.requireRole("admin")); - -// Multiple roles (OR): passes if user holds any one of them -app.get("/api/reports", (req, res) -> { ... }, auth.requireRole("admin", "reports-viewer")); - -// Require all listed scopes -app.post("/api/orders", (req, res) -> { ... }, auth.requireScopes("orders:write", "payments:write")); - -// Require at least one listed scope -app.post("/api/payments", (req, res) -> { ... }, auth.requireAnyScope("payments:write", "payments:admin")); -``` - -`auth.protect()` / `auth.requireRole(...)` / `auth.requireScopes(...)` return a `Middleware` — a composable -`Handler → Handler` wrapper. Flash applies middleware right-to-left so the authentication check -runs before your handler. - -## Accessing the authenticated user - -`ClaimsHolder` holds the JWT claims for the current request in a `ThreadLocal`. -It is populated by `AuthMiddleware` — from `flash-ext-auth-core` — once this source has -authenticated the request, and cleared in the `finally` block afterward. Nothing outside that -module can write to it. It is safe with virtual threads (each request gets its -own virtual thread, so `ThreadLocal` values are naturally isolated). - -### Claims (preferred) - -```java -Claims u = ClaimsHolder.current(); // never null inside a protected handler - -String sub = u.sub(); // unique user ID -String email = u.email(); -String username = u.username(); // preferred_username -String name = u.name(); // full display name - -// Roles — pass the dot-path matching your provider's claim structure -List roles = u.roles("realm_access.roles"); // Keycloak realm roles -List clientRoles = u.roles("resource_access.my-client.roles"); // Keycloak client roles -List groups = u.roles("groups"); // Authelia - -boolean isAdmin = u.hasRole("realm_access.roles", "admin"); - -// Scopes (OIDC/OAuth2 generic): checks "scope" then "scp" -List scopes = u.scopes(); -boolean canWrite = u.hasScope("orders:write"); - -// Custom claim path resolution (for provider-specific payloads) -List customScopes = u.scopes("scope,scp,permissions.scopes"); -boolean canApprove = u.hasScope("permissions.scopes", "orders:approve"); - -// Arbitrary claim -String locale = (String) u.claim("locale"); -Long exp = u.claim("exp", Long.class); - -// Full raw map (escape hatch) -Map all = u.claims(); -``` - -### Raw access (escape hatch) - -```java -Map claims = ClaimsHolder.map(); -String email = ClaimsHolder.claim("email"); -``` - -## Performance - -The middleware adds negligible overhead on the hot path for authenticated requests: - -| Step | Cost | -|---|---| -| `Authorization` header check | `O(1)` map lookup | -| Cookie parse | `O(cookie_length)` single pass scan | -| Session lookup | `O(1)` `ConcurrentHashMap.get()` | -| Token expiry check | `O(1)` `Instant` comparison | -| `ClaimsHolder.set()` | `O(1)` `ThreadLocal.set()` | - -No network calls, no cryptography, no JSON parsing on the happy path (valid session). -JWKS key fetching only happens for Bearer token validation and is cached + rate-limited by -Nimbus's `JWKSourceBuilder`. Silent token refresh only triggers when the access token expires. - -Role/scope claim paths are compiled once during middleware construction (mount time), not per request. - -## Authentication flow details - -On each request the middleware resolves credentials in this order: - -1. **Bearer token** (`Authorization: Bearer `) — validated against JWKS. -2. **Session cookie** (`oidc_session`) — looked up in the session store; transparently - refreshed if the access token is expired (silent refresh via refresh token). -3. **No valid credentials**: - - Browser clients (no `Accept: application/json`) → redirect to `{prefix}/login?redirect={path}` - - API clients → `401 Unauthorized` - -### API error semantics (RFC 6750) - -For API clients (`Accept: application/json`) the middleware includes `WWW-Authenticate`: - -- missing credentials: `Bearer realm=""` -- invalid bearer token: `Bearer realm="", error="invalid_token"` -- insufficient scopes: `Bearer realm="", error="insufficient_scope", scope=""` - -This enables interoperable client-side handling and proper OAuth2 challenge semantics. - -### Token validation (OIDC Core §3.1.3.7) - -| Check | Access token | ID token | -|---|---|---| -| Signature (JWKS) | yes | yes | -| `iss` | yes | yes | -| `aud` = clientId | no (varies by provider) | yes | -| `exp`, `iat`, `sub` | yes | yes | -| `nonce` | — | yes | - -JWKS keys are cached, rate-limited, and retried on cache-miss (handles key rotation). - -### Claim merge strategy - -At callback time the extension merges access token + ID token claims: - -- Access token claims first (contains provider-specific data like `realm_access.roles`) -- ID token claims override (contains verified identity: `sub`, `email`, `name`, …) - -This is provider-agnostic: authorization claims live in the AT per RFC 9068, -identity claims live in the IT per OIDC Core. - -## Standards & compliance notes - -This extension is designed to be compliant with the most relevant OIDC/OAuth2 RFCs: - -- RFC 8414 (Authorization Server Metadata): discovery via `/.well-known/openid-configuration` -- OpenID Connect Core 1.0: Authorization Code flow + PKCE + `nonce` validation on ID token -- RFC 7636 (PKCE): S256 challenge/verifier flow -- RFC 6750 (Bearer Token Usage): `WWW-Authenticate` challenges with standard error codes -- RFC 9068 (JWT Profile for Access Tokens): JWT bearer access-token validation path -- RFC 7519 / RFC 7517 / RFC 7515 family: JWT/JWK/JWS validation via Nimbus + JWKS caching/rotation - -Provider interoperability details: - -- scope extraction supports both standard forms: `scope` (space-delimited string) and `scp` (list/string) -- roles remain configurable via `rolesClaimPath` (`realm_access.roles`, `groups`, etc.) -- scope claim fallback chain is configurable via `scopeClaimPaths` - -## Testing scopes with Keycloak - -Quick path to test `@ScopesAllowed` end-to-end: - -1. **Create a client scope** - - Realm -> Client scopes -> Create - - Name: `orders:write` (or any scope name you want to enforce) -2. **Attach it to your client** - - Clients -> `` -> Client scopes - - Add the scope as `Default` (always in token) or `Optional` (requested via `scope` param) -3. **Ensure scope mapper reaches the token** - - For most Keycloak setups this is automatic via built-in `microprofile-jwt`/scope mappers - - Verify the access token contains either `scope` string or `scp` list -4. **Request the scope in Flash config** - - Include it in `OidcConfig.scopes(...)`, e.g. `"openid profile email orders:write"` -5. **Protect a handler** - - `@ScopesAllowed("orders:write")` on class-based handlers - - or `auth.requireScopes("orders:write")` for lambda routes -6. **Verify behavior** - - token with scope -> 200 - - token without scope -> 403 + `WWW-Authenticate: ... insufficient_scope` - -Useful token inspection flow while testing: - -- Obtain a token from Keycloak -- Decode payload (`jwt.io` or local tool) -- check `scope` / `scp` claims -- call your protected endpoint and inspect status + `WWW-Authenticate` - -## Session store - -Sessions live in `flash-ext-auth-core`'s `Session`/`SessionStore`; this extension keeps its -access, id and refresh tokens in `Session.attributes()` under its own keys, so renewal stays here -and core carries no OAuth2 vocabulary. See -[`../flash-ext-auth-core/docs/sessions.md`](../flash-ext-auth-core/docs/sessions.md). - -The default `InMemorySessionStore` is sufficient for single-instance deployments. -For clustered deployments, implement `SessionStore`: - -```java -public interface SessionStore { - void save(Session session); - Optional find(String sessionId); - void delete(String sessionId); -} -``` - -```java -OidcConfig.builder(...) - .sessionStore(new RedisSessionStore(redisClient)) - .build() -``` - -`Session` fields: `id`, `accessToken`, `idToken`, `refreshToken`, `expiresAt` (`Instant`), `claims` (merged map). - -## Logout - -Add a logout button anywhere in your UI — a `

    ` is sufficient (no JavaScript needed): - -```html - - -
    -``` - -The `POST {prefix}/logout` handler: -1. Reads the `oidc_session` cookie, looks up the session, retrieves the `id_token`. -2. Deletes the local session and clears the cookie (`Max-Age=0`). -3. If the provider has an `end_session_endpoint` (standard IdPs do), redirects there with - `?id_token_hint=&post_logout_redirect_uri=` — this logs - the user out of the IdP as well. -4. Otherwise redirects to `postLogoutRedirectUri` (default: `/`). - -## Bearer token (API clients) - -For API-to-API or SPA-to-API calls, pass a Bearer access token directly. The middleware -validates the JWT signature against JWKS and extracts the claims — no session involved: - -``` -Authorization: Bearer -``` - -The token must be a JWT (opaque tokens are not supported). Claims are available via -`ClaimsHolder.current()` as usual. - -## Multi-tenant - -Multiple OIDC providers on one server — each `OidcExtension` instance is fully independent -(its own PKCE state store, session store, validator, and middleware): - -```java -OidcConfig tenantA = OidcConfig.builder("https://idp/realms/a", "clientA", "secretA", "/a/auth/callback") - .routePrefix("/a/auth").schemeName("tenantA").build(); - -OidcConfig tenantB = OidcConfig.builder("https://idp/realms/b", "clientB", "secretB", "/b/auth/callback") - .routePrefix("/b/auth").schemeName("tenantB").build(); - -app.install(new OidcExtension(tenantA)) - .install(new OidcExtension(tenantB)); -``` - -To reference a specific tenant's middleware on lambda routes, keep the extension instances -and retrieve `AuthMiddleware` from context after `start()`: - -```java -OidcExtension extA = new OidcExtension(tenantA); -OidcExtension extB = new OidcExtension(tenantB); - -FlashApp app = FlashApp.create(8080) - .install(extA) - .install(extB) - .start() - .join(); // wait for bind - -AuthMiddleware mwA = app.ctx().require(AuthMiddleware.class); // last registered = tenantB -``` - -> **Note:** because both extensions register `AuthMiddleware.class` in the same context, -> only the last one wins under that key. For multi-tenant setups, use distinct context -> keys or provide middleware under a wrapper/alias type, or use lambda routes with explicit -> middleware captured from the extension instance before `install()`. - -Class-based handlers annotated with `@Authenticated` / `@RolesAllowed` get the last -registered processor's middleware. For true multi-tenant class-based routing, install -tenant-specific annotation processors with different annotations. - -## OpenAPI integration - -If `flash-ext-openapi` is on the classpath and installed (order irrelevant), -the extension automatically: - -- Adds a `components.securitySchemes` entry for the provider (OAuth2, authorizationCode flow) -- Adds `security` requirements to every operation whose handler carries `@Authenticated` - , `@RolesAllowed`, or `@ScopesAllowed` - -No extra code needed. To customize the scheme name: - -```java -OidcConfig.builder(...).schemeName("keycloak").build() -``` - -If `flash-ext-openapi` is absent the integration is silently skipped. diff --git a/flash-extensions/flash-ext-auth-oidc/docs/README.md b/flash-extensions/flash-ext-auth-oidc/docs/README.md deleted file mode 100644 index 5a64011..0000000 --- a/flash-extensions/flash-ext-auth-oidc/docs/README.md +++ /dev/null @@ -1,98 +0,0 @@ -# flash-ext-auth-oidc - -OpenID Connect for Flash: the authorization-code flow with PKCE, JWKS-validated bearer tokens, -server-side sessions with silent refresh, and single logout. - -It is a **credential source** for [`flash-ext-auth-core`](../../flash-ext-auth-core/docs/README.md), -which owns everything downstream of "who is this caller" — `@Authenticated`, `@RolesAllowed`, -`@ScopesAllowed`, `ClaimsHolder`. Installing this extension installs that machinery too; you do not -install `flash-ext-auth-core` yourself. - -## Quick start - -```java -app.install(new OidcExtension( - OidcConfig.builder( - "https://keycloak.example.com/realms/myrealm", - "my-app", "secret", "/auth/callback") - .rolesClaimPath("realm_access.roles") - .https() - .build())); -``` - -That is the whole integration. Discovery runs at boot and fails fast if the issuer is unreachable, -so a misconfigured provider is a startup crash rather than a 500 on the first login. - -`OidcConfig.fromEnv()` reads the same settings from `OIDC_*` environment variables, and -`OidcConfig.keycloak(serverUrl, realm, ...)` builds the issuer URL for you. - -## What it registers - -| | | -|---|---| -| `GET {prefix}/login` | Builds the authorization URL with PKCE + state and redirects | -| `GET {prefix}/callback` | Validates state and nonce, exchanges the code, creates the session | -| `POST {prefix}/logout` | Ends the session and redirects to the provider's end-session endpoint | - -`{prefix}` is `routePrefix` (default `/auth`). Logout is a `POST` on purpose — a `GET` logout is -one `` tag away from being triggered by any page the user visits. - -In the context it provides `AuthMiddleware` (from auth-core), `OidcCredentialSource` and -`JwtValidator`. - -## How a request is resolved - -1. `Authorization: Bearer …` — validated against the issuer's JWKS. -2. `oidc_session` cookie — looked up in the `SessionStore`; if the access token has expired and a - refresh token is present, refreshed transparently and the session replaced. -3. Neither, and the client sent `Accept: application/json` → `401` with a - `WWW-Authenticate: Bearer` challenge. -4. Neither, and it looks like a browser → redirect to `{prefix}/login?redirect={path}`. - -Points 3 and 4 are why the source distinguishes "no credential" from "bad credential": an API -client must not be redirected into an HTML sign-in page, and a browser must not be left staring at -a bare 401. - -## Configuration - -| Setting | Default | Notes | -|---|---|---| -| `issuer`, `clientId`, `clientSecret`, `redirectUri` | — | required | -| `scopes` | `openid profile email` | | -| `routePrefix` | `/auth` | | -| `selfScheme` | `http` | `https()` behind TLS; only used when no `X-Forwarded-Proto` | -| `rolesClaimPath` | `realm_access.roles` | Keycloak's spelling; `groups` for Authelia | -| `scopeClaimPaths` | `scope,scp` | comma-separated, tried in order | -| `algorithm` | `RS256` | | -| `postLogoutRedirectUri` | `/` | | -| `sessionStore` | `InMemorySessionStore` | swap for Redis/JDBC across instances | -| `clientAuthMethod` | `POST` | token endpoint client authentication | -| `insecureTls()` | off | dev only, skips certificate validation | -| `schemeName` | derived from the issuer | OpenAPI security scheme name | - -A relative `redirectUri` (starting with `/`) is resolved per request against the incoming `Host`, -or `X-Forwarded-Host`/`-Proto` when behind a proxy — so one build works in dev and behind TLS -without a second configuration. - -## Sessions - -A session holds the claims plus the access, id and refresh tokens, the last three in -`Session.attributes()` under this extension's own keys. Core never reads them; renewal happens -here. See [`../../flash-ext-auth-core/docs/sessions.md`](../../flash-ext-auth-core/docs/sessions.md). - -## Multiple providers - -Two issuers on one server, each with its own route prefix: - -```java -app.install(new OidcExtension(tenantAConfig)) // routePrefix("/tenantA/auth") - .install(new OidcExtension(tenantBConfig)); // routePrefix("/tenantB/auth") -``` - -Both are known at boot. Registering an issuer at runtime — a customer connecting their own IdP from -a settings page — is not supported. - -## Interop - -See [`interop.md`](interop.md) for how this extension fits with `flash-ext-auth-core`, -`flash-ext-openapi` and `flash-ext-mcp`. diff --git a/flash-extensions/flash-ext-auth-oidc/docs/interop.md b/flash-extensions/flash-ext-auth-oidc/docs/interop.md deleted file mode 100644 index d0970ea..0000000 --- a/flash-extensions/flash-ext-auth-oidc/docs/interop.md +++ /dev/null @@ -1,88 +0,0 @@ -# Interop - -## flash-ext-auth-core - -A hard dependency, and the reason this extension is as small as it is. The division: - -| Here | `flash-ext-auth-core` | -|---|---| -| Discovery, JWKS, PKCE, token endpoint | `@Authenticated`, `@RolesAllowed`, `@ScopesAllowed` | -| `/login`, `/callback`, `/logout` | `ClaimsHolder`, `Claims` | -| Bearer and cookie resolution, refresh | Role and scope matching | -| RFC 6750 `WWW-Authenticate` challenges | `Session`, `SessionStore` | - -`OidcExtension` builds an `OidcCredentialSource`, hands it to `AuthMiddleware.install(...)`, and -that publishes the middleware and registers the annotation processor. Everything a handler -annotation does is core's code running against claims this extension produced. - -Consequence worth knowing: `@RolesAllowed` is not OIDC-specific and never was. An app that swaps -this extension for another credential source keeps every annotation it had. - -## flash-ext-openapi - -Optional, and resolved lazily so this extension runs standalone when openapi is not on the -classpath. When it is, an `OpenApiContributor` is registered that emits an `oauth2` security scheme -with the `authorizationCode` flow, filled in from the discovery document: - -```json -"securitySchemes": { - "myrealm": { - "type": "oauth2", - "flows": { "authorizationCode": { "authorizationUrl": "…", "tokenUrl": "…", "scopes": {…} } } - } -} -``` - -Per-operation security comes from the same annotations the middleware reads, so the spec and the -enforcement cannot drift: both call `AuthPolicy.compileFromAnnotations`. - -The scheme name is `schemeName`, derived from the last path segment of the issuer (a Keycloak realm -name, usually) unless set explicitly. - -## flash-ext-mcp - -`McpSecurity` asks whether **this** extension is installed — `ctx.find(OidcCredentialSource.class)` -— and not merely whether something authenticates: - -| Policy | this extension installed | absent | -|---|---|---| -| `REQUIRED` | protected | **boot fails** | -| `AUTO` | protected | unprotected, warning logged | -| `NONE` | never protected | unprotected | - -That distinction is deliberate. `REQUIRED` means "a real OAuth2 authorization server is protecting -this endpoint", because everything it turns on — RFC 9728 Protected Resource Metadata, RFC 8707 -audience binding, `WWW-Authenticate` challenges carrying `resource_metadata` — is meaningless -without an issuer. An app that authenticates some other way must not satisfy it by accident. - -When it is installed, `McpOidcIntegration` derives the whole resource-server configuration from the -source with no extra `McpConfig` calls: - -- the MCP route is wrapped with `authMw.withSource(source.withResourceMetadata(path)).protect()` — - the same validation every other route uses, plus the `resource_metadata` challenge parameter; -- an audience guard runs after it and rejects any token whose `aud` does not include this - endpoint's resource identifier; -- the resource identifier is resolved per request from `X-Forwarded-Host`/`-Proto`, or the `Host` - header and `selfScheme`. - -An app that does **not** use OAuth2 can still guard `/mcp`: set `McpSecurity.NONE` and pass its own -guard to `McpConfig.middleware(...)`. - -## Migrating from flash-ext-oidc - -The module was renamed and its generic half moved. Mechanically: - -| Was | Now | -|---|---| -| `flash-ext-oidc` (artifact) | `flash-ext-auth-oidc` | -| `dev.relism.flash.ext.oidc.Authenticated` (and `RolesAllowed`, `ScopesAllowed`) | `dev.relism.flash.ext.auth.…` | -| `OidcMiddleware` | `AuthMiddleware` (`dev.relism.flash.ext.auth`) | -| `ctx.find(OidcMiddleware.class)` | `ctx.find(AuthMiddleware.class)` | -| `OidcUser` | `Claims` | -| `ClaimsHolder.user()` | `ClaimsHolder.current()` | -| `ClaimsHolder.get()` | `ClaimsHolder.map()` | -| `OidcSession`, `OidcSessionStore`, `InMemoryOidcSessionStore` | `Session`, `SessionStore`, `InMemorySessionStore` | -| `session.isAccessTokenExpired()` | `session.isExpired()` | -| `session.idToken()` | `session.attributeAsString(OidcCredentialSource.ID_TOKEN)` | - -`OidcConfig`, `OidcExtension` and every setting on them are unchanged. diff --git a/flash-extensions/flash-ext-auth-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 deleted file mode 100644 index 337039f..0000000 --- a/flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/ClientAuthMethod.java +++ /dev/null @@ -1,18 +0,0 @@ -package dev.relism.flash.ext.oidc; - -/** - * OAuth2 client authentication method for the token endpoint (RFC 6749 §2.3). - * - *
      - *
    • {@link #POST} — credentials sent as {@code client_id} / {@code client_secret} - * form fields (default; most providers).
    • - *
    • {@link #BASIC} — credentials sent as an {@code Authorization: Basic} header; - * body contains only grant-specific parameters.
    • - *
    - */ -public enum ClientAuthMethod { - /** {@code client_secret_post} — credentials in the request body. */ - POST, - /** {@code client_secret_basic} — credentials in the {@code Authorization} header. */ - BASIC -} diff --git a/flash-extensions/flash-ext-auth-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 deleted file mode 100644 index 40b6b0c..0000000 --- a/flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/DiscoveryClient.java +++ /dev/null @@ -1,50 +0,0 @@ -package dev.relism.flash.ext.oidc; - -import net.minidev.json.JSONValue; - -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.util.Map; - -/** - * Fetches and parses the OIDC provider discovery document at - * {@code {issuer}/.well-known/openid-configuration}. - */ -final class DiscoveryClient { - - private DiscoveryClient() {} - - static OidcProviderMetadata fetch(String issuer, HttpClient http) throws Exception { - String url = issuer.endsWith("/") - ? issuer + ".well-known/openid-configuration" - : issuer + "/.well-known/openid-configuration"; - - HttpResponse resp = http.send( - HttpRequest.newBuilder().uri(URI.create(url)).GET().build(), - HttpResponse.BodyHandlers.ofString()); - - if (resp.statusCode() != 200) - throw new IllegalStateException( - "OIDC discovery failed [" + resp.statusCode() + "]: " + url); - - @SuppressWarnings("unchecked") - Map doc = (Map) JSONValue.parse(resp.body()); - - return new OidcProviderMetadata( - require(doc, "authorization_endpoint"), - require(doc, "token_endpoint"), - (String) doc.get("userinfo_endpoint"), // optional - require(doc, "jwks_uri"), - (String) doc.get("end_session_endpoint") // optional - ); - } - - private static String require(Map doc, String key) { - Object v = doc.get(key); - if (v == null) throw new IllegalStateException( - "Discovery doc missing required field: " + key); - return v.toString(); - } -} diff --git a/flash-extensions/flash-ext-auth-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 deleted file mode 100644 index c456733..0000000 --- a/flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/JwtUtils.java +++ /dev/null @@ -1,38 +0,0 @@ -package dev.relism.flash.ext.oidc; - -import net.minidev.json.JSONValue; - -import java.nio.charset.StandardCharsets; -import java.util.Base64; -import java.util.Map; - -/** - * Low-level JWT payload extraction — no signature or expiry validation. - * - *

    Use only for tokens received directly from the provider over a trusted TLS - * connection (e.g. {@code id_token} from the token endpoint). Bearer tokens on - * incoming requests must go through {@link JwtValidator#validate(String)} instead. - */ -final class JwtUtils { - - private JwtUtils() {} - - /** - * Base64URL-decodes the JWT payload and returns the claims as a map. - * Signature, expiry, and issuer are NOT checked. - */ - @SuppressWarnings("unchecked") - static Map parseClaims(String jwt) { - String[] parts = jwt.split("\\."); - if (parts.length < 2) throw new IllegalArgumentException("Malformed JWT: " + jwt); - // Pad to a multiple of 4 for the standard decoder - String padded = parts[1]; - switch (padded.length() % 4) { - case 2 -> padded += "=="; - case 3 -> padded += "="; - } - byte[] payload = Base64.getUrlDecoder().decode(padded); - return (Map) JSONValue.parse( - new String(payload, StandardCharsets.UTF_8)); - } -} diff --git a/flash-extensions/flash-ext-auth-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 deleted file mode 100644 index a0bde6f..0000000 --- a/flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/JwtValidator.java +++ /dev/null @@ -1,184 +0,0 @@ -package dev.relism.flash.ext.oidc; - -import com.nimbusds.jose.JWSAlgorithm; -import com.nimbusds.jose.jwk.source.JWKSource; -import com.nimbusds.jose.jwk.source.JWKSourceBuilder; -import com.nimbusds.jose.proc.JWSKeySelector; -import com.nimbusds.jose.proc.JWSVerificationKeySelector; -import com.nimbusds.jose.proc.SecurityContext; -import com.nimbusds.jose.util.Resource; -import com.nimbusds.jose.util.ResourceRetriever; -import com.nimbusds.jwt.JWTClaimsSet; -import com.nimbusds.jwt.proc.ConfigurableJWTProcessor; -import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier; -import com.nimbusds.jwt.proc.DefaultJWTProcessor; -import dev.relism.flash.exceptions.HttpException; - -import java.io.IOException; -import java.net.URL; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.util.Map; -import java.util.Set; - -/** - * Validates JWTs against a remote JWKS endpoint using Nimbus JOSE+JWT. - * - *

    Two validation modes: - *

      - *
    • {@link #validate(String)} — access token bearer validation per request (hot path). - * Checks signature, {@code iss}, {@code exp}, {@code iat}, {@code sub}. - * Throws {@link HttpException} 401 so the middleware can short-circuit.
    • - *
    • {@link #validateIdToken(String, String)} — ID token validation at callback time. - * Checks signature, {@code iss}, {@code aud} == clientId, {@code exp}, {@code iat}, - * {@code sub}, and {@code nonce} (if provided). - * Throws {@link OidcValidationException} (not 401 — it is a provider/protocol error).
    • - *
    - * - *

    JWKS handling: the shared {@link JWKSource} uses caching + rate-limiting + automatic - * retry-on-key-miss (key rotation). Both processors share the same source — one JWKS - * fetch serves both token types. - */ -public class JwtValidator { - - private final JWKSource jwkSource; - private final ConfigurableJWTProcessor accessTokenProcessor; - private final ConfigurableJWTProcessor idTokenProcessor; - private final String algorithm; - - /** - * @param jwksUri JWKS endpoint URI - * @param issuer Expected {@code iss} claim - * @param clientId OAuth2 client ID — used as expected {@code aud} in ID tokens - * @param algorithm JWS algorithm (e.g. {@code "RS256"}) - * @param http Shared {@link HttpClient} used for all JWKS fetches — already configured - * with the correct TLS policy (trust-all or default trust store). - */ - public JwtValidator(String jwksUri, String issuer, String clientId, - String algorithm, HttpClient http) { - try { - // Use the caller-supplied HttpClient for JWKS retrieval so that TLS policy - // (insecureTls / custom trust store) is applied consistently everywhere. - this.jwkSource = JWKSourceBuilder - .create(new URL(jwksUri), httpRetriever(http)) - .cache(true) - .rateLimited(true) - .retrying(true) - .build(); - } catch (Exception e) { - throw new IllegalStateException("Failed to init JWKS source: " + jwksUri, e); - } - this.algorithm = algorithm; - this.accessTokenProcessor = buildAccessTokenProcessor(jwkSource, issuer, algorithm); - this.idTokenProcessor = buildIdTokenProcessor(jwkSource, issuer, clientId, algorithm); - } - - // -- Public API ----------------------------------------------------------- - - /** - * Validates a JWT access token (bearer on incoming request). - * Returns claims on success; throws {@link HttpException} 401 on any failure. - */ - public Map validate(String token) { - if (!isJwt(token)) throw HttpException.unauthorized(); // opaque token — can't validate - try { - return accessTokenProcessor.process(token, null).getClaims(); - } catch (Exception e) { - throw HttpException.unauthorized(); - } - } - - /** - * Validates an ID token received directly from the token endpoint. - * - *

    Checks: signature (JWKS), {@code iss}, {@code aud} == clientId, - * {@code exp}, {@code iat}, {@code sub}, and {@code nonce} if provided. - * - * @param idToken Raw ID token string - * @param nonce Nonce sent in the authorization request; {@code null} to skip check - * @throws OidcValidationException on any validation failure - */ - public Map validateIdToken(String idToken, String nonce) { - try { - Map claims = idTokenProcessor.process(idToken, null).getClaims(); - if (nonce != null && !nonce.equals(claims.get("nonce"))) - throw new OidcValidationException("ID token nonce mismatch", null); - return claims; - } catch (OidcValidationException e) { - throw e; - } catch (Exception e) { - throw new OidcValidationException("ID token validation failed: " + e.getMessage(), e); - } - } - - /** - * Returns {@code true} if {@code token} is a signed JWT (three dot-separated Base64URL parts). - * Used to detect opaque access tokens before attempting JWKS validation. - */ - public static boolean isJwt(String token) { - if (token == null || token.isBlank()) return false; - int dots = 0; - for (int i = 0; i < token.length(); i++) if (token.charAt(i) == '.') dots++; - return dots == 2; - } - - // -- Processors ----------------------------------------------------------- - - private static ConfigurableJWTProcessor buildAccessTokenProcessor( - JWKSource src, String issuer, String algorithm) { - - ConfigurableJWTProcessor p = new DefaultJWTProcessor<>(); - p.setJWSKeySelector(keySelector(src, algorithm)); - // iss required; aud not enforced on ATs (varies by provider) - if (issuer != null && !issuer.isBlank()) { - p.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>( - new JWTClaimsSet.Builder().issuer(issuer).build(), - Set.of("sub", "iat", "exp"))); - } - return p; - } - - private static ConfigurableJWTProcessor buildIdTokenProcessor( - JWKSource src, String issuer, String clientId, String algorithm) { - - ConfigurableJWTProcessor p = new DefaultJWTProcessor<>(); - p.setJWSKeySelector(keySelector(src, algorithm)); - // iss + aud = clientId strictly required (OIDC Core §3.1.3.7) - JWTClaimsSet.Builder required = new JWTClaimsSet.Builder(); - if (issuer != null) required.issuer(issuer); - if (clientId != null) required.audience(clientId); - p.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>( - required.build(), Set.of("sub", "iat", "exp"))); - return p; - } - - private static JWSKeySelector keySelector( - JWKSource src, String algorithm) { - return new JWSVerificationKeySelector<>(JWSAlgorithm.parse(algorithm), src); - } - - /** - * Wraps a {@link HttpClient} as a Nimbus {@link ResourceRetriever}. - * The client already carries the correct TLS policy (trust-all or default), - * so JWKS fetches honour the same SSL configuration as discovery and token requests. - */ - private static ResourceRetriever httpRetriever(HttpClient http) { - return url -> { - try { - HttpResponse resp = http.send( - HttpRequest.newBuilder().uri(url.toURI()).GET().build(), - HttpResponse.BodyHandlers.ofString()); - if (resp.statusCode() != 200) - throw new IOException("JWKS fetch failed [" + resp.statusCode() + "]: " + url); - String contentType = resp.headers() - .firstValue("Content-Type").orElse("application/json"); - return new Resource(resp.body(), contentType); - } catch (IOException e) { - throw e; - } catch (Exception e) { - throw new IOException("JWKS retrieval error: " + e.getMessage(), e); - } - }; - } -} diff --git a/flash-extensions/flash-ext-auth-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 deleted file mode 100644 index 997b71a..0000000 --- a/flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcConfig.java +++ /dev/null @@ -1,264 +0,0 @@ -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()}. - * - *

    Required fields: {@code issuer}, {@code clientId}, {@code clientSecret}, - * {@code redirectUri}. Everything else has a sensible default. - * - *

    If {@code redirectUri} starts with {@code /} it is treated as server-relative: - * the absolute URL is resolved at request time using {@link #selfScheme()} and the - * incoming {@code Host} header. Use {@link Builder#https()} when behind TLS. - * - *

    {@code
    - * // Keycloak
    - * OidcConfig.builder(
    - *         "https://keycloak.example.com/realms/myrealm",
    - *         "my-app", "secret", "/auth/callback")
    - *     .rolesClaimPath("realm_access.roles")   // Keycloak default
    - *     .scopeClaimPaths("scope,scp")           // default; supports many IdPs
    - *     .build();
    - *
    - * // Authelia
    - * OidcConfig.builder(
    - *         "https://auth.example.com",
    - *         "my-app", "secret", "/auth/callback")
    - *     .rolesClaimPath("groups")
    - *     .scopeClaimPaths("scope,scp")
    - *     .build();
    - *
    - * // Two tenants on one server
    - * OidcConfig tenantA = OidcConfig.builder("https://idp/realms/a", ..., "/tenantA/auth/callback")
    - *     .routePrefix("/tenantA/auth").build();
    - * OidcConfig tenantB = OidcConfig.builder("https://idp/realms/b", ..., "/tenantB/auth/callback")
    - *     .routePrefix("/tenantB/auth").build();
    - * app.install(new OidcExtension(tenantA))
    - *    .install(new OidcExtension(tenantB));
    - * }
    - */ -public final class OidcConfig { - - private final String issuer; - private final String clientId; - private final String clientSecret; - private final String redirectUri; - private final String scopes; - private final String routePrefix; - private final String selfScheme; - private final String rolesClaimPath; - private final String scopeClaimPaths; - private final String algorithm; - private final String postLogoutRedirectUri; - private final SessionStore sessionStore; - private final boolean insecureTls; - private final ClientAuthMethod clientAuthMethod; - private final String schemeName; - - private OidcConfig(Builder b) { - this.issuer = require(b.issuer, "issuer"); - this.clientId = require(b.clientId, "clientId"); - this.clientSecret = require(b.clientSecret, "clientSecret"); - this.redirectUri = require(b.redirectUri, "redirectUri"); - this.scopes = b.scopes; - this.routePrefix = b.routePrefix; - this.selfScheme = b.selfScheme; - this.rolesClaimPath = b.rolesClaimPath; - this.scopeClaimPaths = b.scopeClaimPaths; - this.algorithm = b.algorithm; - this.postLogoutRedirectUri = b.postLogoutRedirectUri; - this.sessionStore = b.sessionStore != null ? b.sessionStore - : new InMemorySessionStore(); - this.insecureTls = b.insecureTls; - this.clientAuthMethod = b.clientAuthMethod; - this.schemeName = b.schemeName != null ? b.schemeName : deriveScheme(this.issuer); - } - - // -- Getters -------------------------------------------------------------- - - public String issuer() { return issuer; } - public String clientId() { return clientId; } - public String clientSecret() { return clientSecret; } - public String redirectUri() { return redirectUri; } - public String scopes() { return scopes; } - public String routePrefix() { return routePrefix; } - public String selfScheme() { return selfScheme; } - public String rolesClaimPath() { return rolesClaimPath; } - /** Comma-separated claim paths used to read OAuth2 scopes (default: {@code "scope,scp"}). */ - public String scopeClaimPaths() { return scopeClaimPaths; } - public String algorithm() { return algorithm; } - public String postLogoutRedirectUri() { return postLogoutRedirectUri; } - 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; } - /** OpenAPI security scheme name (derived from issuer if not set explicitly). */ - public String schemeName() { return schemeName; } - - // -- Factory -------------------------------------------------------------- - - /** - * Reads configuration from environment variables: - *
    -     * OIDC_ISSUER               required
    -     * OIDC_CLIENT_ID            required
    -     * OIDC_CLIENT_SECRET        required
    -     * OIDC_REDIRECT_URI         required  (e.g. /auth/callback)
    -     * OIDC_SCOPES               default: openid profile email
    -     * OIDC_ROUTE_PREFIX         default: /auth
    -     * OIDC_SELF_SCHEME          default: http
    -     * OIDC_ROLES_CLAIM          default: realm_access.roles
    -     * OIDC_SCOPE_CLAIMS         default: scope,scp
    -     * OIDC_ALGORITHM            default: RS256
    -     * OIDC_POST_LOGOUT_REDIRECT default: /
    -     * 
    - */ - public static OidcConfig fromEnv() { - return builder(env("OIDC_ISSUER"), env("OIDC_CLIENT_ID"), - env("OIDC_CLIENT_SECRET"), env("OIDC_REDIRECT_URI")) - .scopes (envOr("OIDC_SCOPES", "openid profile email")) - .routePrefix (envOr("OIDC_ROUTE_PREFIX", "/auth")) - .selfScheme (envOr("OIDC_SELF_SCHEME", "http")) - .rolesClaimPath (envOr("OIDC_ROLES_CLAIM", "realm_access.roles")) - .scopeClaimPaths (envOr("OIDC_SCOPE_CLAIMS", "scope,scp")) - .algorithm (envOr("OIDC_ALGORITHM", "RS256")) - .postLogoutRedirectUri(envOr("OIDC_POST_LOGOUT_REDIRECT", "/")) - .clientAuthMethod(ClientAuthMethod.valueOf( - envOr("OIDC_CLIENT_AUTH_METHOD", "POST").toUpperCase())) - .build(); - } - - public static Builder builder(String issuer, String clientId, - String clientSecret, String redirectUri) { - return new Builder(issuer, clientId, clientSecret, redirectUri); - } - - /** - * Convenience factory for Keycloak: constructs the issuer as - * {@code {serverUrl}/realms/{realm}} automatically. - * - *
    {@code
    -     * OidcConfig.keycloak(
    -     *     "https://keycloak.example.com", "flashboard",
    -     *     "my-app", "secret", "/auth/callback")
    -     *   .https()
    -     *   .build();
    -     * }
    - */ - public static Builder keycloak(String serverUrl, String realm, - String clientId, String clientSecret, - String redirectUri) { - String base = serverUrl.endsWith("/") ? serverUrl.substring(0, serverUrl.length() - 1) : serverUrl; - String issuer = base + "/realms/" + realm; - return new Builder(issuer, clientId, clientSecret, redirectUri) - .rolesClaimPath("realm_access.roles"); // Keycloak default - } - - // -- Helpers -------------------------------------------------------------- - - private static String require(String v, String name) { - if (v == null || v.isBlank()) - throw new IllegalArgumentException("OidcConfig: " + name + " is required"); - return v; - } - - private static String env(String key) { - String v = System.getenv(key); - if (v == null || v.isBlank()) - throw new IllegalArgumentException("Missing required env var: " + key); - return v; - } - - private static String envOr(String key, String def) { - String v = System.getenv(key); - return (v != null && !v.isBlank()) ? v : def; - } - - // -- Builder -------------------------------------------------------------- - - public static final class Builder { - - private final String issuer; - private final String clientId; - private final String clientSecret; - private final String redirectUri; - - private String scopes = "openid profile email"; - private String routePrefix = "/auth"; - private String selfScheme = "http"; - private String rolesClaimPath = "realm_access.roles"; - private String scopeClaimPaths = "scope,scp"; - private String algorithm = "RS256"; - private String postLogoutRedirectUri = "/"; - private SessionStore sessionStore; - private boolean insecureTls = false; - private ClientAuthMethod clientAuthMethod = ClientAuthMethod.POST; - private String schemeName = null; - - private Builder(String issuer, String clientId, String clientSecret, String redirectUri) { - this.issuer = issuer; - this.clientId = clientId; - this.clientSecret = clientSecret; - this.redirectUri = redirectUri; - } - - /** Override requested scopes (default: {@code openid profile email}). */ - public Builder scopes(String scopes) { this.scopes = scopes; return this; } - /** Route prefix for login/callback/logout (default: {@code /auth}). */ - public Builder routePrefix(String prefix) { this.routePrefix = prefix; return this; } - /** Scheme used when resolving self-relative redirect URIs (default: {@code http}). */ - public Builder selfScheme(String scheme) { this.selfScheme = scheme; return this; } - /** Shorthand for {@code selfScheme("https")}. */ - public Builder https() { return selfScheme("https"); } - /** Dot-separated path to the roles array in JWT claims (default: {@code realm_access.roles}). */ - public Builder rolesClaimPath(String path) { this.rolesClaimPath = path; return this; } - /** Comma-separated claim paths used to resolve OAuth2 scopes (default: {@code scope,scp}). */ - public Builder scopeClaimPaths(String paths) { this.scopeClaimPaths = paths; return this; } - /** JWS algorithm (default: {@code RS256}). */ - 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 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. - */ - public Builder insecureTls() { this.insecureTls = true; return this; } - /** Token endpoint client authentication method (default: {@link ClientAuthMethod#POST}). */ - public Builder clientAuthMethod(ClientAuthMethod method) { this.clientAuthMethod = method; return this; } - /** Override the OpenAPI security scheme name (default: derived from the issuer URI). */ - public Builder schemeName(String name) { this.schemeName = name; return this; } - - public OidcConfig build() { return new OidcConfig(this); } - } - - /** - * Derives a short, human-readable scheme name from the issuer URI. - * Takes the last non-empty path segment; falls back to the host. - * - *

    Examples: - *

      - *
    • {@code https://keycloak.dev.home/realms/flashboard} → {@code "flashboard"}
    • - *
    • {@code https://auth.example.com} → {@code "auth.example.com"}
    • - *
    - */ - private static String deriveScheme(String issuer) { - try { - java.net.URI uri = new java.net.URI(issuer); - String path = uri.getPath(); - if (path != null && !path.isEmpty()) { - String[] parts = path.split("/"); - for (int i = parts.length - 1; i >= 0; i--) { - if (!parts[i].isEmpty()) return parts[i]; - } - } - return uri.getHost(); - } catch (Exception e) { - return "oidc"; - } - } -} diff --git a/flash-extensions/flash-ext-auth-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 deleted file mode 100644 index cd95e63..0000000 --- a/flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcCredentialSource.java +++ /dev/null @@ -1,333 +0,0 @@ -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; - -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.time.Instant; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; - -/** - * The OpenID Connect {@link CredentialSource}: it turns what a request carries into claims, and - * rejects it the way OAuth2 says to when it cannot. Authorization on those claims is - * {@code flash-ext-auth-core}'s job, not this class's. - * - *

    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 dev.relism.flash.ext.auth.SessionStore}; transparently - * refreshed if the access token is expired.
    4. - *
    5. Browser clients (no {@code Accept: application/json}) → redirect to - * {@code {routePrefix}/login?redirect={path}}.
    6. - *
    7. API clients → 401 with a {@code WWW-Authenticate: Bearer} challenge.
    8. - *
    - */ -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; - private final TokenClient tokenClient; - private final String resourceMetadataPath; - - OidcCredentialSource(JwtValidator validator, OidcConfig config, - OidcProviderMetadata meta, TokenClient tokenClient) { - this(validator, config, meta, tokenClient, null); - } - - private OidcCredentialSource(JwtValidator validator, OidcConfig config, - OidcProviderMetadata meta, TokenClient tokenClient, - String resourceMetadataPath) { - this.validator = validator; - this.config = config; - this.meta = meta; - this.tokenClient = tokenClient; - this.resourceMetadataPath = resourceMetadataPath; - } - - // -- CredentialSource ----------------------------------------------------- - - /** OIDC issuer this source validates tokens against — the {@code iss} claim it enforces. */ - public String issuer() { return config.issuer(); } - - /** Scheme used to build this app's own absolute URLs — see {@link OidcConfig#selfScheme()}. */ - public String selfScheme() { return config.selfScheme(); } - - /** - * A copy of this source whose 401 challenges also carry {@code resource_metadata} - * (RFC 9728 §5.1), resolved against the request's own scheme and host exactly like - * {@link OidcExtension}'s redirect URIs. {@code path} is absolute, e.g. - * {@code "/.well-known/oauth-protected-resource/mcp"}. - * - *

    Used by {@code flash-ext-mcp} to make its Protected Resource Metadata document - * discoverable straight from the {@code WWW-Authenticate} header, per the MCP Authorization - * spec. - */ - public OidcCredentialSource withResourceMetadata(String path) { - return new OidcCredentialSource(validator, config, meta, tokenClient, path); - } - - @Override - public Map authenticate(Request req, Response res) { - return resolve(req, res, resourceMetadataPath); - } - - @Override - public Map peek(Request req) { - return resolveQuiet(req); - } - - @Override - public String insufficientScopeChallenge(String[] requiredScopes) { - return bearerChallenge() + ", error=\"insufficient_scope\", scope=\"" - + quoted(spaceDelimited(requiredScopes)) + "\""; - } - - // -- Internals ------------------------------------------------------------ - - /** - * Like {@link #resolve} but never redirects or throws — returns {@code null} silently - * when no valid credentials are present. Used by {@link #optional()}. - */ - private Map resolveQuiet(Request req) { - String bearerToken = extractBearerToken(req.header("Authorization")); - if (bearerToken != null) { - try { - return validator.validate(bearerToken); - } catch (Exception ignored) { - return null; - } - } - - String sessionId = cookieValue(req, "oidc_session"); - if (sessionId != null) { - Optional found = config.sessionStore().find(sessionId); - if (found.isPresent()) { - Session session = found.get(); - if (!session.isExpired()) - return session.claims(); - if (session.attributeAsString(REFRESH_TOKEN) != null) { - try { - Session refreshed = doRefresh(session); - config.sessionStore().save(refreshed); - return refreshed.claims(); - } catch (Exception ignored) { } - } - config.sessionStore().delete(sessionId); - } - } - return null; - } - - /** - * Returns claims on success, or {@code null} if a redirect was already written to - * {@code res}. Throws {@link HttpException} 401/403 for API clients. - */ - private Map resolve(Request req, Response res) { - return resolve(req, res, null); - } - - private Map resolve(Request req, Response res, String resourceMetadataPath) { - // 1. Bearer token - String bearerToken = extractBearerToken(req.header("Authorization")); - if (bearerToken != null) { - try { - return validator.validate(bearerToken); - } catch (HttpException e) { - res.header("WWW-Authenticate", invalidTokenChallenge(req, resourceMetadataPath)); - throw e; - } - } - - // 2. Session cookie - String sessionId = cookieValue(req, "oidc_session"); - if (sessionId != null) { - Optional found = config.sessionStore().find(sessionId); - if (found.isPresent()) { - Session session = found.get(); - - if (!session.isExpired()) - return session.claims(); - - // Access token expired — try silent refresh - if (session.attributeAsString(REFRESH_TOKEN) != null) { - try { - Session refreshed = doRefresh(session); - config.sessionStore().save(refreshed); - return refreshed.claims(); - } catch (Exception ignored) { - // Refresh failed — fall through to re-authenticate - } - } - config.sessionStore().delete(sessionId); - } - } - - // 3. No valid credentials - String accept = req.header("Accept"); - if (accept != null && accept.contains("application/json")) { - res.header("WWW-Authenticate", bearerChallenge(req, resourceMetadataPath)); - throw HttpException.unauthorized(); - } - - // Browser — redirect to login, preserving the original URL in state - String loginUrl = config.routePrefix() + "/login?redirect=" - + URLEncoder.encode(req.path(), StandardCharsets.UTF_8); - res.redirect(loginUrl); - return null; - } - - private Session doRefresh(Session old) throws Exception { - OidcTokenResponse tokens = tokenClient.refresh( - meta.tokenEndpoint(), old.attributeAsString(REFRESH_TOKEN)); - - return newSession( - old.id(), - tokens.accessToken(), - tokens.idToken() != null ? tokens.idToken() : old.attributeAsString(ID_TOKEN), - tokens.refreshToken() != null ? tokens.refreshToken() : old.attributeAsString(REFRESH_TOKEN), - Instant.now().plusSeconds(tokens.expiresIn()), - mergeRefreshedClaims(tokens, old) - ); - } - - static String extractBearerToken(String authorizationHeader) { - if (authorizationHeader == null) return null; - int len = authorizationHeader.length(); - int start = 0; - while (start < len && Character.isWhitespace(authorizationHeader.charAt(start))) start++; - int schemeEnd = start + BEARER.length(); - if (schemeEnd > len || !authorizationHeader.regionMatches(true, start, BEARER, 0, BEARER.length())) { - return null; - } - if (schemeEnd == len || !Character.isWhitespace(authorizationHeader.charAt(schemeEnd))) { - return null; - } - int tokenStart = schemeEnd; - while (tokenStart < len && Character.isWhitespace(authorizationHeader.charAt(tokenStart))) tokenStart++; - if (tokenStart >= len) return null; - int tokenEnd = len; - while (tokenEnd > tokenStart && Character.isWhitespace(authorizationHeader.charAt(tokenEnd - 1))) tokenEnd--; - return tokenEnd > tokenStart ? authorizationHeader.substring(tokenStart, tokenEnd) : null; - } - - String bearerChallenge() { - return bearerChallenge(null, null); - } - - private String bearerChallenge(Request req, String resourceMetadataPath) { - String base = BEARER + " realm=\"" + quoted(config.schemeName()) + "\""; - if (resourceMetadataPath == null) return base; - return base + ", resource_metadata=\"" + quoted(absoluteSelf(req, resourceMetadataPath)) + "\""; - } - - String invalidTokenChallenge() { - return invalidTokenChallenge(null, null); - } - - private String invalidTokenChallenge(Request req, String resourceMetadataPath) { - return bearerChallenge(req, resourceMetadataPath) + ", error=\"invalid_token\""; - } - - private String absoluteSelf(Request req, String path) { - if (!path.startsWith("/")) return path; - return selfOrigin(req, config.selfScheme()) + path; - } - - /** - * {@code scheme://host} clients actually reach this app on — the basis for every absolute - * URL it publishes about itself (OAuth2 {@code redirect_uri}, the RFC 9728 resource - * identifier and the {@code resource_metadata} challenge). Behind a reverse proxy the - * request's own {@code Host} is the upstream address the proxy dialled, so - * {@code X-Forwarded-Host}/{@code -Proto} win whenever present: without them the app would - * name an address no client can resolve, and OAuth2 discovery fails with no error anyone - * can trace back to here. Trusted unconditionally — a caller able to reach this app without - * passing the proxy can do worse than spoof a self URL. - */ - public static String selfOrigin(Request req, String fallbackScheme) { - String forwardedHost = req.header("X-Forwarded-Host"); - if (forwardedHost == null) return fallbackScheme + "://" + req.header("Host"); - String forwardedProto = req.header("X-Forwarded-Proto"); - return (forwardedProto != null ? forwardedProto : fallbackScheme) + "://" + forwardedHost; - } - - private static String spaceDelimited(String[] values) { - if (values == null || values.length == 0) return ""; - StringBuilder sb = new StringBuilder(); - for (int i = 0; i < values.length; i++) { - if (i > 0) sb.append(' '); - sb.append(values[i]); - } - return sb.toString(); - } - - private static String quoted(String value) { - StringBuilder out = new StringBuilder(value.length() + 8); - for (int i = 0; i < value.length(); i++) { - char c = value.charAt(i); - if (c == '"' || c == '\\') out.append('\\'); - out.append(c); - } - return out.toString(); - } - - 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()); - if (tokens.accessToken() != null) - merged.putAll(JwtUtils.parseClaims(tokens.accessToken())); - if (tokens.idToken() != null) - merged.putAll(JwtUtils.parseClaims(tokens.idToken())); - return Map.copyOf(merged); - } - - // -- Shared cookie utility (also used by OidcExtension) ------------------- - - static String cookieValue(Request req, String name) { - String header = req.header("Cookie"); - if (header == null || header.isBlank()) return null; - int len = header.length(); - int start = 0; - while (start < len) { - int semi = header.indexOf(';', start); - int end = semi < 0 ? len : semi; - int eq = header.indexOf('=', start); - if (eq > start && eq < end) { - int ns = start, ne = eq; - while (ns < ne && header.charAt(ns) == ' ') ns++; - while (ne > ns && header.charAt(ne-1) == ' ') ne--; - if (ne - ns == name.length() && header.regionMatches(ns, name, 0, name.length())) - return header.substring(eq + 1, end).strip(); - } - start = end + 1; - } - return null; - } -} diff --git a/flash-extensions/flash-ext-auth-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 deleted file mode 100644 index 65605f0..0000000 --- a/flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcExtension.java +++ /dev/null @@ -1,343 +0,0 @@ -package dev.relism.flash.ext.oidc; - -import dev.relism.flash.ext.openapi.OpenApiContributor; -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; -import dev.relism.flash.ext.auth.RolesAllowed; -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.models.Request; - -import javax.net.ssl.SSLContext; -import javax.net.ssl.TrustManager; -import javax.net.ssl.X509TrustManager; -import java.net.URLEncoder; -import java.net.http.HttpClient; -import java.nio.charset.StandardCharsets; -import java.security.SecureRandom; -import java.security.cert.X509Certificate; -import java.time.Instant; -import java.util.*; - -/** - * Full OIDC Authorization Code + PKCE flow for Flash. - * - *

    At {@link #provide}, the extension: - *

      - *
    1. Fetches the provider discovery document — fail-fast at startup.
    2. - *
    3. Provides {@link AuthMiddleware}, {@link OidcCredentialSource} and {@link JwtValidator} - * in the context.
    4. - *
    5. Registers annotation processors for {@link Authenticated}, {@link RolesAllowed} - * and {@link ScopesAllowed}.
    6. - *
    - * - *

    At {@link #routes}, three routes are registered: - *

      - *
    • {@code GET {prefix}/login} — builds the authorization URL and redirects.
    • - *
    • {@code GET {prefix}/callback} — exchanges the code, creates a session, redirects.
    • - *
    • {@code POST {prefix}/logout} — invalidates the session, redirects to provider - * end-session endpoint (if available) or to {@link OidcConfig#postLogoutRedirectUri()}.
    • - *
    - * - *
    {@code
    - * // Keycloak
    - * app.install(new OidcExtension(
    - *     OidcConfig.builder(
    - *             "https://keycloak.example.com/realms/myrealm",
    - *             "my-app", "secret", "/auth/callback")
    - *         .rolesClaimPath("realm_access.roles")
    - *         .build()));
    - *
    - * // Two providers / tenants on one server
    - * app.install(new OidcExtension(tenantAConfig))
    - *    .install(new OidcExtension(tenantBConfig));
    - * }
    - */ -public class OidcExtension implements FlashExtension { - - private final OidcConfig config; - - // Initialized in provide(), used in routes() — private to this extension instance. - private OidcProviderMetadata meta; - private OidcStateStore stateStore; - private TokenClient tokenClient; - private JwtValidator validator; - private OidcCredentialSource source; - private AuthMiddleware authMw; - - public OidcExtension(OidcConfig config) { - this.config = config; - } - - // ── Phase 1: services ───────────────────────────────────────────────────── - - @Override - public void configure(FlashRegistrar app, FlashContext ctx) { - HttpClient http = buildHttpClient(config); - - // Discover provider endpoints (blocking; fail fast at startup). - try { - meta = DiscoveryClient.fetch(config.issuer(), http); - } catch (Exception e) { - throw new IllegalStateException("OIDC discovery failed for issuer: " + config.issuer(), e); - } - - validator = new JwtValidator(meta.jwksUri(), config.issuer(), config.clientId(), config.algorithm(), http); - stateStore = new OidcStateStore(); - tokenClient = new TokenClient(http, config); - source = new OidcCredentialSource(validator, config, meta, tokenClient); - authMw = AuthMiddleware.install(ctx, AuthConfig.builder() - .rolesClaimPath(config.rolesClaimPath()) - .scopeClaimPaths(config.scopeClaimPaths()) - .build(), source); - - ctx.provide(OidcCredentialSource.class, source); - ctx.provide(JwtValidator.class, validator); - - ctx.onReady(() -> registerRoutes(app, ctx)); - } - - private void registerRoutes(FlashRegistrar app, FlashContext ctx) { - String prefix = config.routePrefix(); - - // ── GET {prefix}/login ──────────────────────────────────────────────── - // Builds the provider authorization URL with PKCE + state and redirects. - // Optional query param: ?redirect={relative-url} (default: /) - app.get(prefix + "/login", (req, res) -> { - String verifier = PkceUtils.generateVerifier(); - String challenge = PkceUtils.computeChallenge(verifier); - String state = UUID.randomUUID().toString(); - String nonce = UUID.randomUUID().toString(); - - String redirect = req.query("redirect"); - if (redirect == null || !redirect.startsWith("/")) redirect = "/"; - - stateStore.put(state, redirect, verifier, nonce); - - String authUrl = meta.authorizationEndpoint() - + "?response_type=code" - + "&client_id=" + enc(config.clientId()) - + "&redirect_uri=" + enc(absoluteRedirectUri(req)) - + "&scope=" + enc(config.scopes()) - + "&state=" + state - + "&nonce=" + enc(nonce) - + "&code_challenge=" + challenge - + "&code_challenge_method=S256"; - - res.redirect(authUrl); - return null; - }); - - // ── GET {prefix}/callback ───────────────────────────────────────────── - // Validates state, exchanges code for tokens, creates session, redirects. - app.get(prefix + "/callback", (req, res) -> { - String error = req.query("error"); - if (error != null) { - res.status(400); - return "Authentication error: " + error - + (req.query("error_description") != null - ? " — " + req.query("error_description") : ""); - } - - String code = req.query("code"); - String state = req.query("state"); - - OidcStateStore.Entry entry = stateStore.consumeAndRemove(state).orElse(null); - if (entry == null) { - res.status(400); - return "Invalid or expired state parameter"; - } - - OidcTokenResponse tokens = tokenClient.exchangeCode( - meta.tokenEndpoint(), code, absoluteRedirectUri(req), entry.codeVerifier()); - - // Validate ID token: signature + iss + aud + exp + iat + sub + nonce (OIDC Core §3.1.3.7) - if (tokens.idToken() != null) { - try { - validator.validateIdToken(tokens.idToken(), entry.nonce()); - } catch (OidcValidationException e) { - res.status(400); - return "ID token validation failed: " + e.getMessage(); - } - } - - Map claims = mergeClaims(tokens); - Session session = OidcCredentialSource.newSession( - UUID.randomUUID().toString(), - tokens.accessToken(), tokens.idToken(), tokens.refreshToken(), - Instant.now().plusSeconds(tokens.expiresIn()), claims); - config.sessionStore().save(session); - - res.header("Set-Cookie", sessionCookie(session.id())) - .redirect(entry.originalUrl()); - return null; - }); - - // ── POST {prefix}/logout ────────────────────────────────────────────── - // Invalidates the local session and redirects to end_session_endpoint. - app.post(prefix + "/logout", (req, res) -> { - String sessionId = OidcCredentialSource.cookieValue(req, "oidc_session"); - String idTokenHint = null; - - if (sessionId != null) { - Session session = config.sessionStore().find(sessionId).orElse(null); - if (session != null) idTokenHint = session.attributeAsString(OidcCredentialSource.ID_TOKEN); - config.sessionStore().delete(sessionId); - } - - String clearCookie = "oidc_session=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax"; - String location; - - if (meta.endSessionEndpoint() != null) { - String postLogout = absoluteSelf(req, config.postLogoutRedirectUri()); - StringBuilder url = new StringBuilder(meta.endSessionEndpoint()) - .append("?post_logout_redirect_uri=").append(enc(postLogout)); - if (idTokenHint != null) - url.append("&id_token_hint=").append(enc(idTokenHint)); - location = url.toString(); - } else { - location = config.postLogoutRedirectUri(); - } - - res.header("Set-Cookie", clearCookie).redirect(location); - return null; - }); - - // Register OpenAPI security scheme if flash-ext-openapi is on the classpath. - try { - OpenApiIntegration.register(ctx, config, meta); - } catch (NoClassDefFoundError ignored) { - // flash-ext-openapi not available — OpenAPI integration disabled - } - } - - // ── Helpers ─────────────────────────────────────────────────────────────── - - /** - * Merges claims from both the access token and the ID token. - * ID token values win on conflict so that verified identity claims are authoritative. - */ - private static Map mergeClaims(OidcTokenResponse tokens) { - Map merged = new HashMap<>(); - if (tokens.accessToken() != null) merged.putAll(JwtUtils.parseClaims(tokens.accessToken())); - if (tokens.idToken() != null) merged.putAll(JwtUtils.parseClaims(tokens.idToken())); - return Map.copyOf(merged); - } - - /** - * Builds an {@link HttpClient}. If {@link OidcConfig#insecureTls()} is set, - * installs a trust-all {@link SSLContext} that accepts any certificate. - * Only safe for development with self-signed certificates. - */ - private static HttpClient buildHttpClient(OidcConfig config) { - if (!config.insecureTls()) return HttpClient.newHttpClient(); - try { - TrustManager[] trustAll = { new X509TrustManager() { - public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; } - public void checkClientTrusted(X509Certificate[] c, String a) {} - public void checkServerTrusted(X509Certificate[] c, String a) {} - }}; - SSLContext sslCtx = SSLContext.getInstance("TLS"); - sslCtx.init(null, trustAll, new SecureRandom()); - return HttpClient.newBuilder().sslContext(sslCtx).build(); - } catch (Exception e) { - throw new IllegalStateException("Failed to create trust-all SSLContext", e); - } - } - - private String absoluteRedirectUri(Request req) { - return absoluteSelf(req, config.redirectUri()); - } - - private String absoluteSelf(Request req, String uri) { - if (!uri.startsWith("/")) return uri; - return OidcCredentialSource.selfOrigin(req, config.selfScheme()) + uri; - } - - private static String enc(String v) { - return URLEncoder.encode(v, StandardCharsets.UTF_8); - } - - private static String sessionCookie(String id) { - return "oidc_session=" + id + "; HttpOnly; Path=/; SameSite=Lax"; - } - - /** - * Loaded lazily so that {@code flash-ext-openapi} classes are only resolved at - * runtime when {@link OpenApiContributorRegistry} is actually on the classpath. - */ - private static final class OpenApiIntegration { - static void register(FlashContext ctx, - OidcConfig config, OidcProviderMetadata meta) { - ctx.find(OpenApiContributorRegistry.class) - .ifPresent(registry -> registry.add(new OpenApiContributor() { - - @Override - public Map componentContributions() { - Map scopesMap = new LinkedHashMap<>(); - for (String s : config.scopes().split("\\s+")) { - if (!s.isBlank()) scopesMap.put(s, s); - } - Map flow = new LinkedHashMap<>(); - flow.put("authorizationUrl", meta.authorizationEndpoint()); - flow.put("tokenUrl", meta.tokenEndpoint()); - flow.put("scopes", scopesMap); - - Map scheme = new LinkedHashMap<>(); - scheme.put("type", "oauth2"); - scheme.put("flows", Map.of("authorizationCode", flow)); - - Map securitySchemes = new LinkedHashMap<>(); - securitySchemes.put(config.schemeName(), scheme); - return Map.of("securitySchemes", securitySchemes); - } - - @Override - public OpenApiOperationContribution operationFor(Class handlerClass) { - OpenApiOperationContribution.Builder out = - OpenApiOperationContribution.builder(); - - List operationScopes = AuthPolicy.openApiScopesFor(handlerClass); - if (operationScopes != null) { - out.security(config.schemeName(), operationScopes); - } - - AuthPolicy policy = AuthPolicy.compileFromAnnotations(handlerClass); - if (policy == null || policy.optionalAuth()) return out.build(); - - out.response(401, OpenApiResponseContribution.of("Authentication required")); - - String[] roles = policy.requiredRoles(); - String[] scopes = policy.requiredScopes(); - if (roles.length == 0 && scopes.length == 0) return out.build(); - - String roleMessage = roles.length == 0 ? null : roleRequiredMessage(roles); - String scopeMessage = scopes.length == 0 ? null : scopeRequiredMessage(scopes); - if (roleMessage != null && scopeMessage != null) { - out.response(403, OpenApiResponseContribution.of(roleMessage + "; " + scopeMessage)); - } else - out.response(403, OpenApiResponseContribution.of(Objects.requireNonNullElse(roleMessage, scopeMessage))); - return out.build(); - } - })); - } - - private static String roleRequiredMessage(String[] roles) { - if (roles.length == 1) return "\"" + roles[0] + "\" role required"; - return "Roles \"" + String.join(", ", roles) + "\" are required"; - } - - private static String scopeRequiredMessage(String[] scopes) { - if (scopes.length == 1) return "\"" + scopes[0] + "\" scope required"; - return "Scopes \"" + String.join(", ", scopes) + "\" are required"; - } - } -} diff --git a/flash-extensions/flash-ext-auth-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 deleted file mode 100644 index cc783af..0000000 --- a/flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcProviderMetadata.java +++ /dev/null @@ -1,15 +0,0 @@ -package dev.relism.flash.ext.oidc; - -/** - * OIDC provider endpoints discovered from {@code {issuer}/.well-known/openid-configuration}. - * - *

    {@link #endSessionEndpoint()} may be {@code null} — not all providers expose it - * (e.g. some Authelia configurations omit it). - */ -public record OidcProviderMetadata( - String authorizationEndpoint, - String tokenEndpoint, - String userinfoEndpoint, - String jwksUri, - String endSessionEndpoint // nullable -) {} diff --git a/flash-extensions/flash-ext-auth-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 deleted file mode 100644 index 4918cbd..0000000 --- a/flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcStateStore.java +++ /dev/null @@ -1,39 +0,0 @@ -package dev.relism.flash.ext.oidc; - -import java.time.Instant; -import java.util.Optional; -import java.util.concurrent.ConcurrentHashMap; - -/** - * Short-lived store mapping state nonces → (original URL, PKCE verifier). - * - *

    Entries expire after {@value #TTL_SECONDS} seconds. Cleanup runs on every - * access to prevent unbounded growth without needing a background thread. - */ -final class OidcStateStore { - - static final int TTL_SECONDS = 600; // 10 minutes - - record Entry(String originalUrl, String codeVerifier, String nonce, Instant expiresAt) {} - - private final ConcurrentHashMap store = new ConcurrentHashMap<>(); - - void put(String state, String originalUrl, String codeVerifier, String nonce) { - cleanup(); - store.put(state, new Entry(originalUrl, codeVerifier, nonce, - Instant.now().plusSeconds(TTL_SECONDS))); - } - - /** Atomically retrieves and removes the entry; returns empty if absent or expired. */ - Optional consumeAndRemove(String nonce) { - cleanup(); - Entry e = store.remove(nonce); - if (e == null || Instant.now().isAfter(e.expiresAt())) return Optional.empty(); - return Optional.of(e); - } - - private void cleanup() { - Instant now = Instant.now(); - store.entrySet().removeIf(kv -> now.isAfter(kv.getValue().expiresAt())); - } -} diff --git a/flash-extensions/flash-ext-auth-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 deleted file mode 100644 index 8afad32..0000000 --- a/flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcTokenResponse.java +++ /dev/null @@ -1,10 +0,0 @@ -package dev.relism.flash.ext.oidc; - -/** Parsed response from an OAuth2 token endpoint. Package-private — internal use only. */ -record OidcTokenResponse( - String accessToken, - String idToken, // may be null on refresh if provider omits it - String refreshToken, // may be null - int expiresIn, - int refreshExpiresIn -) {} diff --git a/flash-extensions/flash-ext-auth-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 deleted file mode 100644 index 202f75f..0000000 --- a/flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/OidcValidationException.java +++ /dev/null @@ -1,14 +0,0 @@ -package dev.relism.flash.ext.oidc; - -import dev.relism.flash.exceptions.HttpException; - -/** - * Thrown when OIDC token validation fails (signature, claims, nonce, expiry, etc.). - * Distinct from {@link HttpException}: this signals a protocol-level - * failure, not an HTTP response — callers decide the appropriate status code. - */ -public final class OidcValidationException extends RuntimeException { - public OidcValidationException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/flash-extensions/flash-ext-auth-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 deleted file mode 100644 index 99fc632..0000000 --- a/flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/PkceUtils.java +++ /dev/null @@ -1,36 +0,0 @@ -package dev.relism.flash.ext.oidc; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.SecureRandom; -import java.util.Base64; - -/** - * PKCE (RFC 7636) utilities: code verifier generation and S256 challenge computation. - * Package-private — used exclusively by {@link OidcExtension}. - */ -final class PkceUtils { - - private static final SecureRandom RANDOM = new SecureRandom(); - - private PkceUtils() {} - - /** - * Generates a cryptographically random code verifier (43 URL-safe characters, - * per RFC 7636 §4.1 — 32 bytes encoded as unpadded Base64URL). - */ - static String generateVerifier() { - byte[] bytes = new byte[32]; - RANDOM.nextBytes(bytes); - return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); - } - - /** - * Computes the S256 code challenge: {@code BASE64URL(SHA-256(ASCII(verifier)))}. - */ - static String computeChallenge(String verifier) throws Exception { - byte[] digest = MessageDigest.getInstance("SHA-256") - .digest(verifier.getBytes(StandardCharsets.US_ASCII)); - return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); - } -} diff --git a/flash-extensions/flash-ext-auth-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 deleted file mode 100644 index a6be2c2..0000000 --- a/flash-extensions/flash-ext-auth-oidc/src/main/java/dev/relism/flash/ext/oidc/TokenClient.java +++ /dev/null @@ -1,112 +0,0 @@ -package dev.relism.flash.ext.oidc; - -import net.minidev.json.JSONValue; - -import java.net.URI; -import java.net.URLEncoder; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.charset.StandardCharsets; -import java.util.Base64; -import java.util.LinkedHashMap; -import java.util.Map; - -/** - * HTTP client for OAuth2 token endpoint operations (pure HTTP, no SDK). - * - *

    Supports two client authentication methods (RFC 6749 §2.3): - *

      - *
    • {@link ClientAuthMethod#POST} — credentials in form body ({@code client_secret_post})
    • - *
    • {@link ClientAuthMethod#BASIC} — credentials in {@code Authorization: Basic} header - * ({@code client_secret_basic})
    • - *
    - */ -final class TokenClient { - - private final HttpClient http; - private final String clientId; - private final String clientSecret; - private final ClientAuthMethod authMethod; - - TokenClient(HttpClient http, OidcConfig config) { - this.http = http; - this.clientId = config.clientId(); - this.clientSecret = config.clientSecret(); - this.authMethod = config.clientAuthMethod(); - } - - /** Authorization Code + PKCE exchange. */ - OidcTokenResponse exchangeCode(String tokenEndpoint, - String code, String redirectUri, - String codeVerifier) throws Exception { - Map params = new LinkedHashMap<>(); - params.put("grant_type", "authorization_code"); - params.put("code", code); - params.put("redirect_uri", redirectUri); - params.put("code_verifier", codeVerifier); - return post(tokenEndpoint, params); - } - - /** Refresh token grant. */ - OidcTokenResponse refresh(String tokenEndpoint, String refreshToken) throws Exception { - Map params = new LinkedHashMap<>(); - params.put("grant_type", "refresh_token"); - params.put("refresh_token", refreshToken); - return post(tokenEndpoint, params); - } - - // -- Internals ------------------------------------------------------------ - - private OidcTokenResponse post(String url, Map params) throws Exception { - HttpRequest.Builder req = HttpRequest.newBuilder() - .uri(URI.create(url)) - .header("Content-Type", "application/x-www-form-urlencoded"); - - if (authMethod == ClientAuthMethod.BASIC) { - String creds = Base64.getEncoder().encodeToString( - (clientId + ":" + clientSecret).getBytes(StandardCharsets.UTF_8)); - req.header("Authorization", "Basic " + creds); - } else { - params.put("client_id", clientId); - params.put("client_secret", clientSecret); - } - - HttpResponse resp = http.send( - req.POST(HttpRequest.BodyPublishers.ofString(form(params))).build(), - HttpResponse.BodyHandlers.ofString()); - - if (resp.statusCode() < 200 || resp.statusCode() >= 300) - throw new IllegalStateException( - "Token endpoint [" + resp.statusCode() + "]: " + resp.body()); - - @SuppressWarnings("unchecked") - Map json = (Map) JSONValue.parse(resp.body()); - - return new OidcTokenResponse( - (String) json.get("access_token"), - (String) json.get("id_token"), - (String) json.get("refresh_token"), - numInt(json, "expires_in", 300), - numInt(json, "refresh_expires_in", 1800) - ); - } - - private static String form(Map params) { - StringBuilder sb = new StringBuilder(); - params.forEach((k, v) -> { - if (!sb.isEmpty()) sb.append('&'); - sb.append(enc(k)).append('=').append(enc(v)); - }); - return sb.toString(); - } - - private static String enc(String v) { - return URLEncoder.encode(v, StandardCharsets.UTF_8); - } - - private static int numInt(Map m, String key, int def) { - Object v = m.get(key); - return v instanceof Number n ? n.intValue() : def; - } -} diff --git a/flash-extensions/flash-ext-auth-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 deleted file mode 100644 index 7f7d6dd..0000000 --- a/flash-extensions/flash-ext-auth-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcCredentialSourceTest.java +++ /dev/null @@ -1,43 +0,0 @@ -package dev.relism.flash.ext.oidc; - -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** - * What stayed behind when authorization moved to {@code flash-ext-auth-core}: reading a bearer - * token off the wire, and the RFC 6750 challenges this source answers with. The matching of - * claims those credentials produce is {@code ClaimMatchingTest}'s job now. - */ -class OidcCredentialSourceTest { - - private static OidcCredentialSource source() { - return new OidcCredentialSource(null, OidcConfig - .builder("https://idp.example.com", "client", "secret", "/auth/callback") - .build(), null, null); - } - - @Test - void extractBearerToken_acceptsCaseInsensitiveBearerAndTrimsSpaces() { - assertEquals("abc.def.ghi", OidcCredentialSource.extractBearerToken("Bearer abc.def.ghi")); - assertEquals("abc", OidcCredentialSource.extractBearerToken(" bearer abc ")); - assertNull(OidcCredentialSource.extractBearerToken("Basic Zm9vOmJhcg==")); - assertNull(OidcCredentialSource.extractBearerToken("Bearer")); - } - - @Test - void bearerChallenge_containsRealmAndRfcErrors() { - OidcCredentialSource src = source(); - - String basic = src.bearerChallenge(); - String invalid = src.invalidTokenChallenge(); - String insufficient = src.insufficientScopeChallenge(new String[]{"orders:read", "payments:write"}); - - assertTrue(basic.startsWith("Bearer realm=\"")); - assertTrue(invalid.contains("error=\"invalid_token\"")); - assertTrue(insufficient.contains("error=\"insufficient_scope\"")); - assertTrue(insufficient.contains("scope=\"orders:read payments:write\"")); - } -} diff --git a/flash-extensions/flash-ext-auth-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 deleted file mode 100644 index 2597e90..0000000 --- a/flash-extensions/flash-ext-auth-oidc/src/test/java/dev/relism/flash/ext/oidc/OidcOpenApiInteropTest.java +++ /dev/null @@ -1,128 +0,0 @@ -package dev.relism.flash.ext.oidc; - -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.openapi.OpenApiContributor; -import dev.relism.flash.ext.auth.AuthPolicy; -import dev.relism.flash.ext.auth.Authenticated; -import dev.relism.flash.ext.auth.RolesAllowed; -import dev.relism.flash.ext.auth.ScopesAllowed; -import dev.relism.flash.extension.FlashContext; -import org.junit.jupiter.api.Test; - -import java.lang.reflect.Constructor; -import java.lang.reflect.Method; -import java.util.List; -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.assertTrue; - -class OidcOpenApiInteropTest { - - @Authenticated - static class AuthOnly {} - - @Authenticated(optional = true) - static class AuthOptional {} - - @RolesAllowed("admin") - static class OneRole {} - - @RolesAllowed({"admin", "operator"}) - static class MultiRole {} - - @ScopesAllowed("orders:write") - static class OneScope {} - - @ScopesAllowed({"orders:write", "payments:write"}) - static class MultiScope {} - - @RolesAllowed("admin") - @ScopesAllowed("orders:write") - static class RoleAndScope {} - - @Test - void autoResponses_authOnly() throws Exception { - Map responses = responses(AuthOnly.class); - assertEquals("Authentication required", responses.get(401)); - assertFalse(responses.containsKey(403)); - } - - @Test - void autoResponses_optionalAuth_addsNothing() throws Exception { - Map responses = responses(AuthOptional.class); - assertTrue(responses.isEmpty()); - } - - @Test - void autoResponses_oneRole_formatsSingular() throws Exception { - Map responses = responses(OneRole.class); - assertEquals("Authentication required", responses.get(401)); - assertEquals("\"admin\" role required", responses.get(403)); - } - - @Test - void autoResponses_multiRoles_formatsPlural() throws Exception { - Map responses = responses(MultiRole.class); - assertEquals("Roles \"admin, operator\" are required", responses.get(403)); - } - - @Test - void autoResponses_oneScope_formatsSingular() throws Exception { - Map responses = responses(OneScope.class); - assertEquals("\"orders:write\" scope required", responses.get(403)); - } - - @Test - void autoResponses_multiScopes_formatsPlural() throws Exception { - Map responses = responses(MultiScope.class); - assertEquals("Scopes \"orders:write, payments:write\" are required", responses.get(403)); - } - - @Test - void autoResponses_roleAndScope_combinesMessages() throws Exception { - Map responses = responses(RoleAndScope.class); - assertEquals("\"admin\" role required; \"orders:write\" scope required", responses.get(403)); - } - - @Test - void securityContribution_presentForAuthenticatedHandler() throws Exception { - OpenApiOperationContribution operation = contributor().operationFor(AuthOnly.class); - List>> security = operation.security(); - assertEquals(1, security.size()); - assertTrue(security.getFirst().containsKey("issuer")); - } - - private static OpenApiContributor contributor() throws Exception { - Class clazz = Class.forName("dev.relism.flash.ext.oidc.OidcExtension$OpenApiIntegration"); - Constructor ctor = clazz.getDeclaredConstructor(); - ctor.setAccessible(true); - Object instance = ctor.newInstance(); - - Method m = clazz.getDeclaredMethod("register", FlashContext.class, OidcConfig.class, OidcProviderMetadata.class); - m.setAccessible(true); - - FlashContext ctx = new FlashContext(); - OpenApiContributorRegistry registry = new OpenApiContributorRegistry(); - ctx.provide(OpenApiContributorRegistry.class, registry); - ctx.complete(); - - OidcConfig config = OidcConfig.builder("https://issuer", "c", "s", "/cb").build(); - OidcProviderMetadata meta = new OidcProviderMetadata("a", "t", "u", "j", "e"); - m.invoke(instance, ctx, config, meta); - - return registry.contributors().getFirst(); - } - - private static Map responses(Class cls) throws Exception { - Map byCode = contributor().operationFor(cls).responses(); - java.util.LinkedHashMap out = new java.util.LinkedHashMap<>(); - for (Map.Entry e : byCode.entrySet()) { - out.put(e.getKey(), e.getValue().description()); - } - return out; - } -} diff --git a/flash-extensions/flash-ext-limiter/docs/README.md b/flash-extensions/flash-ext-limiter/docs/README.md index 098b0d8..8f1568a 100644 --- a/flash-extensions/flash-ext-limiter/docs/README.md +++ b/flash-extensions/flash-ext-limiter/docs/README.md @@ -36,7 +36,7 @@ FlashApp.create(8080) // With custom resolvers LimiterConfig conf = new LimiterConfig() .registerResolver("auth_user", req -> - ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous"); + SecurityIdentity.current() != null ? SecurityIdentity.current().principal().name() : "anonymous"); FlashApp.create(8080) .install(new LimiterExtension(conf)) diff --git a/flash-extensions/flash-ext-limiter/docs/key-resolvers.md b/flash-extensions/flash-ext-limiter/docs/key-resolvers.md index 1b39d34..137d2cf 100644 --- a/flash-extensions/flash-ext-limiter/docs/key-resolvers.md +++ b/flash-extensions/flash-ext-limiter/docs/key-resolvers.md @@ -35,11 +35,11 @@ conf.registerResolver("ip", req -> { LimiterConfig conf = new LimiterConfig(); ``` -### By authenticated user (OIDC / ClaimsHolder) +### By authenticated user ```java conf.registerResolver("auth_user", req -> - ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous"); + SecurityIdentity.current() != null ? SecurityIdentity.current().principal().name() : "anonymous"); ``` Requests from unauthenticated users share the `"anonymous"` bucket. If you want @@ -88,7 +88,7 @@ returns the same key for the same user regardless of endpoint; the limit is set ```java conf.registerResolver("auth_user", req -> - ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anon"); + SecurityIdentity.current() != null ? SecurityIdentity.current().principal().name() : "anon"); ``` ```java diff --git a/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/KeyResolver.java b/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/KeyResolver.java index 7fd678a..f8848e9 100644 --- a/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/KeyResolver.java +++ b/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/KeyResolver.java @@ -11,7 +11,7 @@ import dev.relism.flash.models.Request; * *
    {@code
      * conf.registerResolver("ip",       req -> req.header("X-Forwarded-For"));
    - * conf.registerResolver("auth_user", req -> ClaimsHolder.user().sub());
    + * conf.registerResolver("auth_user", req -> SecurityIdentity.current().principal().name());
      * }
    */ @FunctionalInterface diff --git a/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimiterConfig.java b/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimiterConfig.java index 607db07..b6b93df 100644 --- a/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimiterConfig.java +++ b/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimiterConfig.java @@ -21,8 +21,8 @@ import java.util.Map; *
    {@code
      * LimiterConfig conf = new LimiterConfig()
      *     .registerResolver("auth_user", req -> {
    - *         // custom logic — e.g. extract sub from ClaimsHolder
    - *         return ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous";
    + *         // custom logic — e.g. key by the authenticated caller
    + *         return SecurityIdentity.current() != null ? SecurityIdentity.current().principal().name() : "anonymous";
      *     });
      *
      * app.install(new LimiterExtension(conf));
    diff --git a/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimiterExtension.java b/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimiterExtension.java
    index 9f09e85..7e5c5b2 100644
    --- a/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimiterExtension.java
    +++ b/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/flash/ext/limiter/LimiterExtension.java
    @@ -43,7 +43,7 @@ import java.util.Map;
      * 

    Lambda routes (via Guard)

    *
    {@code
      * app.install(new LimiterExtension(
    - *     new LimiterConfig().registerResolver("auth_user", req -> ClaimsHolder.user().sub())));
    + *     new LimiterConfig().registerResolver("auth_user", req -> SecurityIdentity.current().principal().name())));
      *
      * // inside a FlashContext.onReady(...) callback:
      * Guard guard = ctx.require(Guard.class);
    diff --git a/flash-extensions/flash-ext-mcp/docs/README.md b/flash-extensions/flash-ext-mcp/docs/README.md
    index 4022668..5d9ee7f 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-auth-oidc`.
    +`flash-ext-security-core`.
     
     ## 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-auth-oidc` — see `security.md`.
    +- **Security**: authenticated by `flash-ext-security-core`, an OAuth2 protected resource when OIDC is installed — 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.
     
    @@ -53,6 +53,4 @@ public class GetWeatherTool extends McpTool {
     - [`tools-resources-prompts.md`](tools-resources-prompts.md) — defining tools, resources, prompts
     - [`transport.md`](transport.md) — Streamable HTTP scope, session/SSE limitations, Origin validation
     - [`security.md`](security.md) — `McpSecurity` policy, OAuth2 resolution, RFC 9728 / RFC 8707
    -- [`keycloak.md`](keycloak.md) — Keycloak-specific setup cookbook: Dynamic Client Registration,
    -  the RFC 8707 audience mapper gotcha, and how to verify/debug it
     - [`jackson-interop.md`](jackson-interop.md) — why this extension does not depend on `flash-ext-jackson`
    diff --git a/flash-extensions/flash-ext-mcp/docs/jackson-interop.md b/flash-extensions/flash-ext-mcp/docs/jackson-interop.md
    index a968ad9..19873c4 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-auth-oidc` already handles its own internal JSON needs (`json-smart` for
    +This mirrors how `flash-ext-security-oidc` handles its own JSON needs (Nimbus's parser 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.
    @@ -44,8 +44,7 @@ Nothing here rules out a later, additive convenience layer: `McpExtension.routes
     that shared mapper as the backing for an escape hatch such as `ToolArguments.as(Class)` or
     for a tool that wants to `ToolResponse.success(someRecord)` and have it serialized with the
     app's own conventions — falling back to a locally-constructed default `ObjectMapper` when
    -`flash-ext-jackson` isn't installed, the same "prefer shared, degrade to sane default" shape
    -already used for `McpSecurity.AUTO`. That would be purely additive on top of the
    +`flash-ext-jackson` isn't installed. That would be purely additive on top of the
     `JsonGenerator`-based envelope/content writing described above, not a replacement for it — the
     fixed-shape protocol plumbing has no reason to ever go through databinding, regardless of what
     convenience layer gets added around it.
    diff --git a/flash-extensions/flash-ext-mcp/docs/keycloak.md b/flash-extensions/flash-ext-mcp/docs/keycloak.md
    deleted file mode 100644
    index e930b77..0000000
    --- a/flash-extensions/flash-ext-mcp/docs/keycloak.md
    +++ /dev/null
    @@ -1,107 +0,0 @@
    -# Keycloak cookbook
    -
    -`security.md` covers the OAuth2 mechanics `McpOidcIntegration` implements against any
    -`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.
    -
    -## 1. Allow Dynamic Client Registration
    -
    -MCP clients (Claude Desktop, Claude.ai, MCP Inspector, others) don't share one static OAuth
    -client — each has its own `redirect_uri` and none know your realm in advance. They self-register
    -on first connect via `POST {issuer}/clients-registrations/openid-connect` (the
    -`registration_endpoint` from the AS metadata document, reached via the RFC 9728 Protected
    -Resource Metadata document `McpExtension` publishes).
    -
    -**Clients → Client registration**: remove the **Trusted Hosts** policy — it rejects anonymous
    -registration from hosts not on an explicit allowlist (`403` / `"Host not trusted"`), which
    -doesn't scale to arbitrary future agents. This does not weaken end-user authentication — DCR
    -only grants an app a `client_id`; every user still authenticates against Keycloak's real login
    -screen regardless of which client asked. Lighter hygiene policies (**Max Clients Limit**,
    -**Consent Required**) can stay, they don't interfere.
    -
    -## 2. RFC 8707 audience: mapper on `basic`, not a custom scope
    -
    -`McpOidcIntegration` rejects (403) any token whose `aud` doesn't include the MCP endpoint's
    -canonical URL. Keycloak doesn't add this by default. The obvious fix — a custom client scope
    -with an Audience mapper, marked Default, added to Allowed Client Scopes — **does not work**:
    -clients created via the `openid-connect` DCR endpoint only ever get scopes they explicitly
    -request, and most MCP clients (including MCP Inspector) don't request anything beyond what a
    -server tells them to via `scopes_supported` (step 3). Default-scope auto-attachment, which is
    -how a normal manually-created client would pick up a custom Default scope, doesn't apply to
    -DCR-created clients at all.
    -
    -`basic` is the one built-in scope Keycloak attaches to every client unconditionally, regardless
    -of what it registered with. Put the audience mapper there:
    -
    -1. **Client scopes → `basic`** → **Mappers** → **Add mapper** → **By configuration** →
    -   **Audience**.
    -2. **Included Custom Audience** = the exact value your server expects — check
    -   `GET {parent-of-rootPath}/.well-known/oauth-protected-resource{rootPath}` on the running
    -   server for the `resource` field it publishes (auto-derived from the request's
    -   forwarded/`Host` headers — see `security.md`). Leave **Included Client Audience** empty (that
    -   targets another Keycloak client, not a resource URL).
    -3. **Add to access token** = ON.
    -4. **Save.**
    -
    -This is unconditional and works regardless of client cooperation — keep it even after step 3
    -below gets other claims flowing normally, since audience binding is a hard spec requirement
    -that shouldn't depend on a client bothering to request the right scope.
    -
    -## 3. Other claims (username, email...): `scopes_supported` + Allowed Client Scopes
    -
    -`OidcUser.username()`/`.email()`/`.name()` read `preferred_username`/`email`/`name` — normally
    -from the `profile`/`email` client scopes, which DCR clients don't get either, same root cause.
    -Unlike audience, this **is** fixable the "normal" way, because it doesn't need to survive a
    -completely uncooperative client:
    -
    -`McpConfig.scopesSupported("openid", "profile", "email")` publishes those scopes in the PRM
    -document. MCP clients that read it (confirmed for MCP Inspector) echo them back in their DCR
    -registration request — `"scope": "openid profile email offline_access"` (`offline_access` is
    -Inspector's own addition, for refresh tokens). For that request to actually succeed, **Allowed
    -Client Scopes** needs, exactly:
    -
    -- **`openid` listed explicitly.** The one genuinely non-obvious step: `openid` is not covered by
    -  **Allow Default Scopes** (On by default) the way other realm-Default scopes are, even though
    -  every OIDC request includes it. Until it's listed here, registration fails with a generic
    -  `403 insufficient_scope` / `"Not permitted to use specified clientScope"` regardless of
    -  whether everything else is configured correctly.
    -- **`offline_access` listed explicitly** — it's Optional, not Default, so `ALLOW_DEFAULT_SCOPES`
    -  doesn't cover it either.
    -- **`profile`/`email` — do not list them here.** Mark them **Default** on the **Client scopes**
    -  page (Assigned Type column) instead, and leave **Allow Default Scopes** = On. Adding an
    -  already-Default scope to this list explicitly gets rejected on save
    -  (`"Client scopes not allowed: [...]"`) — the list is for *additional* Optional scopes only.
    -
    -With that, a real client's token comes back with `preferred_username`/`email` populated
    -normally.
    -
    -### Fallback for anything else
    -
    -For a claim not covered by `openid profile email` (a custom attribute, a role) — or for a client
    -that ignores `scopes_supported` entirely — add a **User Property** mapper to `basic` too
    -(Property `username` → Token Claim Name `preferred_username`, or whatever's needed), same as the
    -audience mapper in step 2. Unconditional, works regardless of client cooperation, costs one
    -mapper per claim, once, at the realm level — not per tool.
    -
    -## Verifying without a full OAuth round-trip
    -
    -**Clients → (any client) → Client scopes → Evaluate**: pick a user, run it — Default scopes
    -(including `basic`) apply automatically and won't appear in the "Select scope parameters"
    -picker, which only lists Optional ones — and check the **Generated Access Token** preview.
    -Confirms mappers work without a browser + real MCP client round-trip each time.
    -
    -## If a real client still gets rejected
    -
    -`McpOidcIntegration.audienceGuard` logs the actual mismatch at `WARN`:
    -
    -```
    -[flash-ext-mcp] Rejecting token (RFC 8707): aud= does not include expected
    -resource identifier "" — ...
    -```
    -
    -`aud=null` → the `basic` mapper produced nothing (most common cause: **Included Custom
    -Audience** left blank — the mapper saves fine and silently does nothing without it). A non-null
    -`aud` that still doesn't match → compare byte-for-byte — the expected side is derived from the
    -request's own forwarded/`Host` headers, so scheme/host/trailing-slash mismatches show up here
    -directly, as does a proxy hop that drops `X-Forwarded-Host`.
    diff --git a/flash-extensions/flash-ext-mcp/docs/security.md b/flash-extensions/flash-ext-mcp/docs/security.md
    index f6fa0d0..917fc94 100644
    --- a/flash-extensions/flash-ext-mcp/docs/security.md
    +++ b/flash-extensions/flash-ext-mcp/docs/security.md
    @@ -1,188 +1,47 @@
     # Security
     
    -Provider-specific setup steps (not generic OAuth2 mechanics) live in separate cookbooks —
    -[`keycloak.md`](keycloak.md) for Keycloak: enabling Dynamic Client Registration, why the RFC 8707
    -audience mapper needs to go on the built-in `basic` scope instead of a custom one, and the exact
    -Allowed Client Scopes configuration `scopes_supported` needs to actually work.
    +The MCP endpoint is secured by [`flash-ext-security-core`](../../flash-ext-security-core/docs/README.md):
    +whatever mechanisms the application registers — OAuth2 bearer tokens, API keys, custom ones —
    +authenticate `/mcp` exactly as they authenticate every other route.
     
    -## `McpSecurity`
    +| `McpConfig.security(...)` | |
    +|---|---|
    +| `REQUIRED` (default) | every call must be authenticated; boot fails without a `SecurityExtension` |
    +| `NONE` | a public endpoint; a tool carrying security annotations fails the boot |
     
    -`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()`:
    +## OAuth2 protected resource
     
    -| Policy | `flash-ext-auth-oidc` installed | `flash-ext-auth-oidc` absent |
    -|---|---|---|
    -| `REQUIRED` | protected | **boot fails** (`IllegalStateException`) |
    -| `AUTO` (default) | protected | runs unprotected, logs a warning |
    -| `NONE` | never protected, even if oidc is installed elsewhere in the app | runs unprotected |
    +When a registered mechanism publishes an OAuth2 issuer — `flash-ext-security-oidc` does — the endpoint
    +behaves as the MCP authorization spec requires, with nothing to configure:
     
    -### Guarding `/mcp` without OAuth2
    +- `GET /.well-known/oauth-protected-resource/mcp` serves RFC 9728 metadata: the `resource` (derived per
    +  request from `X-Forwarded-Proto`/`-Host` or `Host`), every issuer as `authorization_servers`, and
    +  `scopes_supported` when `McpConfig.scopesSupported(...)` is set;
    +- an anonymous call gets `401` with `WWW-Authenticate: Bearer resource_metadata="…"`;
    +- a token whose `aud` does not include the resource is `403` (RFC 8707) and logged at `WARN`. Credentials
    +  that are not audience-bound, such as API keys, are unaffected.
     
    -`McpSecurity` only ever answers "is `flash-ext-auth-oidc` installed". An app that authenticates
    -some other way sets `McpSecurity.NONE` and supplies its own guard:
    +For Keycloak, the audience comes from an *Audience* protocol mapper whose included custom audience is
    +the resource URL, attached to a client scope every MCP client receives (the built-in `basic` scope is the
    +one that needs no client cooperation). Clients that register dynamically need Keycloak's anonymous
    +client registration policies relaxed for the trusted hosts.
    +
    +`McpConfig.requireTokenAudience(false)` drops that last check for an authorization server that cannot
    +mint a resource audience at all — Keycloak ignores RFC 8707's `resource` parameter, so a deployment that
    +cannot add the mapper has no other way in. Every token a registered issuer signs is then accepted on the
    +endpoint, and the boot logs say so.
    +
    +## Tool policies
    +
    +The core annotations work on tools as on handlers, checked per `tools/call` against the caller the route
    +authenticated:
     
     ```java
    -McpConfig.builder("my-server")
    -    .toolsPackage("com.example.mcp")
    -    .security(McpSecurity.NONE)
    -    .middleware(myAuthMiddleware.protect())
    -    .build();
    +@Tool(name = "approve", description = "Approves a pending proposal")
    +@RolesAllowed(value = "REVIEWER", on = {"project", "locale"})   // read from the tool's arguments
    +public class ApproveTool extends McpTool { … }
     ```
     
    -`McpConfig.middleware(...)` runs after the transport guards and after whatever `McpSecurity`
    -resolved to, so it composes with OAuth2 protection rather than replacing it — the same hook is how
    -you add rate limiting, audit logging or tracing to the endpoint. It never satisfies `REQUIRED`,
    -which still asks for a real authorization server.
    +A denial is a tool result with `isError: true` — the call reached the server, the tool did not run.
     
    -Use `REQUIRED` for anything you intend to run in production reachable over the network — it
    -turns "someone forgot to wire up OAuth2" into a startup crash instead of a silently open
    -endpoint. `AUTO` is meant for local development, where spinning up a real identity provider is
    -friction you don't want yet.
    -
    -## 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-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-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
    -fails, and only when there's something to fail. This mirrors `OidcExtension`'s own lazy bridge to
    -`flash-ext-openapi` — same technique, same reason.
    -
    -## OAuth2 resolution details — zero-config by default
    -
    -When oidc is available and `security() != NONE`, `McpOidcIntegration` (an isolated,
    -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-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.
    -2. An audience guard always runs after `protect(...)`: it reads the validated claims from
    -   `ClaimsHolder` and rejects (`403`) any token whose `aud` claim does not include the resource
    -   identifier — **RFC 8707 Resource Indicators / audience binding**, enforced unconditionally,
    -   not opt-in. `OidcMiddleware` itself validates `aud` against its own `clientId` for ID
    -   tokens, but deliberately does not enforce audience on access tokens (it varies by provider)
    -   — the MCP extension adds that check on top, scoped to its own resource identifier.
    -3. The resource identifier is the canonical URI of the MCP endpoint, resolved **per request** by
    -   `OidcMiddleware#selfOrigin` + `rootPath` — the same scheme/host resolution `OidcExtension`
    -   uses for its own redirect URIs: `X-Forwarded-Host`/`X-Forwarded-Proto` when the request came
    -   through a reverse proxy, otherwise `{selfScheme()}://{Host header}`. Behind a proxy the
    -   `Host` alone is the upstream address the proxy dialled, which would publish a resource
    -   identifier no client can reach. `McpConfig.resourceIdentifier(...)` still overrides it
    -   outright for a proxy that forwards neither header.
    -4. The authorization server issuer is read from `OidcMiddleware#issuer()` unless
    -   `McpConfig.authorizationServerIssuer(...)` overrides it.
    -
    -## RFC 9728 Protected Resource Metadata
    -
    -Whenever the endpoint ends up protected, `flash-ext-mcp` publishes a Protected Resource Metadata
    -document at `/.well-known/oauth-protected-resource{rootPath}` — no explicit `resourceIdentifier`/
    -`authorizationServerIssuer` configuration required, both are auto-derived as described above:
    -
    -```json
    -{ "resource": "https://mcp.example.com/mcp", "authorization_servers": ["https://auth.example.com/realms/myrealm"] }
    -```
    -
    -`resource` is computed per request from the incoming request's forwarded/`Host` headers (see
    -above), so the document is correct without hardcoding the server's own public URL.
    -
    -### `scopes_supported`
    -
    -Optional per RFC 9728, omitted from the document entirely unless set via
    -`McpConfig.scopesSupported("openid", "profile", "email")`:
    -
    -```json
    -{ "resource": "...", "authorization_servers": ["..."], "scopes_supported": ["openid", "profile", "email"] }
    -```
    -
    -This is pure advertisement — token validation doesn't change based on it — but it matters in
    -practice: a client that ignores it and requests no scope at all (many do — see `keycloak.md`)
    -only gets back whatever the authorization server treats as always-included regardless of
    -request, which for Keycloak is just its built-in `basic` scope. A client that *does* read
    -`scopes_supported` and echoes it back in its authorization/token requests gets a token with the
    -claims those scopes actually provide (`profile` → `preferred_username`/`name`, etc.), without
    -needing every one of those claims hand-mapped onto `basic`. Set it to whatever scopes your
    -`McpTool`s actually read off `ClaimsHolder`/`OidcUser` — there's no way to auto-derive this list,
    -it depends entirely on what your tools do with the claims.
    -
    -## `WWW-Authenticate: resource_metadata` (RFC 9728 §5.1)
    -
    -The MCP Authorization spec **requires** a `401` to carry `resource_metadata` in
    -`WWW-Authenticate`, pointing at the Protected Resource Metadata document above — this is how a
    -spec-compliant client discovers the authorization server without out-of-band configuration.
    -`OidcMiddleware.protect(String resourceMetadataPath)` (an overload added specifically for this)
    -builds that challenge automatically:
    -
    -```
    -WWW-Authenticate: Bearer realm="...", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"
    -```
    -
    -The plain `OidcMiddleware.protect()` (no argument), used by every other Flash5 app, is
    -unaffected — this parameter is additive and MCP-specific.
    -
    -## Per-tool `@RolesAllowed`/`@ScopesAllowed`
    -
    -`McpTool` subclasses can carry `flash-ext-auth-oidc`'s `@RolesAllowed`/`@ScopesAllowed`:
    -
    -```java
    -@Tool(name = "delete_route", description = "Delete a route")
    -@RolesAllowed("admin")
    -public class DeleteRouteTool extends McpTool {
    -    @Override public ToolResponse call(ToolArguments args) { ... }
    -}
    -```
    -
    -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,
    -`McpOidcIntegration.compileToolPolicy` reads the annotations once at boot (`McpRegistry.scan`)
    -and compiles them into a closure (`McpAuthPolicy`) that `McpDispatcher` runs *after* the
    -route-wide auth has already succeeded and *before* invoking the specific tool named in the
    -`tools/call` request — narrowing what's already-authenticated, not replacing it. A denial is a
    -normal `isError: true` tool result (see `ToolResponse.error`), not an HTTP-level rejection — the
    -model sees why, the same as any other tool failure.
    -
    -Roles are read via `OidcUser#hasRole` against `McpConfig.rolesClaimPath(...)` (default
    -`"realm_access.roles"`, matching `OidcConfig`'s own default — set this explicitly if the two
    -diverge; there's no way to read `OidcConfig`'s actual configured value from here). Scopes use
    -`OidcUser#hasScope`'s built-in default claim paths (`scope`/`scp`), no extra config needed.
    -`@ScopesAllowed(match = ScopesAllowed.Match.ANY)` and multi-role `@RolesAllowed({"admin",
    -"editor"})` (OR semantics) both work exactly as they do on a `RequestHandler`.
    -
    -**`@Authenticated` alone has no effect and fails boot.** Once oidc is active for a server, every
    -tool call is already authenticated — there's no per-tool public/authenticated split the way
    -there is for HTTP routes, so a bare `@Authenticated` on a tool can't mean anything and would
    -silently do nothing if allowed to compile. Boot fails instead, with a message pointing at
    -`@RolesAllowed`/`@ScopesAllowed` as the actual narrowing mechanism.
    -
    -**Annotating a tool without active OAuth2 also fails boot**, not silently at request time: if
    -`@RolesAllowed`/`@ScopesAllowed`/`@Authenticated` shows up on a tool while `McpSecurity` resolved
    -to unprotected (`NONE`, or `AUTO` with no oidc installed), that's very likely a forgotten
    -`OidcExtension` install or a `McpSecurity.NONE` left over from local dev — `IllegalStateException`
    -at `app.start()`.
    -
    -## The `HttpException` safety net
    -
    -`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
    -`FlashApp#onException(...)` (or installs something that does) gets `HttpException.status()`
    -honored.
    -
    -To keep the MCP endpoint correct regardless of what the rest of the app configures,
    -`McpTransportGuards.httpExceptionGuard()` wraps the whole route and translates `HttpException`
    -into the right HTTP status itself, rather than letting it fall through to the app's (possibly
    -unconfigured) global handler. This is scoped entirely to the MCP route — it does not touch or
    -override the app's `onException` for any other route.
    +`McpConfig.middleware(...)` runs after authentication, for rate limiting, auditing or tracing.
    diff --git a/flash-extensions/flash-ext-mcp/pom.xml b/flash-extensions/flash-ext-mcp/pom.xml
    index b0a873f..314d91a 100644
    --- a/flash-extensions/flash-ext-mcp/pom.xml
    +++ b/flash-extensions/flash-ext-mcp/pom.xml
    @@ -19,8 +19,7 @@
             
             
                 dev.relism
    -            flash-ext-auth-oidc
    -            true
    +            flash-ext-security-core
             
             
                 com.fasterxml.jackson.core
    @@ -43,6 +42,22 @@
                 flash-testing
                 test
             
    +        
    +            dev.relism
    +            flash-ext-security-test
    +        
    +        
    +            dev.relism
    +            flash-ext-security-oidc
    +            ${project.version}
    +            test
    +        
    +        
    +            dev.relism
    +            flash-ext-security-apikey
    +            ${project.version}
    +            test
    +        
         
     
     
    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
    deleted file mode 100644
    index 6bdd880..0000000
    --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpAuthPolicy.java
    +++ /dev/null
    @@ -1,23 +0,0 @@
    -package dev.relism.flash.ext.mcp;
    -
    -import java.util.function.Supplier;
    -
    -/**
    - * Compiled per-tool authorization requirement, built once at boot by {@link McpOidcIntegration}
    - * from {@code @RolesAllowed}/{@code @ScopesAllowed} on an {@link McpTool} subclass — {@code null}
    - * on {@link McpRegistry.RegisteredTool} means no restriction beyond whatever {@link McpSecurity}
    - * already enforces route-wide.
    - *
    - * 

    {@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-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}. - * - *

    Returns {@code null} from {@link #check()}{@code .get()} when authorized, or a - * human-readable denial reason otherwise — invoked once per {@code tools/call} against an - * annotated tool, never allocated on that path (the closure and its captured role/scope arrays - * are built exactly once, at boot). - */ -record McpAuthPolicy(Supplier check) {} 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 805d53b..d8268a7 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 @@ -26,8 +26,7 @@ public final class McpConfig { private final String rootPath; private final String toolsPackage; private final McpSecurity security; - private final String resourceIdentifier; - private final String authorizationServerIssuer; + private final boolean requireTokenAudience; private final List allowedOrigins; private final List scopesSupported; private final List middleware; @@ -39,8 +38,7 @@ public final class McpConfig { this.rootPath = b.rootPath; this.toolsPackage = b.toolsPackage; this.security = b.security; - this.resourceIdentifier = b.resourceIdentifier; - this.authorizationServerIssuer = b.authorizationServerIssuer; + this.requireTokenAudience = b.requireTokenAudience; this.allowedOrigins = List.copyOf(b.allowedOrigins); this.scopesSupported = List.copyOf(b.scopesSupported); this.middleware = List.copyOf(b.middleware); @@ -52,8 +50,7 @@ public final class McpConfig { String rootPath() { return rootPath; } String toolsPackage() { return toolsPackage; } McpSecurity security() { return security; } - String resourceIdentifier() { return resourceIdentifier; } - String authorizationServerIssuer() { return authorizationServerIssuer; } + boolean requireTokenAudience() { return requireTokenAudience; } List allowedOrigins() { return allowedOrigins; } List scopesSupported() { return scopesSupported; } List middleware() { return middleware; } @@ -66,9 +63,8 @@ public final class McpConfig { private String instructions; private String rootPath = "/mcp"; private String toolsPackage; - private McpSecurity security = McpSecurity.AUTO; - private String resourceIdentifier; - private String authorizationServerIssuer; + private McpSecurity security = McpSecurity.REQUIRED; + private boolean requireTokenAudience = true; private final List allowedOrigins = new ArrayList<>(); private final List scopesSupported = new ArrayList<>(); private final List middleware = new ArrayList<>(); @@ -91,28 +87,16 @@ public final class McpConfig { /** Package scanned for {@link Tool @Tool}/{@link Resource @Resource}/{@link Prompt @Prompt} classes. Required. */ public Builder toolsPackage(String toolsPackage) { this.toolsPackage = toolsPackage; return this; } - /** OAuth2 requirement policy. Default {@link McpSecurity#AUTO}. */ + /** Default {@link McpSecurity#REQUIRED}. */ public Builder security(McpSecurity security) { this.security = security; return this; } /** - * 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-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 - * {@code X-Forwarded-Host} nor {@code X-Forwarded-Proto}. + * Whether a bearer token must name this endpoint in its {@code aud} (RFC 8707), as the MCP + * authorization spec requires. Default {@code true}. Turn it off for an authorization server + * that cannot mint a resource audience — every token a registered issuer signs is then + * accepted on the endpoint, and a warning is logged at boot. */ - public Builder resourceIdentifier(String resourceIdentifier) { this.resourceIdentifier = resourceIdentifier; return this; } - - /** - * 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-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). - */ - public Builder authorizationServerIssuer(String issuer) { this.authorizationServerIssuer = issuer; return this; } + public Builder requireTokenAudience(boolean require) { this.requireTokenAudience = require; return this; } /** * Origins allowed to call the MCP endpoint (DNS-rebinding protection, per the Streamable @@ -121,30 +105,10 @@ public final class McpConfig { */ public Builder allowedOrigins(String... origins) { this.allowedOrigins.addAll(List.of(origins)); return this; } - /** - * OAuth2 scopes this server expects clients to request, published as {@code - * scopes_supported} in the RFC 9728 Protected Resource Metadata document. Optional per - * the spec — omitted from the document entirely if never set. A spec-compliant client - * reads this to know what to put in its authorization/token requests instead of - * requesting nothing; see {@code docs/keycloak.md}'s "same story for any other claim" - * section for why this matters in practice (a client that requests no scope only gets - * whatever your authorization server treats as always-included, e.g. Keycloak's `basic`). - * Purely advertisement — this server still validates whatever token it actually receives - * the same way regardless of what a client requested. - */ + /** Published as {@code scopes_supported} in the RFC 9728 metadata, so OAuth clients request them. */ public Builder scopesSupported(String... scopes) { this.scopesSupported.addAll(List.of(scopes)); return this; } - /** - * Middleware to run on the MCP route, in the order given, after the transport guards and - * after whatever {@link McpSecurity} resolved to. Rate limiting, audit logging, tracing — - * anything that is routine on every other Flash route and had no way in here. - * - *

    It runs on an authenticated request when OAuth2 protection is active, and is the only - * thing standing in front of the endpoint when it is not: {@link McpSecurity#NONE} plus a - * middleware of your own is how an app that authenticates some other way guards - * {@code /mcp}. It never satisfies {@link McpSecurity#REQUIRED}, which still asks for a - * real authorization server. - */ + /** Runs on the MCP route after the transport guards and authentication — rate limiting, auditing, tracing. */ public Builder middleware(Middleware... middleware) { this.middleware.addAll(List.of(middleware)); return this; diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpDispatcher.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpDispatcher.java index 86271d1..90fe905 100644 --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpDispatcher.java +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpDispatcher.java @@ -3,6 +3,8 @@ package dev.relism.flash.ext.mcp; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.databind.JsonNode; import dev.relism.flash.http.ContentType; +import dev.relism.flash.ext.security.SecurityIdentity; +import dev.relism.flash.ext.security.SecurityPolicy; import dev.relism.flash.models.Request; import dev.relism.flash.models.Response; @@ -18,10 +20,10 @@ import java.io.IOException; * tool/resource/prompt name, resource/prompt handler exceptions) is a JSON-RPC error object, * always returned with HTTP 200: the HTTP request itself succeeded, only the RPC did not. Only * malformed HTTP-level input (unparsable JSON, not a JSON object) gets HTTP 400. A - * {@code @RolesAllowed}/{@code @ScopesAllowed} denial (see {@link McpAuthPolicy}) is the same + * {@code @RolesAllowed}/{@code @ScopesAllowed} denial (see {@link SecurityPolicy}) is the same * category — {@code isError: true}, tool never invoked — not a transport-level rejection; the - * route-wide 401/403 for "not authenticated at all" already happened earlier, in the {@code - * OidcMiddleware}/audience-guard middleware chain, before this dispatcher ever runs. + * route-wide 401/403 already happened earlier, in the security middleware, before this + * dispatcher ever runs. */ final class McpDispatcher { @@ -136,12 +138,14 @@ final class McpDispatcher { if (tool == null) throw McpProtocolException.invalidParams("Unknown tool: " + name); + ToolArguments args = new ToolArguments(params.path("arguments")); + SecurityPolicy policy = tool.policy(); ToolResponse result; - String denied = tool.policy() != null ? tool.policy().check().get() : null; - if (denied != null) { - result = ToolResponse.error("Tool \"" + name + "\" denied: " + denied); + if (policy != null && !policy.permitsScopes(SecurityIdentity.current())) { + result = ToolResponse.error("Tool \"" + name + "\" denied: missing scope"); + } else if (policy != null && !policy.permitsRoles(SecurityIdentity.current(), args::getString)) { + result = ToolResponse.error("Tool \"" + name + "\" denied: missing role"); } else { - ToolArguments args = new ToolArguments(params.path("arguments")); try { result = tool.instance().call(args); } catch (Exception e) { 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 a7a81c8..82a1292 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 @@ -1,5 +1,10 @@ package dev.relism.flash.ext.mcp; +import dev.relism.flash.exceptions.HttpException; +import dev.relism.flash.ext.security.SecurityExtension; +import dev.relism.flash.ext.security.SecurityIdentity; +import dev.relism.flash.ext.security.SecurityPolicy; +import dev.relism.flash.ext.security.SecurityScheme; import dev.relism.flash.extension.FlashContext; import dev.relism.flash.extension.FlashExtension; import dev.relism.flash.extension.FlashRegistrar; @@ -9,38 +14,25 @@ import lombok.extern.slf4j.Slf4j; import java.util.ArrayList; import java.util.List; +import java.util.Objects; /** - * MCP (Model Context Protocol) server extension. Streamable HTTP transport — a single - * {@code POST} JSON-RPC endpoint, stateless in this revision (no session, no SSE stream; see - * {@code docs/transport.md}) — dispatch precompiled at boot from classes annotated with - * {@link Tool @Tool}/{@link Resource @Resource}/{@link Prompt @Prompt} under + * MCP (Model Context Protocol) server extension. Streamable HTTP transport — a single {@code POST} + * JSON-RPC endpoint, stateless in this revision (see {@code docs/transport.md}) — dispatching to + * {@link Tool @Tool}/{@link Resource @Resource}/{@link Prompt @Prompt} classes under * {@link McpConfig#toolsPackage(String)}. * *

    {@code
    - * // Standalone, no OAuth2
    - * FlashApp.create(8080)
    - *     .install(new McpExtension(McpConfig.builder("my-mcp-server")
    - *         .toolsPackage("com.example.tools")
    - *         .build()))
    - *     .start();
    - *
    - * // 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)
    - *     .install(new OidcExtension(oidcConfig))
    - *     .install(new McpExtension(McpConfig.builder("my-mcp-server")
    - *         .toolsPackage("com.example.tools")
    - *         .security(McpSecurity.REQUIRED)
    - *         .build()))
    - *     .start();
    + * app.install(new SecurityExtension())
    + *    .install(new OidcExtension(OidcProvider.of("sso", issuer, clientId, secret)))
    + *    .install(new McpExtension(McpConfig.builder("my-server").toolsPackage("com.example.tools").build()));
      * }
    * - *

    One server per {@code McpExtension} instance — install multiple instances (distinct - * {@code rootPath}, distinct {@code toolsPackage}) for multiple MCP servers on one app, - * mirroring the {@code OidcExtension} multi-tenant pattern. See {@code docs/security.md} for - * the full OAuth2 resolution rules. + *

    Every call is authenticated by the application's security chain — OAuth2 bearer tokens, API + * keys, anything registered. When an OAuth2 issuer is among its schemes, the endpoint is also an + * OAuth2 protected resource: RFC 9728 metadata, a {@code resource_metadata} challenge, and RFC 8707 + * audience binding for audience-bound tokens. Tool annotations are enforced per call, with + * {@code @RolesAllowed(on = ...)} reading tool arguments. */ @Slf4j public class McpExtension implements FlashExtension { @@ -53,69 +45,54 @@ public class McpExtension implements FlashExtension { @Override public void configure(FlashRegistrar app, FlashContext ctx) { - ctx.onReady(() -> registerRoutes(app, ctx)); - } + ctx.onReady(() -> { + SecurityExtension security = config.security() == McpSecurity.NONE ? null : ctx.find(SecurityExtension.class) + .orElseThrow(() -> new IllegalStateException("MCP server \"" + config.name() + + "\" requires flash-ext-security-core: install a SecurityExtension, or set McpSecurity.NONE for a public server")); + McpDispatcher dispatcher = new McpDispatcher(McpRegistry.scan(config.toolsPackage(), ctx, security), + config.name(), config.version(), config.instructions()); - private void registerRoutes(FlashRegistrar app, FlashContext ctx) { - // Resolved before scanning so McpRegistry knows, per tool, whether @RolesAllowed/ - // @ScopesAllowed are backed by real OAuth2 protection or a boot-time misconfiguration - // (see McpOidcIntegration#compileToolPolicy) — must run first, not after. - McpOidcIntegration.Resolved secured = resolveSecurity(ctx); - McpRegistry registry = McpRegistry.scan(config.toolsPackage(), ctx, secured != null, - secured == null ? null : secured.rolesClaimPath()); - McpDispatcher dispatcher = new McpDispatcher(registry, config.name(), config.version(), config.instructions()); - - List chain = new ArrayList<>(3 + config.middleware().size()); - chain.add(McpTransportGuards.httpExceptionGuard()); - chain.add(McpTransportGuards.originGuard(config.allowedOrigins())); - if (secured != null) chain.add(secured.security()); - chain.addAll(config.middleware()); - - app.post(config.rootPath(), (req, res) -> { dispatcher.handle(req, res); return null; }, - chain.toArray(Middleware[]::new)); - - registerResourceMetadata(app, secured); - } - - private McpOidcIntegration.Resolved resolveSecurity(FlashContext ctx) { - if (config.security() == McpSecurity.NONE) return null; - - McpOidcIntegration.Resolved resolved; - try { - resolved = McpOidcIntegration.resolve(ctx, config); - } catch (NoClassDefFoundError e) { - 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-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-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; - } - - /** - * RFC 9728 Protected Resource Metadata, built once security is resolved — no longer - * conditioned on {@code resourceIdentifier}/{@code authorizationServerIssuer} being set - * explicitly, since {@link McpOidcIntegration#resolve} now derives both by default. The - * {@code resource} field is computed per request (it depends on that request's own - * forwarded/{@code Host} headers) via {@link McpOidcIntegration.Resolved#resourceIdentifier()}. - */ - private void registerResourceMetadata(FlashRegistrar app, McpOidcIntegration.Resolved secured) { - if (secured == null) return; - String path = "/.well-known/oauth-protected-resource" + config.rootPath(); - app.get(path, (req, res) -> { - res.type(ContentType.JSON); - return McpResourceMetadata.build( - secured.resourceIdentifier().apply(req), secured.issuer(), config.scopesSupported()); + List chain = new ArrayList<>(List.of( + McpTransportGuards.httpExceptionGuard(), McpTransportGuards.originGuard(config.allowedOrigins()))); + if (security != null) protect(app, security, chain); + chain.addAll(config.middleware()); + app.post(config.rootPath(), (req, res) -> { + dispatcher.handle(req, res); + return null; + }, chain.toArray(Middleware[]::new)); }); } + + private void protect(FlashRegistrar app, SecurityExtension security, List chain) { + String metadataPath = "/.well-known/oauth-protected-resource" + config.rootPath(); + chain.add(security.enforce(SecurityPolicy.AUTHENTICATED, (req, res) -> { + List issuers = issuers(security); + res.header("WWW-Authenticate", issuers.isEmpty() + ? String.join(", ", security.schemes().stream().map(SecurityScheme::challenge).toList()) + : "Bearer resource_metadata=\"" + req.origin() + metadataPath + "\""); + throw HttpException.unauthorized(); + })); + if (config.requireTokenAudience()) { + chain.add(next -> (req, res) -> { + String resource = req.origin() + config.rootPath(); + if (!SecurityIdentity.current().principal().hasAudience(resource)) { + log.warn("[flash-ext-mcp] Rejected a token not issued for {} (RFC 8707) — the authorization server must put it in aud", resource); + throw HttpException.forbidden(); + } + return next.handle(req, res); + }); + } else { + log.warn("[flash-ext-mcp] Token audience validation (RFC 8707) is DISABLED for {} — every token a registered issuer signs is accepted.", config.rootPath()); + } + app.get(metadataPath, (req, res) -> { + List issuers = issuers(security); + if (issuers.isEmpty()) throw HttpException.notFound("Protected resource metadata"); + res.type(ContentType.JSON); + return McpResourceMetadata.build(req.origin() + config.rootPath(), issuers, config.scopesSupported()); + }); + } + + private static List issuers(SecurityExtension security) { + return security.schemes().stream().map(SecurityScheme::issuer).filter(Objects::nonNull).toList(); + } } 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 64f3c4e..07636bd 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-auth-oidc} applies to its own JSON needs + * mapper independently — the same reasoning any protocol-level extension 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 deleted file mode 100644 index 5262bb4..0000000 --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpOidcIntegration.java +++ /dev/null @@ -1,186 +0,0 @@ -package dev.relism.flash.ext.mcp; - -import dev.relism.flash.ext.auth.AuthMiddleware; -import dev.relism.flash.ext.auth.Authenticated; -import dev.relism.flash.ext.auth.Claims; -import dev.relism.flash.ext.auth.ClaimsHolder; -import dev.relism.flash.ext.auth.RolesAllowed; -import dev.relism.flash.ext.auth.ScopesAllowed; -import dev.relism.flash.ext.oidc.OidcCredentialSource; -import dev.relism.flash.exceptions.HttpException; -import dev.relism.flash.extension.FlashContext; -import dev.relism.flash.models.Request; -import dev.relism.flash.routing.Middleware; -import lombok.extern.slf4j.Slf4j; - -import java.util.LinkedHashSet; -import java.util.Map; -import java.util.Optional; -import java.util.function.Function; -import java.util.function.Supplier; - -/** - * 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-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-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. - * {@link McpConfig#resourceIdentifier(String)}/{@link McpConfig#authorizationServerIssuer(String)} - * remain as explicit overrides for the rare case where that guess is wrong. - */ -@Slf4j -final class McpOidcIntegration { - - private static final String[] NO_VALUES = new String[0]; - - private McpOidcIntegration() {} - - /** Everything {@link McpExtension} needs once oidc security is resolved. */ - record Resolved(Middleware security, String issuer, String rolesClaimPath, - Function resourceIdentifier) {} - - /** Returns the resolved security bundle, or {@code null} if oidc is not installed. */ - static Resolved resolve(FlashContext ctx, McpConfig config) { - // Deliberately keyed on the OIDC source and not on AuthMiddleware: McpSecurity means - // "a real OAuth2 authorization server is protecting this endpoint", and an app that - // authenticates some other way must not satisfy REQUIRED by accident. - Optional oidc = ctx.find(OidcCredentialSource.class); - Optional auth = ctx.find(AuthMiddleware.class); - if (oidc.isEmpty() || auth.isEmpty()) return null; - - OidcCredentialSource source = oidc.get(); - AuthMiddleware authMw = auth.get(); - String resourceMetadataPath = "/.well-known/oauth-protected-resource" + config.rootPath(); - String issuer = config.authorizationServerIssuer() != null - ? config.authorizationServerIssuer() : source.issuer(); - Function resourceId = req -> config.resourceIdentifier() != null - ? config.resourceIdentifier() - : OidcCredentialSource.selfOrigin(req, source.selfScheme()) + config.rootPath(); - - Middleware protect = authMw.withSource(source.withResourceMetadata(resourceMetadataPath)).protect(); - Middleware secured = Middleware.of(protect, audienceGuard(resourceId)); - return new Resolved(secured, issuer, authMw.rolesClaimPath(), resourceId); - } - - /** - * RFC 8707 audience binding, unconditionally enforced once oidc is protecting the MCP - * route — no longer opt-in behind an explicit {@code resourceIdentifier(...)} call. - */ - private static Middleware audienceGuard(Function resourceIdentifier) { - return next -> (req, res) -> { - Map claims = ClaimsHolder.map(); - String expected = resourceIdentifier.apply(req); - if (claims != null && !audienceMatches(claims.get("aud"), expected)) { - log.warn("[flash-ext-mcp] Rejecting token (RFC 8707): aud={} does not include expected " + - "resource identifier \"{}\" — the authorization server must include this exact " + - "value in the access token's aud claim (e.g. an Audience protocol mapper in " + - "Keycloak) for this MCP server to accept it.", claims.get("aud"), expected); - throw HttpException.forbidden(); - } - return next.handle(req, res); - }; - } - - private static boolean audienceMatches(Object aud, String expected) { - if (aud instanceof String s) return s.equals(expected); - if (aud instanceof Iterable it) { - for (Object o : it) if (expected.equals(String.valueOf(o))) return true; - } - return false; - } - - /** - * Compiles {@code @RolesAllowed}/{@code @ScopesAllowed} on a tool class into a {@link - * McpAuthPolicy}, or returns {@code null} if the tool carries none of the three OIDC - * annotations. Called once per tool at boot ({@link McpRegistry#scan}), never on the - * request hot path — the {@link Supplier} it returns is what runs per {@code tools/call}, - * closing over the already-normalized role/scope arrays so the hot path itself allocates - * nothing beyond what {@link Claims#hasRole}/{@link Claims#hasScope} already do. - * - *

    Fails fast at boot, not silently at request time, for the two ways this can be - * misconfigured: the annotation present without OAuth2 actually protecting this MCP server - * ({@code oidcActive == false}), and {@code @Authenticated} — which has no per-tool meaning - * here (see below) — used at all. - */ - static McpAuthPolicy compileToolPolicy(Class toolClass, boolean oidcActive, - String rolesClaimPath) { - Authenticated auth = toolClass.getAnnotation(Authenticated.class); - RolesAllowed roles = toolClass.getAnnotation(RolesAllowed.class); - ScopesAllowed scopes = toolClass.getAnnotation(ScopesAllowed.class); - if (auth == null && roles == null && scopes == null) return null; - - if (!oidcActive) { - throw new IllegalStateException( - "MCP tool \"" + toolClass.getSimpleName() + "\" declares @Authenticated/@RolesAllowed/" + - "@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() + "."); - } - if (auth != null) { - throw new IllegalStateException( - "MCP tool \"" + toolClass.getSimpleName() + "\" is annotated @Authenticated, which has " + - "no effect on an McpTool: the whole MCP endpoint is already all-or-nothing " + - "authenticated once oidc is active (McpSecurity.AUTO/REQUIRED) — unlike a RequestHandler " + - "route, there is no per-tool public/authenticated split to opt into. Remove it, or use " + - "@RolesAllowed/@ScopesAllowed to narrow further."); - } - - String[] requiredRoles = roles != null ? normalizeRequired("RolesAllowed", roles.value()) : NO_VALUES; - String[] requiredScopes = scopes != null ? normalizeRequired("ScopesAllowed", scopes.value()) : NO_VALUES; - ScopesAllowed.Match scopeMatch = scopes != null ? scopes.match() : ScopesAllowed.Match.ALL; - - Supplier check = () -> { - Claims user = ClaimsHolder.current(); - if (user == null) return "not authenticated"; - if (requiredRoles.length > 0 && !hasAnyRole(user, rolesClaimPath, requiredRoles)) - return "missing required role (any of: " + String.join(", ", requiredRoles) + ")"; - if (requiredScopes.length > 0 && !hasScopes(user, requiredScopes, scopeMatch)) - return "missing required scope (" + scopeMatch + " of: " + String.join(", ", requiredScopes) + ")"; - return null; - }; - return new McpAuthPolicy(check); - } - - private static boolean hasAnyRole(Claims user, String claimPath, String[] roles) { - for (String role : roles) if (user.hasRole(claimPath, role)) return true; - return false; - } - - private static boolean hasScopes(Claims user, String[] scopes, ScopesAllowed.Match match) { - if (match == ScopesAllowed.Match.ALL) { - for (String scope : scopes) if (!user.hasScope(scope)) return false; - return true; - } - for (String scope : scopes) if (user.hasScope(scope)) return true; - return false; - } - - /** Mirrors {@code OidcAuthPolicy}'s own normalization — trim, dedupe, require non-blank. */ - private static String[] normalizeRequired(String annotationName, String[] values) { - if (values == null || values.length == 0) - throw new IllegalStateException("@" + annotationName + " requires at least one value"); - - LinkedHashSet normalized = new LinkedHashSet<>(values.length); - for (String raw : values) { - if (raw == null) continue; - String trimmed = raw.trim(); - if (!trimmed.isEmpty()) normalized.add(trimmed); - } - if (normalized.isEmpty()) - throw new IllegalStateException("@" + annotationName + " requires at least one non-empty value"); - - return normalized.toArray(String[]::new); - } -} 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 61105d4..a109f6b 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 @@ -2,6 +2,8 @@ package dev.relism.flash.ext.mcp; import com.fasterxml.jackson.core.JsonGenerator; import dev.relism.flash.exceptions.InitializationException; +import dev.relism.flash.ext.security.SecurityExtension; +import dev.relism.flash.ext.security.SecurityPolicy; import dev.relism.flash.extension.FlashContext; import java.io.IOException; @@ -25,7 +27,7 @@ final class McpRegistry { private static final String EMPTY_ARRAY = "[]"; /** {@code policy} is {@code null} unless the tool carries @RolesAllowed/@ScopesAllowed. */ - record RegisteredTool(String name, McpTool instance, McpAuthPolicy policy) {} + record RegisteredTool(String name, McpTool instance, SecurityPolicy policy) {} record RegisteredResource(String uri, McpResource instance) {} record RegisteredPrompt(String name, McpPrompt instance) {} @@ -39,15 +41,8 @@ final class McpRegistry { private McpRegistry() {} - /** - * @param oidcActive whether this MCP server's route is actually OAuth2-protected right - * now (see {@link McpOidcIntegration#resolve}) — gates whether - * {@code @RolesAllowed}/{@code @ScopesAllowed} on a tool are honored or - * rejected at boot as a misconfiguration; see - * {@link McpOidcIntegration#compileToolPolicy}. - * @param rolesClaimPath claim path resolved from the installed OIDC extension. - */ - static McpRegistry scan(String packageName, FlashContext ctx, boolean oidcActive, String rolesClaimPath) { + /** @param security {@code null} for a server running with {@link McpSecurity#NONE} */ + static McpRegistry scan(String packageName, FlashContext ctx, SecurityExtension security) { McpPackageScanner.ScanResult found = McpPackageScanner.scan(packageName); McpRegistry registry = new McpRegistry(); @@ -55,7 +50,9 @@ final class McpRegistry { Tool ann = cls.getAnnotation(Tool.class); McpTool instance = instantiate(cls); instance.bind(ctx); - McpAuthPolicy policy = compileToolPolicy(cls, oidcActive, rolesClaimPath); + if (security == null && SecurityPolicy.of(cls) != null) + throw new InitializationException("MCP tool \"" + ann.name() + "\" declares security annotations, but the server runs with McpSecurity.NONE"); + SecurityPolicy policy = security == null ? null : security.policy(cls); if (registry.tools.putIfAbsent(ann.name(), new RegisteredTool(ann.name(), instance, policy)) != null) throw new InitializationException("Duplicate MCP tool name: \"" + ann.name() + "\""); } @@ -173,24 +170,6 @@ final class McpRegistry { gen.writeEndArray(); } - /** - * Isolated the same way {@link McpOidcIntegration#resolve} is — {@code - * 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 - * McpOidcIntegration#resolve} has already succeeded once this boot, which proves those - * types resolve fine). - */ - private static McpAuthPolicy compileToolPolicy(Class cls, boolean oidcActive, - String rolesClaimPath) { - try { - return McpOidcIntegration.compileToolPolicy(cls, oidcActive, rolesClaimPath); - } catch (NoClassDefFoundError e) { - return null; - } - } - private static T instantiate(Class cls) { try { Constructor ctor = cls.getDeclaredConstructor(); diff --git a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpResourceMetadata.java b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpResourceMetadata.java index 55a7eb9..4fced75 100644 --- a/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpResourceMetadata.java +++ b/flash-extensions/flash-ext-mcp/src/main/java/dev/relism/flash/ext/mcp/McpResourceMetadata.java @@ -2,18 +2,18 @@ package dev.relism.flash.ext.mcp; import java.util.List; -/** RFC 9728 OAuth 2.0 Protected Resource Metadata document, built once at boot. */ +/** RFC 9728 OAuth 2.0 Protected Resource Metadata. */ final class McpResourceMetadata { private McpResourceMetadata() {} - /** {@code scopesSupported} is optional per RFC 9728 — omitted from the document if empty. */ - static String build(String resourceIdentifier, String authorizationServerIssuer, List scopesSupported) { + /** {@code scopesSupported} is optional — omitted when empty. */ + static String build(String resource, List authorizationServers, List scopesSupported) { return McpJson.buildString(gen -> { gen.writeStartObject(); - gen.writeStringField("resource", resourceIdentifier); + gen.writeStringField("resource", resource); gen.writeArrayFieldStart("authorization_servers"); - gen.writeString(authorizationServerIssuer); + for (String issuer : authorizationServers) gen.writeString(issuer); gen.writeEndArray(); if (!scopesSupported.isEmpty()) { gen.writeArrayFieldStart("scopes_supported"); 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 21ee37b..b8eedab 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 @@ -1,17 +1,11 @@ package dev.relism.flash.ext.mcp; -/** - * OAuth2 requirement policy for the MCP endpoint, resolved against whether - * {@code flash-ext-auth-oidc} is installed ({@code ctx.find(OidcMiddleware.class)}). - */ +/** Whether the MCP endpoint requires an authenticated caller. */ public enum McpSecurity { - /** Fail fast at boot if {@code flash-ext-auth-oidc} is not installed — never expose an unprotected MCP endpoint. */ + /** The default: every call is authenticated by {@code flash-ext-security-core}, which must be installed. */ REQUIRED, - /** 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-auth-oidc} is installed elsewhere in the app. */ + /** A public endpoint. Tools declaring security annotations fail the boot. */ 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 af6be7b..c6447bf 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 @@ -19,7 +19,7 @@ final class McpTransportGuards { * allowed through — only a present but disallowed value is rejected. * *

    If {@code allowedOrigins} is empty, validation is skipped and a boot-time warning is - * logged — same graceful-degradation shape as {@link McpSecurity#AUTO}. + * logged. */ static Middleware originGuard(List allowedOrigins) { if (allowedOrigins.isEmpty()) { @@ -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-auth-oidc}'s middleware) into a proper HTTP status + * {@link #originGuard} or by {@code flash-ext-security-core}) 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 deleted file mode 100644 index 799a7a4..0000000 --- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/FakeOidcProvider.java +++ /dev/null @@ -1,109 +0,0 @@ -package dev.relism.flash.ext.mcp; - -import com.nimbusds.jose.JWSAlgorithm; -import com.nimbusds.jose.JWSHeader; -import com.nimbusds.jose.crypto.RSASSASigner; -import com.nimbusds.jose.jwk.JWKSet; -import com.nimbusds.jose.jwk.KeyUse; -import com.nimbusds.jose.jwk.RSAKey; -import com.nimbusds.jwt.JWTClaimsSet; -import com.nimbusds.jwt.SignedJWT; -import com.sun.net.httpserver.HttpServer; - -import java.io.OutputStream; -import java.net.InetSocketAddress; -import java.nio.charset.StandardCharsets; -import java.security.KeyPair; -import java.security.KeyPairGenerator; -import java.security.interfaces.RSAPrivateKey; -import java.security.interfaces.RSAPublicKey; -import java.time.Instant; -import java.util.Date; -import java.util.List; -import java.util.Map; -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-auth-oidc}'s actual discovery + JWKS + JWT validation path. - */ -final class FakeOidcProvider implements AutoCloseable { - - private final HttpServer server; - private final String issuer; - private final RSAKey rsaKey; - - FakeOidcProvider() throws Exception { - KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA"); - gen.initialize(2048); - KeyPair kp = gen.generateKeyPair(); - this.rsaKey = new RSAKey.Builder((RSAPublicKey) kp.getPublic()) - .privateKey((RSAPrivateKey) kp.getPrivate()) - .keyUse(KeyUse.SIGNATURE) - .algorithm(JWSAlgorithm.RS256) - .keyID(UUID.randomUUID().toString()) - .build(); - - this.server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); - this.issuer = "http://127.0.0.1:" + server.getAddress().getPort(); - - server.createContext("/.well-known/openid-configuration", ex -> respond(ex, discoveryDocument())); - server.createContext("/jwks", ex -> respond(ex, new JWKSet(rsaKey.toPublicJWK()).toJSONObject().toString())); - server.setExecutor(null); - server.start(); - } - - String issuer() { return issuer; } - - /** Mints a valid RS256 access token — bearer-validation only, no full authorization-code round-trip needed. */ - String signToken(String subject, String audience) { - return signToken(subject, audience, null, NO_ROLES); - } - - /** - * Same as {@link #signToken(String, String)}, plus a {@code scope} claim (space-delimited, - * matching {@link dev.relism.flash.ext.auth.Claims#hasScope}'s default claim path) and a - * Keycloak-shaped {@code realm_access.roles} claim (matching {@code McpConfig}'s default - * {@code rolesClaimPath}) when {@code roles} is non-empty. - */ - String signToken(String subject, String audience, String scope, String... roles) { - try { - JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder() - .issuer(issuer) - .subject(subject) - .audience(audience) - .issueTime(Date.from(Instant.now())) - .expirationTime(Date.from(Instant.now().plusSeconds(300))); - if (scope != null) builder.claim("scope", scope); - if (roles.length > 0) builder.claim("realm_access", Map.of("roles", List.of(roles))); - SignedJWT jwt = new SignedJWT( - new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaKey.getKeyID()).build(), builder.build()); - jwt.sign(new RSASSASigner(rsaKey)); - return jwt.serialize(); - } catch (Exception e) { - throw new IllegalStateException(e); - } - } - - private static final String[] NO_ROLES = new String[0]; - - private String discoveryDocument() { - return "{" - + "\"issuer\":\"" + issuer + "\"," - + "\"authorization_endpoint\":\"" + issuer + "/auth\"," - + "\"token_endpoint\":\"" + issuer + "/token\"," - + "\"jwks_uri\":\"" + issuer + "/jwks\"" - + "}"; - } - - private static void respond(com.sun.net.httpserver.HttpExchange ex, String body) throws java.io.IOException { - byte[] bytes = body.getBytes(StandardCharsets.UTF_8); - ex.getResponseHeaders().add("Content-Type", "application/json"); - ex.sendResponseHeaders(200, bytes.length); - try (OutputStream os = ex.getResponseBody()) { os.write(bytes); } - } - - @Override - public void close() { server.stop(0); } -} diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpAuthPolicyTest.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpAuthPolicyTest.java deleted file mode 100644 index af42bdb..0000000 --- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpAuthPolicyTest.java +++ /dev/null @@ -1,141 +0,0 @@ -package dev.relism.flash.ext.mcp; - -import dev.relism.flash.ext.oidc.OidcConfig; -import dev.relism.flash.ext.oidc.OidcExtension; -import dev.relism.flash.extension.FlashApp; -import dev.relism.flash.extension.FlashConfiguration; -import dev.relism.flash.testing.FlashResponse; -import dev.relism.flash.testing.FlashTest; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; - -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** - * {@code @RolesAllowed}/{@code @ScopesAllowed} on an {@link McpTool} — see - * {@link McpOidcIntegration#compileToolPolicy}. Same real-discovery/real-JWKS/real-RS256-token - * approach as {@link McpExtensionSecurityTest}, against {@code fixtures.secured}'s tools. - */ -class McpAuthPolicyTest { - - private static final String SECURED_TOOLS = "dev.relism.flash.ext.mcp.authfixtures.secured"; - private static final String AUTHENTICATED_ONLY_TOOLS = "dev.relism.flash.ext.mcp.authfixtures.authenticatedonly"; - - private static final FakeOidcProvider provider = newProvider(); - - @RegisterExtension - static FlashTest secured = FlashTest.of(app -> { - app.install(new OidcExtension(OidcConfig.builder( - provider.issuer(), "mcp-client", "secret", "/auth/callback").build())); - app.install(new McpExtension(McpConfig.builder("secure-server") - .toolsPackage(SECURED_TOOLS) - .security(McpSecurity.REQUIRED) - .build())); - }); - - /** Tokens are audience-bound to this server, so the port has to be read back after boot. */ - private static String resourceId() { - return "http://127.0.0.1:" + secured.port() + "/mcp"; - } - - @AfterAll - static void closeProvider() { - provider.close(); - } - - // ── Tool policy ────────────────────────────────────────────────────────── - - @Test - void rolesAllowed_deniesWithoutRole_allowsWithRole() throws Exception { - callTool("admin_only", provider.signToken("user-1", resourceId(), null)) - .expectStatus(200) - .expectBodyContains("\"isError\":true") - .expectBodyContains("missing required role"); - - callTool("admin_only", provider.signToken("user-1", resourceId(), null, "admin")) - .expectStatus(200) - .expectBodyContains("\"isError\":false") - .expectBodyContains("ok"); - } - - @Test - void scopesAllowed_deniesWithoutScope_allowsWithScope() throws Exception { - callTool("write_only", provider.signToken("user-1", resourceId(), "read")) - .expectStatus(200) - .expectBodyContains("\"isError\":true") - .expectBodyContains("missing required scope"); - - callTool("write_only", provider.signToken("user-1", resourceId(), "read write")) - .expectStatus(200) - .expectBodyContains("\"isError\":false") - .expectBodyContains("written"); - } - - @Test - void unannotatedTool_unaffectedByOtherToolsPolicies() throws Exception { - callTool("open", provider.signToken("user-1", resourceId(), null)) - .expectStatus(200) - .expectBodyContains("\"isError\":false") - .expectBodyContains("open"); - } - - private static FlashResponse callTool(String toolName, String token) { - return secured.request() - .header("Accept", "application/json") - .header("Authorization", "Bearer " + token) - .json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"" - + toolName + "\"}}") - .post("/mcp"); - } - - // ── Boot-time rejection ────────────────────────────────────────────────── - // These assert that start() throws, so they build the app directly rather than through - // FlashTest — a harness whose job is to boot an app is the wrong tool for asserting that - // booting fails. Port 0 still removes the old free-port dance. - - private FlashApp bootFailure; - - @AfterEach - void releaseBootFailureListener() { - if (bootFailure != null) bootFailure.stop().join(); - } - - @Test - void toolAnnotated_butSecurityNone_failsAtBoot() { - bootFailure = mcpApp(SECURED_TOOLS, McpSecurity.NONE); - - IllegalStateException error = assertThrows(IllegalStateException.class, bootFailure::start); - assertTrue(error.getMessage().contains("no active OAuth2 protection"), error.getMessage()); - } - - @Test - void bareAuthenticated_hasNoEffect_failsAtBoot() { - bootFailure = mcpApp(AUTHENTICATED_ONLY_TOOLS, McpSecurity.REQUIRED); - - IllegalStateException error = assertThrows(IllegalStateException.class, bootFailure::start); - assertTrue(error.getMessage().contains("no effect"), error.getMessage()); - } - - private static FlashApp mcpApp(String toolsPackage, McpSecurity security) { - FlashApp app = FlashApp.create(FlashConfiguration.builder() - .port(0).host("127.0.0.1").shutdownDrainTimeoutMs(250).build()); - app.install(new OidcExtension(OidcConfig.builder( - provider.issuer(), "mcp-client", "secret", "/auth/callback").build())); - app.install(new McpExtension(McpConfig.builder("secure-server") - .toolsPackage(toolsPackage) - .security(security) - .build())); - return app; - } - - private static FakeOidcProvider newProvider() { - try { - return new FakeOidcProvider(); - } catch (Exception failure) { - throw new IllegalStateException("Could not start the fake OIDC provider", failure); - } - } -} 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 deleted file mode 100644 index 4b8f606..0000000 --- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpExtensionSecurityTest.java +++ /dev/null @@ -1,179 +0,0 @@ -package dev.relism.flash.ext.mcp; - -import dev.relism.flash.ext.oidc.OidcConfig; -import dev.relism.flash.ext.oidc.OidcExtension; -import dev.relism.flash.extension.FlashApp; -import dev.relism.flash.extension.FlashApplication; -import dev.relism.flash.extension.FlashConfiguration; -import dev.relism.flash.testing.FlashRequest; -import dev.relism.flash.testing.FlashResponse; -import dev.relism.flash.testing.FlashTest; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; - -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-auth-oidc} - * installation backed by {@link FakeOidcProvider} — real discovery, real JWKS, real RS256 - * tokens — plus the fail-fast/degrade behavior when oidc is absent. - * - *

    Four server configurations differ only in how MCP security is declared, so each gets its - * own {@link FlashTest} and they share one provider. - */ -class McpExtensionSecurityTest { - - private static final String TOOLS_PACKAGE = "dev.relism.flash.ext.mcp.fixtures"; - private static final String EXPLICIT_RESOURCE_ID = "https://mcp.example.com/mcp"; - - private static final FakeOidcProvider provider = newProvider(); - - /** MCP asked for AUTO security with no oidc installed — should degrade to public. */ - @RegisterExtension - static FlashTest degraded = FlashTest.of(app -> app.install(new McpExtension( - McpConfig.builder("auto-server") - .toolsPackage(TOOLS_PACKAGE) - .security(McpSecurity.AUTO) - .build()))); - - /** REQUIRED with oidc, resource identifier derived from the request. */ - @RegisterExtension - static FlashTest secured = FlashTest.of(securedApp(null, null)); - - /** REQUIRED with oidc and an explicitly declared resource identifier. */ - @RegisterExtension - static FlashTest securedWithResourceId = FlashTest.of(securedApp(EXPLICIT_RESOURCE_ID, null)); - - /** REQUIRED with oidc and advertised scopes. */ - @RegisterExtension - static FlashTest securedWithScopes = - FlashTest.of(securedApp(null, new String[] {"openid", "profile", "email"})); - - @AfterAll - static void closeProvider() { - provider.close(); - } - - // ── No oidc installed ──────────────────────────────────────────────────── - - @Test - void required_withoutOidc_throwsAtBoot() { - // Asserting that boot fails, so this one builds its app directly rather than through - // the harness; port(0) still removes the old free-port dance. - FlashApp app = FlashApp.create(FlashConfiguration.builder() - .port(0).host("127.0.0.1").shutdownDrainTimeoutMs(250).build()); - app.install(new McpExtension(McpConfig.builder("secure-server") - .toolsPackage(TOOLS_PACKAGE) - .security(McpSecurity.REQUIRED) - .build())); - try { - assertThrows(IllegalStateException.class, app::start); - } finally { - app.stop().join(); - } - } - - @Test - void auto_withoutOidc_degradesToPublic() { - post(degraded, initializeBody(), null).expectStatus(200); - } - - // ── REQUIRED with oidc ─────────────────────────────────────────────────── - - @Test - void required_withOidc_rejectsMissingToken() { - post(secured, initializeBody(), null).expectStatus(401); - } - - @Test - void required_withOidc_rejectsWrongAudience() throws Exception { - String token = provider.signToken("user-1", "https://someone-else.example.com/resource"); - - post(securedWithResourceId, initializeBody(), token).expectStatus(403); - } - - @Test - void required_withOidc_acceptsValidAudience() throws Exception { - String token = provider.signToken("user-1", EXPLICIT_RESOURCE_ID); - - post(securedWithResourceId, initializeBody(), token) - .expectStatus(200) - .expectBodyContains("\"protocolVersion\""); - } - - @Test - void required_withOidc_noExplicitResourceIdentifier_derivesFromRequestAndEnforcesAudience() throws Exception { - String derivedResourceId = "http://127.0.0.1:" + secured.port() + "/mcp"; - - post(secured, initializeBody(), provider.signToken("user-1", derivedResourceId)) - .expectStatus(200); - post(secured, initializeBody(), provider.signToken("user-1", "https://someone-else.example.com/resource")) - .expectStatus(403); - } - - @Test - void required_withOidc_missingToken_challengeIncludesResourceMetadata() { - FlashResponse response = post(secured, initializeBody(), null).expectStatus(401); - - String challenge = response.header("WWW-Authenticate"); - assertTrue(challenge != null && challenge.contains("resource_metadata=\"http://127.0.0.1:" - + secured.port() + "/.well-known/oauth-protected-resource/mcp\""), - "WWW-Authenticate: " + challenge); - } - - // ── Protected resource metadata ────────────────────────────────────────── - - @Test - void required_withOidc_noExplicitConfig_publishesProtectedResourceMetadata() { - FlashResponse response = secured.get("/.well-known/oauth-protected-resource/mcp") - .expectStatus(200) - .expectBodyContains("\"resource\":\"http://127.0.0.1:" + secured.port() + "/mcp\"") - .expectBodyContains("\"authorization_servers\":[\"" + provider.issuer() + "\"]"); - - assertTrue(!response.body().contains("scopes_supported"), - "scopes_supported must be omitted when unset: " + response.body()); - } - - @Test - void scopesSupported_published_inProtectedResourceMetadata() { - securedWithScopes.get("/.well-known/oauth-protected-resource/mcp") - .expectStatus(200) - .expectBodyContains("\"scopes_supported\":[\"openid\",\"profile\",\"email\"]"); - } - - // ── Helpers ────────────────────────────────────────────────────────────── - - private static FlashApplication securedApp(String resourceIdentifier, String[] scopesSupported) { - return app -> { - app.install(new OidcExtension(OidcConfig.builder( - provider.issuer(), "mcp-client", "secret", "/auth/callback").build())); - - McpConfig.Builder mcp = McpConfig.builder("secure-server") - .toolsPackage(TOOLS_PACKAGE) - .security(McpSecurity.REQUIRED); - if (resourceIdentifier != null) mcp.resourceIdentifier(resourceIdentifier); - if (scopesSupported != null) mcp.scopesSupported(scopesSupported); - app.install(new McpExtension(mcp.build())); - }; - } - - private static FlashResponse post(FlashTest server, String body, String bearerToken) { - FlashRequest request = server.request().header("Accept", "application/json").json(body); - if (bearerToken != null) request.header("Authorization", "Bearer " + bearerToken); - return request.post("/mcp"); - } - - private static String initializeBody() { - return "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}"; - } - - private static FakeOidcProvider newProvider() { - try { - return new FakeOidcProvider(); - } catch (Exception failure) { - throw new IllegalStateException("Could not start the fake OIDC provider", failure); - } - } -} diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpRegistryTest.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpRegistryTest.java index a3faa29..349b84a 100644 --- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpRegistryTest.java +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpRegistryTest.java @@ -16,7 +16,7 @@ class McpRegistryTest { @Test void scan_findsAndPrecompilesToolsResourcesPrompts() throws Exception { - McpRegistry registry = McpRegistry.scan("dev.relism.flash.ext.mcp.fixtures", new FlashContext(), false, "realm_access.roles"); + McpRegistry registry = McpRegistry.scan("dev.relism.flash.ext.mcp.fixtures", new FlashContext(), null); assertTrue(registry.hasTools()); assertTrue(registry.hasResources()); @@ -47,7 +47,7 @@ class McpRegistryTest { @Test void scan_emptyPackage_throwsInitializationException() { assertThrows(InitializationException.class, - () -> McpRegistry.scan("dev.relism.flash.ext.mcp.doesnotexist", new FlashContext(), false, "realm_access.roles")); + () -> McpRegistry.scan("dev.relism.flash.ext.mcp.doesnotexist", new FlashContext(), null)); } private static JsonNode findByField(JsonNode array, String field, String value) { diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpSecurityTest.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpSecurityTest.java new file mode 100644 index 0000000..a107b1b --- /dev/null +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/McpSecurityTest.java @@ -0,0 +1,109 @@ +package dev.relism.flash.ext.mcp; + +import dev.relism.flash.ext.security.SecurityExtension; +import dev.relism.flash.ext.security.apikey.ApiKey; +import dev.relism.flash.ext.security.apikey.ApiKeyExtension; +import dev.relism.flash.ext.security.apikey.GeneratedApiKey; +import dev.relism.flash.ext.security.oidc.OidcExtension; +import dev.relism.flash.ext.security.oidc.OidcProvider; +import dev.relism.flash.ext.security.test.FakeOidcProvider; +import dev.relism.flash.testing.FlashRequest; +import dev.relism.flash.testing.FlashResponse; +import dev.relism.flash.testing.FlashTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import java.util.Map; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +class McpSecurityTest { + + static final FakeOidcProvider provider = start(); + static final GeneratedApiKey KEY = new ApiKeyExtension("mk", id -> null).generate(); + static final ApiKeyExtension apiKeys = new ApiKeyExtension<>("mk", id -> id.equals(KEY.id()) ? new ApiKey<>(KEY.id(), KEY.secretHash(), "agent", null, null) : null); + + @RegisterExtension + static final FlashTest app = FlashTest.of(flash -> flash + .install(new SecurityExtension().roles((identity, role, on) -> identity.principal().name().equals(role + "@" + on.get("project")))) + .install(new OidcExtension(OidcProvider.of("fake", provider.issuer(), "app", "secret"))) + .install(apiKeys) + .install(new McpExtension(McpConfig.builder("secure").toolsPackage("dev.relism.flash.ext.mcp.authfixtures.secured") + .scopesSupported("openid", "email").build()))); + + /** The same chain with the RFC 8707 check turned off, for an authorization server that cannot mint a resource audience. */ + @RegisterExtension + static final FlashTest relaxed = FlashTest.of(flash -> flash + .install(new SecurityExtension().roles((identity, role, on) -> false)) + .install(new OidcExtension(OidcProvider.of("fake", provider.issuer(), "app", "secret"))) + .install(new McpExtension(McpConfig.builder("relaxed").toolsPackage("dev.relism.flash.ext.mcp.authfixtures.secured") + .requireTokenAudience(false).build()))); + + static FakeOidcProvider start() { + try { + return new FakeOidcProvider(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + static String resource() { + return "http://127.0.0.1:" + app.port() + "/mcp"; + } + + static FlashResponse call(Consumer credential, String method, String params) { + return app.request().with(credential).header("Accept", "application/json") + .json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"" + method + "\",\"params\":" + params + "}") + .post("/mcp"); + } + + @Test + void anAnonymousCallIsChallengedWithTheResourceMetadata() { + call(request -> {}, "initialize", "{}").expectStatus(401) + .expectHeader("WWW-Authenticate", "Bearer resource_metadata=\"http://127.0.0.1:" + app.port() + "/.well-known/oauth-protected-resource/mcp\""); + } + + @Test + void theProtectedResourceMetadataNamesTheIssuer() { + app.get("/.well-known/oauth-protected-resource/mcp").expectStatus(200) + .expectBody("{\"resource\":\"" + resource() + "\",\"authorization_servers\":[\"" + provider.issuer() + "\"],\"scopes_supported\":[\"openid\",\"email\"]}"); + } + + @Test + void aTokenIsAcceptedOnlyForThisResource() { + call(provider.bearer("u", Map.of("aud", resource())), "initialize", "{}").expectStatus(200).expectBodyContains("protocolVersion"); + call(provider.bearer("u", Map.of("aud", "https://elsewhere.example/mcp")), "initialize", "{}").expectStatus(403); + } + + @Test + void aTokenWithoutTheResourceAudienceIsAcceptedWhenTheCheckIsOff() { + relaxed.request().with(provider.bearer("u", Map.of("aud", "https://elsewhere.example/mcp"))).header("Accept", "application/json") + .json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}") + .post("/mcp").expectStatus(200).expectBodyContains("protocolVersion"); + } + + /** An API key is not audience-bound: the same chain authenticates agents that never saw an authorization server. */ + @Test + void anApiKeyIsAcceptedBesideOAuth() { + call(request -> request.header("Authorization", "Bearer " + KEY.token()), "initialize", "{}").expectStatus(200); + } + + @Test + void toolPoliciesReadTheirTargetFromTheArguments() { + Consumer admin = provider.bearer("admin@42", Map.of("aud", resource())); + call(admin, "tools/call", "{\"name\":\"admin_only\",\"arguments\":{\"project\":\"42\"}}").expectBodyContains("\"isError\":false"); + call(admin, "tools/call", "{\"name\":\"admin_only\",\"arguments\":{\"project\":\"7\"}}").expectBodyContains("denied: missing role"); + call(provider.bearer("u", Map.of("aud", resource(), "scope", "write")), "tools/call", "{\"name\":\"write_only\"}").expectBodyContains("written"); + call(provider.bearer("u", Map.of("aud", resource())), "tools/call", "{\"name\":\"write_only\"}").expectBodyContains("denied: missing scope"); + } + + @Test + void securityIsRequiredUnlessDeclaredOff() { + FlashTest unsecured = FlashTest.of(flash -> flash.install(new McpExtension(McpConfig.builder("x").toolsPackage("dev.relism.flash.ext.mcp.fixtures").build()))); + assertThrows(Exception.class, () -> unsecured.get("/mcp")); + FlashTest contradictory = FlashTest.of(flash -> flash.install(new McpExtension(McpConfig.builder("x") + .toolsPackage("dev.relism.flash.ext.mcp.authfixtures.secured").security(McpSecurity.NONE).build()))); + assertThrows(Exception.class, () -> contradictory.get("/mcp")); + } +} diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/authenticatedonly/PointlessAuthTool.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/authenticatedonly/PointlessAuthTool.java deleted file mode 100644 index 71934d9..0000000 --- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/authenticatedonly/PointlessAuthTool.java +++ /dev/null @@ -1,20 +0,0 @@ -package dev.relism.flash.ext.mcp.authfixtures.authenticatedonly; - -import dev.relism.flash.ext.mcp.McpTool; -import dev.relism.flash.ext.mcp.TextContent; -import dev.relism.flash.ext.mcp.Tool; -import dev.relism.flash.ext.mcp.ToolArguments; -import dev.relism.flash.ext.mcp.ToolResponse; -import dev.relism.flash.ext.auth.Authenticated; - -/** Deliberately misconfigured fixture: bare @Authenticated has no effect on an McpTool — see - * McpOidcIntegration#compileToolPolicy. Boot must fail with a clear message, not silently no-op. */ -@Tool(name = "pointless", description = "Exists only to prove @Authenticated alone fails boot") -@Authenticated -public class PointlessAuthTool extends McpTool { - - @Override - public ToolResponse call(ToolArguments args) { - return ToolResponse.success(new TextContent("unreachable")); - } -} diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/AdminOnlyTool.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/AdminOnlyTool.java index 9da014e..ad3ef90 100644 --- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/AdminOnlyTool.java +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/AdminOnlyTool.java @@ -5,10 +5,10 @@ import dev.relism.flash.ext.mcp.TextContent; import dev.relism.flash.ext.mcp.Tool; import dev.relism.flash.ext.mcp.ToolArguments; import dev.relism.flash.ext.mcp.ToolResponse; -import dev.relism.flash.ext.auth.RolesAllowed; +import dev.relism.flash.ext.security.RolesAllowed; @Tool(name = "admin_only", description = "Only callable with the admin role") -@RolesAllowed("admin") +@RolesAllowed(value = "admin", on = "project") public class AdminOnlyTool extends McpTool { @Override diff --git a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/WriteScopeTool.java b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/WriteScopeTool.java index aa4a3c0..8526506 100644 --- a/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/WriteScopeTool.java +++ b/flash-extensions/flash-ext-mcp/src/test/java/dev/relism/flash/ext/mcp/authfixtures/secured/WriteScopeTool.java @@ -5,7 +5,7 @@ import dev.relism.flash.ext.mcp.TextContent; import dev.relism.flash.ext.mcp.Tool; import dev.relism.flash.ext.mcp.ToolArguments; import dev.relism.flash.ext.mcp.ToolResponse; -import dev.relism.flash.ext.auth.ScopesAllowed; +import dev.relism.flash.ext.security.ScopesAllowed; @Tool(name = "write_only", description = "Only callable with the write scope") @ScopesAllowed("write") diff --git a/flash-extensions/flash-ext-openapi/README.md b/flash-extensions/flash-ext-openapi/README.md index 977c86c..4d0c868 100644 --- a/flash-extensions/flash-ext-openapi/README.md +++ b/flash-extensions/flash-ext-openapi/README.md @@ -121,15 +121,11 @@ Merge policy: - contributor collisions use **last-wins** - manual `@APIResponse` description always wins over contributors for the same status -## OIDC interop +## Security interop -When `flash-ext-auth-oidc` is installed, OpenAPI integrates automatically: - -- security scheme under `components.securitySchemes` -- per-operation `security` -- auto responses (class-based handlers): - - `401 Authentication required` - - `403` role/scope required messages when applicable +With `flash-ext-security-core` installed, every registered mechanism's scheme lands under +`components.securitySchemes`, and every operation carrying a security annotation lists them as +`security` alternatives with automatic `401` and — for roles or scopes — `403` responses. Manual `@APIResponse` for the same status code always wins. diff --git a/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/model/RouteRecord.java b/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/model/RouteRecord.java index 8e6e357..fb2415b 100644 --- a/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/model/RouteRecord.java +++ b/flash-extensions/flash-ext-routeviewer/src/main/java/dev/relism/flash/ext/routeviewer/model/RouteRecord.java @@ -102,7 +102,7 @@ public record RouteRecord( /** * Strips the synthetic lambda suffix ({@code $$Lambda/0x...}) from class names - * so that {@code OidcMiddleware$$Lambda/0x0000019c381f} becomes {@code OidcMiddleware}. + * so that {@code SecurityExtension$$Lambda/0x0000019c381f} becomes {@code SecurityExtension}. */ private static List buildMiddlewareNames(List> chain) { List names = new ArrayList<>(chain.size()); diff --git a/flash-extensions/flash-ext-security-apikey/docs/README.md b/flash-extensions/flash-ext-security-apikey/docs/README.md new file mode 100644 index 0000000..c4cd271 --- /dev/null +++ b/flash-extensions/flash-ext-security-apikey/docs/README.md @@ -0,0 +1,20 @@ +# flash-ext-security-apikey + +API keys for [`flash-ext-security-core`](../../flash-ext-security-core/docs/README.md), sent as +`Authorization: Bearer _.`. + +```java +ApiKeyExtension apiKeys = new ApiKeyExtension<>("gk", id -> rows.find(id)); // ApiKeyStore +app.install(new SecurityExtension().roles(...)).install(apiKeys); + +GeneratedApiKey key = apiKeys.generate(); // show key.token() once +rows.save(key.id(), key.secretHash(), grant); // never the token +``` + +The store returns `ApiKey(id, secretHash, grant, expiresAt, revokedAt)`; `G` is whatever the +application authorizes on. An authenticated caller is an `ApiKeyPrincipal` carrying that grant — +read it in a `RoleResolver` with `identity.principal(ApiKeyPrincipal.class)`. + +A bearer token without this prefix is left to other mechanisms; one with it that fails — unknown id, +wrong secret, expired, revoked — is a `401 invalid_token`. Only a SHA-256 of the secret is stored: the +secret is 192 random bits, so a slow KDF would protect nothing and cost every request. diff --git a/flash-extensions/flash-ext-security-apikey/pom.xml b/flash-extensions/flash-ext-security-apikey/pom.xml new file mode 100644 index 0000000..38a31f2 --- /dev/null +++ b/flash-extensions/flash-ext-security-apikey/pom.xml @@ -0,0 +1,30 @@ + + + 4.0.0 + + + dev.relism + flash-extensions + 2.1.0-SNAPSHOT + + + flash-ext-security-apikey + + + + dev.relism + flash-ext-security-core + + + org.junit.jupiter + junit-jupiter + + + dev.relism + flash-testing + test + + + diff --git a/flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/ApiKey.java b/flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/ApiKey.java new file mode 100644 index 0000000..7094246 --- /dev/null +++ b/flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/ApiKey.java @@ -0,0 +1,17 @@ +package dev.relism.flash.ext.security.apikey; + +import java.time.Instant; + +/** + * An API key as the application stores it: never the secret, only its hash, and the grant it was + * issued with — whatever the application authorizes on. + * + * @param expiresAt {@code null} for a key that does not expire + * @param revokedAt {@code null} for a key that has not been revoked + */ +public record ApiKey(String id, String secretHash, G grant, Instant expiresAt, Instant revokedAt) { + + boolean isActive() { + return revokedAt == null && (expiresAt == null || expiresAt.toEpochMilli() > System.currentTimeMillis()); + } +} diff --git a/flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/ApiKeyExtension.java b/flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/ApiKeyExtension.java new file mode 100644 index 0000000..87cf053 --- /dev/null +++ b/flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/ApiKeyExtension.java @@ -0,0 +1,97 @@ +package dev.relism.flash.ext.security.apikey; + +import dev.relism.flash.ext.security.AuthenticationFailedException; +import dev.relism.flash.ext.security.AuthenticationMechanism; +import dev.relism.flash.ext.security.Principal; +import dev.relism.flash.ext.security.SecurityExtension; +import dev.relism.flash.ext.security.SecurityScheme; +import dev.relism.flash.extension.FlashContext; +import dev.relism.flash.extension.FlashExtension; +import dev.relism.flash.extension.FlashRegistrar; +import dev.relism.flash.models.Request; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; + +/** + * API keys sent as {@code Authorization: Bearer _.}. The prefix makes a key + * recognisable at a glance and to secret scanners; the id is what the store is queried by; only a + * SHA-256 of the secret is ever stored — a KDF would add nothing to 192 random bits. + * + *

    {@code
    + * app.install(new SecurityExtension())
    + *    .install(new ApiKeyExtension<>("gk", keys::find));
    + * }
    + */ +public final class ApiKeyExtension implements FlashExtension, AuthenticationMechanism { + + private static final AuthenticationFailedException INVALID = new AuthenticationFailedException("Bearer error=\"invalid_token\""); + private static final SecureRandom RANDOM = new SecureRandom(); + private static final Base64.Encoder BASE64URL = Base64.getUrlEncoder().withoutPadding(); + + private final String prefix; + private final String bearer; + private final ApiKeyStore store; + + /** @param prefix identifies this application's keys; letters and digits only */ + public ApiKeyExtension(String prefix, ApiKeyStore store) { + if (!prefix.matches("[A-Za-z0-9]+")) throw new IllegalArgumentException("API key prefix must be alphanumeric: " + prefix); + this.prefix = prefix; + this.bearer = "Bearer " + prefix + "_"; + this.store = store; + } + + /** A new key: 72 bits of id, 192 bits of secret. */ + public GeneratedApiKey generate() { + String id = random(9); + String secret = random(24); + return new GeneratedApiKey(id, prefix + "_" + id + "." + secret, hash(secret)); + } + + @Override + public Principal authenticate(Request req) { + String header = req.header("Authorization"); + if (header == null || !header.startsWith(bearer)) return null; + int dot = header.indexOf('.', bearer.length()); + if (dot < 0) throw INVALID; + ApiKey key = store.find(header.substring(bearer.length(), dot)); + if (key == null || !matches(header.substring(dot + 1), key.secretHash()) || !key.isActive()) throw INVALID; + return new ApiKeyPrincipal<>(key.id(), key.grant()); + } + + @Override + public SecurityScheme scheme() { + return SecurityScheme.bearer("apiKey", prefix + "_."); + } + + @Override + public void configure(FlashRegistrar app, FlashContext ctx) { + ctx.provide(ApiKeyExtension.class, this); + ctx.onReady(() -> ctx.require(SecurityExtension.class).mechanism(this)); + } + + private static boolean matches(String secret, String secretHash) { + return MessageDigest.isEqual(digest(secret), Base64.getUrlDecoder().decode(secretHash)); + } + + private static String hash(String secret) { + return BASE64URL.encodeToString(digest(secret)); + } + + private static byte[] digest(String secret) { + try { + return MessageDigest.getInstance("SHA-256").digest(secret.getBytes(StandardCharsets.US_ASCII)); + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException(impossible); + } + } + + private static String random(int bytes) { + byte[] value = new byte[bytes]; + RANDOM.nextBytes(value); + return BASE64URL.encodeToString(value); + } +} diff --git a/flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/ApiKeyPrincipal.java b/flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/ApiKeyPrincipal.java new file mode 100644 index 0000000..580a66c --- /dev/null +++ b/flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/ApiKeyPrincipal.java @@ -0,0 +1,6 @@ +package dev.relism.flash.ext.security.apikey; + +import dev.relism.flash.ext.security.Principal; + +/** A caller authenticated by an API key, carrying the grant the key was issued with. */ +public record ApiKeyPrincipal(String name, G grant) implements Principal {} diff --git a/flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/ApiKeyStore.java b/flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/ApiKeyStore.java new file mode 100644 index 0000000..a047890 --- /dev/null +++ b/flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/ApiKeyStore.java @@ -0,0 +1,9 @@ +package dev.relism.flash.ext.security.apikey; + +/** Where the application keeps its API keys. */ +@FunctionalInterface +public interface ApiKeyStore { + + /** The key with this id, or {@code null}. */ + ApiKey find(String id); +} diff --git a/flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/GeneratedApiKey.java b/flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/GeneratedApiKey.java new file mode 100644 index 0000000..401046d --- /dev/null +++ b/flash-extensions/flash-ext-security-apikey/src/main/java/dev/relism/flash/ext/security/apikey/GeneratedApiKey.java @@ -0,0 +1,7 @@ +package dev.relism.flash.ext.security.apikey; + +/** + * A freshly generated key. {@code token} goes to the caller exactly once; the application stores + * {@code id} and {@code secretHash}, never the token. + */ +public record GeneratedApiKey(String id, String token, String secretHash) {} diff --git a/flash-extensions/flash-ext-security-apikey/src/test/java/dev/relism/flash/ext/security/apikey/ApiKeyExtensionTest.java b/flash-extensions/flash-ext-security-apikey/src/test/java/dev/relism/flash/ext/security/apikey/ApiKeyExtensionTest.java new file mode 100644 index 0000000..c584bb4 --- /dev/null +++ b/flash-extensions/flash-ext-security-apikey/src/test/java/dev/relism/flash/ext/security/apikey/ApiKeyExtensionTest.java @@ -0,0 +1,57 @@ +package dev.relism.flash.ext.security.apikey; + +import dev.relism.flash.ext.security.SecurityExtension; +import dev.relism.flash.ext.security.SecurityIdentity; +import dev.relism.flash.ext.security.SecurityPolicy; +import dev.relism.flash.testing.FlashTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import java.time.Instant; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +class ApiKeyExtensionTest { + + static final Map> KEYS = new ConcurrentHashMap<>(); + static final SecurityExtension security = new SecurityExtension(); + static final ApiKeyExtension apiKeys = new ApiKeyExtension<>("fk", KEYS::get); + + @RegisterExtension + static final FlashTest app = FlashTest.of(flash -> flash + .install(security) + .install(apiKeys) + .get("/grant", (req, res) -> SecurityIdentity.current().principal(ApiKeyPrincipal.class).grant(), + security.enforce(SecurityPolicy.AUTHENTICATED))); + + static String issue(String grant, Instant expiresAt, Instant revokedAt) { + GeneratedApiKey key = apiKeys.generate(); + KEYS.put(key.id(), new ApiKey<>(key.id(), key.secretHash(), grant, expiresAt, revokedAt)); + return "Bearer " + key.token(); + } + + @Test + void anIssuedKeyAuthenticatesWithItsGrant() { + app.request().header("Authorization", issue("project-42", null, null)).get("/grant").expectStatus(200).expectBody("project-42"); + } + + @Test + void aWrongSecretAnExpiredKeyAndARevokedKeyAreRejectedAsInvalid() { + String valid = issue("x", null, null); + for (String token : new String[]{ + valid.substring(0, valid.length() - 1) + (valid.endsWith("A") ? "B" : "A"), + issue("x", Instant.now().minusSeconds(1), null), + issue("x", null, Instant.now()), + "Bearer fk_no-secret-here"}) { + app.request().header("Authorization", token).get("/grant") + .expectStatus(401).expectHeader("WWW-Authenticate", "Bearer error=\"invalid_token\""); + } + } + + /** Another application's bearer token is not a key of ours: it is left to other mechanisms, not rejected. */ + @Test + void aForeignBearerTokenIsNotThisMechanisms() { + app.request().header("Authorization", "Bearer eyJhbGciOi.payload.signature").get("/grant") + .expectStatus(401).expectHeader("WWW-Authenticate", "Bearer realm=\"apiKey\""); + } +} diff --git a/flash-extensions/flash-ext-security-core/docs/README.md b/flash-extensions/flash-ext-security-core/docs/README.md new file mode 100644 index 0000000..c5390ca --- /dev/null +++ b/flash-extensions/flash-ext-security-core/docs/README.md @@ -0,0 +1,88 @@ +# flash-ext-security-core + +Authentication and authorization for Flash, independent of any credential. Mechanisms — +[`-oidc`](../../flash-ext-security-oidc/docs/README.md), [`-apikey`](../../flash-ext-security-apikey/docs/README.md), +[`-form`](../../flash-ext-security-form/docs/README.md), or your own — register into one chain; +this module owns everything downstream of "who is the caller". + +```java +app.install(new SecurityExtension() + .users(principal -> users.findOrProvision(principal)) // optional: principal → your user + .roles((identity, role, on) -> members.has(identity.user(User.class), role, on.get("project")))) + .install(new OidcExtension(OidcProvider.of("sso", issuer, clientId, secret))); +``` + +## The model + +| Type | Role | +|---|---| +| `AuthenticationMechanism` | reads one kind of credential: returns a `Principal`, `null` (not mine), or throws `AuthenticationFailedException` (mine, invalid) | +| `Principal` | who the mechanism proved the caller to be — typed per mechanism (`OidcPrincipal`, `ApiKeyPrincipal`, …) | +| `SecurityIdentity` | the current caller: `principal(OidcPrincipal.class)`, `user(User.class)`, `hasRole`, `hasScope` | +| `UserResolver` | principal → application user, resolved lazily, once per request | +| `RoleResolver` | whether a caller holds a role, optionally on a resource | +| `AuthenticationEntryPoint` | the answer to a request that needs a caller and carries no credential | + +Mechanisms never write the response. That is what keeps the one mistake that matters impossible to +make: a credential that was presented and rejected is always a 401, never a redirect into a sign-in +page an API client cannot parse. + +## Annotations + +On a handler or an MCP tool class: + +| | | +|---|---| +| `@Authenticated` | any authenticated caller | +| `@PermitAll` | anyone; a caller who authenticates is still identified, one who fails is anonymous | +| `@RolesAllowed(value, on)` | any of the roles; `on` names the path/query parameters (tool arguments on MCP) identifying the resource | +| `@ScopesAllowed(value)` | every one of the credential's scopes | + +`@RolesAllowed(value = "MANAGER", on = "project")` on `/projects/{project}/keys` asks the +`RoleResolver` whether the caller is a manager *of that project*. A handler declaring roles with no +`RoleResolver` configured fails the boot. Policies compile once; checking one allocates nothing. + +## The chain + +Mechanisms are tried in registration order, then the session cookie. The first to return a +principal wins. When none does: + +- a browser (`Accept: text/html`) is redirected to the only login method, or to `loginPage` when there are several; +- anything else gets `401` with every mechanism's challenge in `WWW-Authenticate`. + +`entryPoint(...)` replaces that, e.g. to pick an identity provider from the user's email domain. + +## Sessions + +`signIn(req, res, principal[, expiresAt])` stores the principal under a `flash_session` cookie; +`POST /auth/logout` ends it and follows `Principal.logoutUrl()`. An expired session is handed to the +`SessionRefresher` registered for its principal type, or ended. `InMemorySessionStore` is the +default; `sessions(...)` swaps it for one that survives a restart or spans instances. + +`GET /auth/methods` lists every registered `LoginMethod` for a client to render. + +## OpenAPI + +With `flash-ext-openapi` present, every registered mechanism's `SecurityScheme` is published, and +every protected operation lists them as alternatives, with its 401 and — for roles or scopes — its +403 and what it requires. Nothing to write per mechanism. + +## Writing a mechanism + +```java +security.mechanism(new AuthenticationMechanism() { + public Principal authenticate(Request req) { + String key = req.header("X-Key"); + if (key == null) return null; // not mine + Principal p = keys.get(key); + if (p == null) throw new AuthenticationFailedException(null); // mine, and invalid + return p; + } + public SecurityScheme scheme() { return SecurityScheme.bearer("key", "opaque"); } +}); +``` + +## Testing + +[`flash-ext-security-test`](../../flash-ext-security-test/docs/README.md) authenticates requests as +any principal without an identity provider. diff --git a/flash-extensions/flash-ext-auth-oidc/pom.xml b/flash-extensions/flash-ext-security-core/pom.xml similarity index 66% rename from flash-extensions/flash-ext-auth-oidc/pom.xml rename to flash-extensions/flash-ext-security-core/pom.xml index 43aa29a..05c32db 100644 --- a/flash-extensions/flash-ext-auth-oidc/pom.xml +++ b/flash-extensions/flash-ext-security-core/pom.xml @@ -10,13 +10,9 @@ 2.1.0-SNAPSHOT - flash-ext-auth-oidc + flash-ext-security-core - - dev.relism - flash-ext-auth-core - dev.relism flash @@ -26,22 +22,14 @@ flash-ext-openapi true - - com.nimbusds - nimbus-jose-jwt - - - net.minidev - json-smart - - - org.projectlombok - lombok - org.junit.jupiter junit-jupiter + + dev.relism + flash-testing + test + - diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/Authenticated.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/Authenticated.java new file mode 100644 index 0000000..587f935 --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/Authenticated.java @@ -0,0 +1,12 @@ +package dev.relism.flash.ext.security; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** The handler or MCP tool requires an authenticated caller. */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@java.lang.annotation.Target(ElementType.TYPE) +public @interface Authenticated {} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/AuthenticationEntryPoint.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/AuthenticationEntryPoint.java new file mode 100644 index 0000000..ea56cb4 --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/AuthenticationEntryPoint.java @@ -0,0 +1,11 @@ +package dev.relism.flash.ext.security; + +import dev.relism.flash.models.Request; +import dev.relism.flash.models.Response; + +/** Answers a request that needs a caller and carries no credential at all. */ +@FunctionalInterface +public interface AuthenticationEntryPoint { + + Object commence(Request req, Response res) throws Exception; +} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/AuthenticationFailedException.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/AuthenticationFailedException.java new file mode 100644 index 0000000..ae74a1f --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/AuthenticationFailedException.java @@ -0,0 +1,24 @@ +package dev.relism.flash.ext.security; + +import dev.relism.flash.exceptions.HttpException; + +/** A credential was presented and rejected. Stackless: turning away forged tokens stays cheap. */ +public final class AuthenticationFailedException extends HttpException { + + private final String challenge; + + /** @param challenge the {@code WWW-Authenticate} value to answer with, or {@code null} */ + public AuthenticationFailedException(String challenge) { + super(401, "Unauthorized"); + this.challenge = challenge; + } + + public String challenge() { + return challenge; + } + + @Override + public synchronized Throwable fillInStackTrace() { + return this; + } +} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/AuthenticationMechanism.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/AuthenticationMechanism.java new file mode 100644 index 0000000..263fe37 --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/AuthenticationMechanism.java @@ -0,0 +1,21 @@ +package dev.relism.flash.ext.security; + +import dev.relism.flash.models.Request; + +/** + * Reads one kind of credential off a request. A mechanism never writes the response: an anonymous + * request is answered by the {@link AuthenticationEntryPoint}, a rejected one by the 401 its + * {@link AuthenticationFailedException} carries. + */ +public interface AuthenticationMechanism { + + /** + * The caller, or {@code null} when the request carries no credential of this kind. + * + * @throws AuthenticationFailedException the request carries one, and it is invalid + */ + Principal authenticate(Request req); + + /** How OpenAPI documents the credential and a 401 challenges for it; {@code null} for neither. */ + default SecurityScheme scheme() { return null; } +} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/InMemorySessionStore.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/InMemorySessionStore.java new file mode 100644 index 0000000..459ffeb --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/InMemorySessionStore.java @@ -0,0 +1,26 @@ +package dev.relism.flash.ext.security; + +import java.util.concurrent.ConcurrentHashMap; + +/** One instance's sessions, lost on restart. Expired ones are swept whenever a session is saved. */ +public final class InMemorySessionStore implements SessionStore { + + private final ConcurrentHashMap sessions = new ConcurrentHashMap<>(); + + @Override + public void save(Session session) { + long now = System.currentTimeMillis(); + sessions.values().removeIf(s -> s.expiresAt().toEpochMilli() <= now); + sessions.put(session.id(), session); + } + + @Override + public Session find(String id) { + return sessions.get(id); + } + + @Override + public void delete(String id) { + sessions.remove(id); + } +} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/LoginMethod.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/LoginMethod.java new file mode 100644 index 0000000..84721a8 --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/LoginMethod.java @@ -0,0 +1,12 @@ +package dev.relism.flash.ext.security; + +/** A way to sign in, listed at {@code GET /auth/methods} for a client to offer. */ +public record LoginMethod(String id, String name, String url, Kind kind) { + + public enum Kind { + /** A browser navigates to {@code url} and comes back signed in — OpenID Connect, for instance. */ + REDIRECT, + /** A client posts {@code username} and {@code password} to {@code url}. */ + FORM + } +} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/PermitAll.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/PermitAll.java new file mode 100644 index 0000000..dc4efaf --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/PermitAll.java @@ -0,0 +1,15 @@ +package dev.relism.flash.ext.security; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * Anyone may call the handler. A caller who authenticates is still identified; one whose credential + * is rejected is treated as anonymous rather than refused. + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@java.lang.annotation.Target(ElementType.TYPE) +public @interface PermitAll {} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/Principal.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/Principal.java new file mode 100644 index 0000000..e1972b2 --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/Principal.java @@ -0,0 +1,20 @@ +package dev.relism.flash.ext.security; + +/** + * Who an {@link AuthenticationMechanism} proved the caller to be. Each mechanism has its own type; + * {@link SecurityIdentity#principal(Class)} reads it back. + */ +public interface Principal { + + /** Unique within the mechanism that produced it. */ + String name(); + + /** Whether the credential grants {@code scope}. One that carries no scopes grants every scope. */ + default boolean hasScope(String scope) { return true; } + + /** Whether the credential was issued for {@code audience} (RFC 8707). One bound to no audience was. */ + default boolean hasAudience(String audience) { return true; } + + /** Where signing out sends the browser; {@code null} for the application root. */ + default String logoutUrl() { return null; } +} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/RoleResolver.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/RoleResolver.java new file mode 100644 index 0000000..05abb0b --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/RoleResolver.java @@ -0,0 +1,11 @@ +package dev.relism.flash.ext.security; + +/** + * Whether a caller holds a role — read from a token, a database, anywhere. {@code on} identifies + * the resource for roles held per resource rather than globally. + */ +@FunctionalInterface +public interface RoleResolver { + + boolean hasRole(SecurityIdentity identity, String role, Target on); +} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/RolesAllowed.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/RolesAllowed.java new file mode 100644 index 0000000..65a60e9 --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/RolesAllowed.java @@ -0,0 +1,24 @@ +package dev.relism.flash.ext.security; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** + * The caller must hold at least one of these roles, as decided by the configured + * {@link RoleResolver}. Implies {@link Authenticated}. + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@java.lang.annotation.Target(ElementType.TYPE) +public @interface RolesAllowed { + + String[] value(); + + /** + * Names of the path or query parameters (tool arguments on MCP) that identify the resource the + * role is held on — {@code on = "project"} checks the role on {@code /projects/{project}}. + */ + String[] on() default {}; +} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/ScopesAllowed.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/ScopesAllowed.java new file mode 100644 index 0000000..5bb26ba --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/ScopesAllowed.java @@ -0,0 +1,15 @@ +package dev.relism.flash.ext.security; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +/** The caller's credential must grant every one of these scopes. Implies {@link Authenticated}. */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@java.lang.annotation.Target(ElementType.TYPE) +public @interface ScopesAllowed { + + String[] value(); +} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SecurityExtension.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SecurityExtension.java new file mode 100644 index 0000000..6713fef --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SecurityExtension.java @@ -0,0 +1,321 @@ +package dev.relism.flash.ext.security; + +import dev.relism.flash.exceptions.HttpException; +import dev.relism.flash.ext.openapi.OpenApiContributor; +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.extension.FlashContext; +import dev.relism.flash.extension.FlashExtension; +import dev.relism.flash.extension.FlashRegistrar; +import dev.relism.flash.http.ContentType; +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 dev.relism.fpr.core.ByteView; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.time.Duration; +import java.time.Instant; +import java.util.Arrays; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Flash security: the authentication chain, the policies security annotations declare, sessions, + * and the {@code /auth/logout} and {@code /auth/methods} routes. Mechanisms register through + * {@link #mechanism}, directly or from their own extensions, and are tried in registration order + * before the session cookie. + * + *
    {@code
    + * app.install(new SecurityExtension().users(users).roles(roles))
    + *    .install(new OidcExtension(OidcProvider.of("sso", issuer, clientId, secret)));
    + * }
    + */ +public class SecurityExtension implements FlashExtension { + + /** The node security annotations mount under, for middleware that must run before or after it. */ + public static final MiddlewareKey POLICY = MiddlewareKey.of("flash.security.policy"); + + private static final String WWW_AUTHENTICATE = "WWW-Authenticate"; + private static final String COOKIE = "flash_session"; + private static final SecureRandom RANDOM = new SecureRandom(); + + private final Map, SessionRefresher> refreshers = new ConcurrentHashMap<>(); + private volatile AuthenticationMechanism[] mechanisms = {}; + private volatile SecurityScheme[] schemes = {}; + private volatile LoginMethod[] loginMethods = {}; + private volatile String challenges; + private volatile String methodsJson = "[]"; + + UserResolver users = principal -> principal; + RoleResolver roles; + private AuthenticationEntryPoint entryPoint = this::commence; + private SessionStore sessions = new InMemorySessionStore(); + private Duration sessionTimeout = Duration.ofHours(12); + private String loginPage = "/login"; + + // -- Configuration -------------------------------------------------------- + + /** Resolves {@link SecurityIdentity#user} — default: the principal itself. */ + public SecurityExtension users(UserResolver users) { + this.users = users; + return this; + } + + /** Required by {@link RolesAllowed}; a handler that declares roles without one fails the boot. */ + public SecurityExtension roles(RoleResolver roles) { + this.roles = roles; + return this; + } + + /** Replaces the default: a browser is redirected to sign in, anything else gets 401 with every challenge. */ + public SecurityExtension entryPoint(AuthenticationEntryPoint entryPoint) { + this.entryPoint = entryPoint; + return this; + } + + public SecurityExtension sessions(SessionStore sessions) { + this.sessions = sessions; + return this; + } + + public SecurityExtension sessionTimeout(Duration sessionTimeout) { + this.sessionTimeout = sessionTimeout; + return this; + } + + /** Where a browser signs in, unless the only {@link LoginMethod} is a redirect it can follow directly. */ + public SecurityExtension loginPage(String loginPage) { + this.loginPage = loginPage; + return this; + } + + // -- Registration (boot time) --------------------------------------------- + + public synchronized SecurityExtension mechanism(AuthenticationMechanism mechanism) { + mechanisms = append(mechanisms, mechanism); + return mechanism.scheme() == null ? this : scheme(mechanism.scheme()); + } + + /** Documents and challenges for a credential beyond the one {@link AuthenticationMechanism#scheme()} names. */ + public synchronized SecurityExtension scheme(SecurityScheme scheme) { + schemes = append(schemes, scheme); + challenges = challenges == null ? scheme.challenge() : challenges + ", " + scheme.challenge(); + return this; + } + + public synchronized SecurityExtension loginMethod(LoginMethod method) { + loginMethods = append(loginMethods, method); + StringBuilder json = new StringBuilder("["); + for (LoginMethod m : loginMethods) { + if (json.length() > 1) json.append(','); + json.append("{\"id\":\"").append(m.id()).append("\",\"name\":\"").append(m.name()) + .append("\",\"url\":\"").append(m.url()).append("\",\"kind\":\"").append(m.kind().name().toLowerCase()).append("\"}"); + } + methodsJson = json.append(']').toString(); + return this; + } + + public SecurityExtension refresher(Class type, SessionRefresher refresher) { + refreshers.put(type, refresher); + return this; + } + + /** The schemes of every registered mechanism, in registration order. */ + public List schemes() { + return List.of(schemes); + } + + // -- Runtime -------------------------------------------------------------- + + /** + * The caller, or {@code null} when no mechanism recognises a credential. + * + * @throws AuthenticationFailedException a mechanism recognised one and rejected it + */ + public SecurityIdentity authenticate(Request req) { + for (AuthenticationMechanism mechanism : mechanisms) { + Principal principal = mechanism.authenticate(req); + if (principal != null) return new SecurityIdentity(principal, this, req); + } + Principal principal = sessionPrincipal(req); + return principal == null ? null : new SecurityIdentity(principal, this, req); + } + + /** + * The policy {@code type}'s annotations declare, checked against this configuration — declaring + * roles without a {@link RoleResolver} fails here, at boot. {@code null} for no annotations. + */ + public SecurityPolicy policy(Class type) { + SecurityPolicy policy = SecurityPolicy.of(type); + if (policy != null && policy.requiresRoles() && roles == null) { + throw new IllegalStateException(type.getName() + " declares @RolesAllowed, but no RoleResolver is configured — SecurityExtension.roles(...)"); + } + return policy; + } + + public Middleware enforce(SecurityPolicy policy) { + return enforce(policy, entryPoint); + } + + /** {@code anonymous} answers a caller without credentials on this route instead of the configured entry point. */ + public Middleware enforce(SecurityPolicy policy, AuthenticationEntryPoint anonymous) { + return next -> (req, res) -> { + SecurityIdentity identity; + try { + identity = authenticate(req); + } catch (AuthenticationFailedException rejected) { + if (policy.required) { + if (rejected.challenge() != null) res.header(WWW_AUTHENTICATE, rejected.challenge()); + throw rejected; + } + identity = null; + } + if (identity == null) { + if (policy.required) return anonymous.commence(req, res); + } else if (!policy.permitsScopes(identity)) { + res.header(WWW_AUTHENTICATE, policy.scopeChallenge); + throw HttpException.forbidden(); + } else if (!policy.permitsRoles(identity, policy.on.length == 0 ? Target.NONE : name -> { + String value = req.param(name); + return value != null ? value : req.query(name); + })) { + throw HttpException.forbidden(); + } + SecurityIdentity.CURRENT.set(identity); + try { + return next.handle(req, res); + } finally { + SecurityIdentity.CURRENT.remove(); + } + }; + } + + /** Starts a session for {@code principal} lasting the configured timeout. */ + public void signIn(Request req, Response res, Principal principal) { + signIn(req, res, principal, Instant.now().plus(sessionTimeout)); + } + + public void signIn(Request req, Response res, Principal principal, Instant expiresAt) { + byte[] id = new byte[24]; + RANDOM.nextBytes(id); + Session session = new Session(Base64.getUrlEncoder().withoutPadding().encodeToString(id), principal, expiresAt); + sessions.save(session); + res.header("Set-Cookie", COOKIE + "=" + session.id() + "; Path=/; HttpOnly; SameSite=Lax" + (req.origin().startsWith("https") ? "; Secure" : "")); + } + + /** Ends the caller's session and returns where the browser goes next. */ + public String signOut(Request req, Response res) { + String id = req.cookie(COOKIE); + Session session = id == null ? null : sessions.find(id); + if (session != null) sessions.delete(id); + res.header("Set-Cookie", COOKIE + "=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax"); + String next = session == null ? null : session.principal().logoutUrl(); + return next == null ? "/" : next; + } + + // -- Extension ------------------------------------------------------------ + + @Override + public void configure(FlashRegistrar app, FlashContext ctx) { + ctx.provide(SecurityExtension.class, this); + ctx.addAnnotationProcessor(handler -> { + SecurityPolicy policy = policy(handler); + return policy == null ? List.of() : List.of(MiddlewareNode.of(POLICY, enforce(policy))); + }); + app.post("/auth/logout", (req, res) -> { + res.status(303).header("Location", signOut(req, res)); + return null; + }); + app.get("/auth/methods", (req, res) -> { + res.type(ContentType.JSON); + return methodsJson; + }); + ctx.onReady(() -> { + try { + OpenApi.register(ctx, this); + } catch (NoClassDefFoundError absent) { + // flash-ext-openapi is not on the classpath + } + }); + } + + private Object commence(Request req, Response res) { + LoginMethod[] methods = loginMethods; + String accept = req.header("Accept"); + if (methods.length > 0 && accept != null && accept.contains("text/html")) { + String login = methods.length == 1 && methods[0].kind() == LoginMethod.Kind.REDIRECT ? methods[0].url() : loginPage; + ByteView query = req.getRequestLine().getQuery(); + byte[] raw = new byte[query == null ? 0 : query.length()]; + for (int i = 0; i < raw.length; i++) raw[i] = query.byteAt(i); + String target = raw.length == 0 ? req.path() : req.path() + "?" + new String(raw, StandardCharsets.UTF_8); + res.redirect(login + "?redirect=" + URLEncoder.encode(target, StandardCharsets.UTF_8)); + return null; + } + if (challenges != null) res.header(WWW_AUTHENTICATE, challenges); + throw HttpException.unauthorized(); + } + + private Principal sessionPrincipal(Request req) { + String id = req.cookie(COOKIE); + Session session = id == null ? null : sessions.find(id); + if (session == null) return null; + if (session.expiresAt().toEpochMilli() > System.currentTimeMillis()) return session.principal(); + SessionRefresher refresher = refreshers.get(session.principal().getClass()); + Session renewed = refresher == null ? null : refresher.refresh(session); + if (renewed == null) { + sessions.delete(id); + return null; + } + sessions.save(renewed); + return renewed.principal(); + } + + private static T[] append(T[] array, T element) { + T[] grown = Arrays.copyOf(array, array.length + 1); + grown[array.length] = element; + return grown; + } + + /** Isolated so this extension loads without flash-ext-openapi on the classpath. */ + private static final class OpenApi { + + static void register(FlashContext ctx, SecurityExtension security) { + ctx.find(OpenApiContributorRegistry.class).ifPresent(registry -> registry.add(new OpenApiContributor() { + @Override + public Map componentContributions() { + Map definitions = new LinkedHashMap<>(); + for (SecurityScheme scheme : security.schemes) definitions.put(scheme.name(), scheme.definition()); + return definitions.isEmpty() ? Map.of() : Map.of("securitySchemes", definitions); + } + + @Override + public OpenApiOperationContribution operationFor(Class handler) { + SecurityPolicy policy = SecurityPolicy.of(handler); + if (policy == null || !policy.required) return OpenApiOperationContribution.empty(); + OpenApiOperationContribution.Builder operation = OpenApiOperationContribution.builder(); + for (SecurityScheme scheme : security.schemes) { + operation.security(scheme.name(), scheme.issuer() != null ? List.of(policy.scopes) : List.of()); + } + operation.response(401, OpenApiResponseContribution.of("Authentication required")); + String roles = policy.roles.length == 0 ? null : "Requires role " + String.join(" or ", policy.roles) + + (policy.on.length == 0 ? "" : " on " + String.join(", ", policy.on)); + String scopes = policy.scopes.length == 0 ? null : "Requires scopes " + String.join(" ", policy.scopes); + if (roles != null || scopes != null) { + operation.response(403, OpenApiResponseContribution.of( + roles == null ? scopes : scopes == null ? roles : roles + "; " + scopes)); + } + return operation.build(); + } + })); + } + } +} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SecurityIdentity.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SecurityIdentity.java new file mode 100644 index 0000000..121d91c --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SecurityIdentity.java @@ -0,0 +1,65 @@ +package dev.relism.flash.ext.security; + +import dev.relism.flash.models.Request; + +/** + * The authenticated caller of the current request: the {@link Principal} a mechanism produced, the + * application user it resolves to, and the roles and scopes it holds. + */ +public final class SecurityIdentity { + + static final ThreadLocal CURRENT = new ThreadLocal<>(); + + private final Principal principal; + private final SecurityExtension security; + private final Request request; + private Object user; + + SecurityIdentity(Principal principal, SecurityExtension security, Request request) { + this.principal = principal; + this.security = security; + this.request = request; + } + + /** The caller of the request this thread is handling; {@code null} when it is anonymous. */ + public static SecurityIdentity current() { + return CURRENT.get(); + } + + public Principal principal() { + return principal; + } + + /** + * The request being authorized. {@link Target} carries what {@link RolesAllowed#on()} names, read + * from path and query parameters; a {@link RoleResolver} whose scope is somewhere else — a tenant + * header, say — reads it from here. + */ + public Request request() { + return request; + } + + /** The principal as {@code type}, or {@code null} when another mechanism authenticated the caller. */ + public

    P principal(Class

    type) { + return type.isInstance(principal) ? type.cast(principal) : null; + } + + /** The application user, resolved once per request by the configured {@link UserResolver}. */ + public U user(Class type) { + if (user == null) user = security.users.resolve(principal); + return type.cast(user); + } + + public boolean hasScope(String scope) { + return principal.hasScope(scope); + } + + public boolean hasRole(String role) { + return hasRole(role, Target.NONE); + } + + public boolean hasRole(String role, Target on) { + if (security.roles == null) throw new IllegalStateException("No RoleResolver configured — SecurityExtension.roles(...)"); + return security.roles.hasRole(this, role, on); + } +} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SecurityPolicy.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SecurityPolicy.java new file mode 100644 index 0000000..891bf43 --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SecurityPolicy.java @@ -0,0 +1,63 @@ +package dev.relism.flash.ext.security; + +/** What a handler's or tool's security annotations require, compiled once at boot. Checks allocate nothing. */ +public final class SecurityPolicy { + + /** Any authenticated caller. */ + public static final SecurityPolicy AUTHENTICATED = new SecurityPolicy(true, new String[0], new String[0], new String[0]); + + final boolean required; + final String[] roles; + final String[] on; + final String[] scopes; + final String scopeChallenge; + + private SecurityPolicy(boolean required, String[] roles, String[] on, String[] scopes) { + this.required = required; + this.roles = roles; + this.on = on; + this.scopes = scopes; + this.scopeChallenge = "Bearer error=\"insufficient_scope\", scope=\"" + String.join(" ", scopes) + "\""; + } + + /** The policy {@code type} declares, or {@code null} when it carries no security annotation. */ + public static SecurityPolicy of(Class type) { + boolean permitAll = type.isAnnotationPresent(PermitAll.class); + boolean authenticated = type.isAnnotationPresent(Authenticated.class); + RolesAllowed roles = type.getAnnotation(RolesAllowed.class); + ScopesAllowed scopes = type.getAnnotation(ScopesAllowed.class); + if (!permitAll && !authenticated && roles == null && scopes == null) return null; + if (permitAll && (authenticated || roles != null || scopes != null)) { + throw new IllegalStateException("@PermitAll contradicts the other security annotations on " + type.getName()); + } + return new SecurityPolicy(!permitAll, + roles == null ? AUTHENTICATED.roles : values(roles.value(), "@RolesAllowed", type), + roles == null ? AUTHENTICATED.on : roles.on(), + scopes == null ? AUTHENTICATED.scopes : values(scopes.value(), "@ScopesAllowed", type)); + } + + /** False only for {@link PermitAll}. */ + public boolean required() { + return required; + } + + public boolean requiresRoles() { + return roles.length > 0; + } + + public boolean permitsScopes(SecurityIdentity identity) { + for (String scope : scopes) if (!identity.hasScope(scope)) return false; + return true; + } + + public boolean permitsRoles(SecurityIdentity identity, Target target) { + if (roles.length == 0) return true; + for (String role : roles) if (identity.hasRole(role, target)) return true; + return false; + } + + private static String[] values(String[] values, String annotation, Class type) { + if (values.length == 0) throw new IllegalStateException(annotation + " on " + type.getName() + " names nothing"); + return values; + } +} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SecurityScheme.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SecurityScheme.java new file mode 100644 index 0000000..36c48b0 --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SecurityScheme.java @@ -0,0 +1,24 @@ +package dev.relism.flash.ext.security; + +import java.util.Map; + +/** + * A credential as OpenAPI names and defines it, the {@code WWW-Authenticate} challenge an anonymous + * API call receives for it, and — for OAuth — the issuer that grants it. + * + * @param definition the OpenAPI Security Scheme Object, verbatim + * @param issuer the authorization server's issuer identifier, {@code null} for anything else + */ +public record SecurityScheme(String name, Map definition, String challenge, String issuer) { + + public static SecurityScheme bearer(String name, String bearerFormat) { + return new SecurityScheme(name, Map.of("type", "http", "scheme", "bearer", "bearerFormat", bearerFormat), + "Bearer realm=\"" + name + "\"", null); + } + + public static SecurityScheme openIdConnect(String name, String issuer) { + return new SecurityScheme(name, + Map.of("type", "openIdConnect", "openIdConnectUrl", issuer + (issuer.endsWith("/") ? "" : "/") + ".well-known/openid-configuration"), + "Bearer realm=\"" + name + "\"", issuer); + } +} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/Session.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/Session.java new file mode 100644 index 0000000..478fe75 --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/Session.java @@ -0,0 +1,6 @@ +package dev.relism.flash.ext.security; + +import java.time.Instant; + +/** A signed-in principal, kept server-side under the id its cookie carries. */ +public record Session(String id, Principal principal, Instant expiresAt) {} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SessionRefresher.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SessionRefresher.java new file mode 100644 index 0000000..d4a08cd --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SessionRefresher.java @@ -0,0 +1,8 @@ +package dev.relism.flash.ext.security; + +/** Renews an expired session — with a refresh token, typically. {@code null} ends it instead. */ +@FunctionalInterface +public interface SessionRefresher { + + Session refresh(Session expired); +} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SessionStore.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SessionStore.java new file mode 100644 index 0000000..3f21ad3 --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/SessionStore.java @@ -0,0 +1,12 @@ +package dev.relism.flash.ext.security; + +/** Where sessions live. {@link InMemorySessionStore} unless one that survives restarts is configured. */ +public interface SessionStore { + + void save(Session session); + + /** The session, or {@code null}. */ + Session find(String id); + + void delete(String id); +} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/Target.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/Target.java new file mode 100644 index 0000000..868c532 --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/Target.java @@ -0,0 +1,15 @@ +package dev.relism.flash.ext.security; + +/** + * The resource a role is checked on: the values {@link RolesAllowed#on()} names, read from path + * and query parameters on HTTP and from tool arguments on MCP. + */ +@FunctionalInterface +public interface Target { + + /** A role checked on nothing in particular. */ + Target NONE = name -> null; + + /** The value called {@code name}, or {@code null} when the call does not carry one. */ + String get(String name); +} diff --git a/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/UserResolver.java b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/UserResolver.java new file mode 100644 index 0000000..085981e --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/main/java/dev/relism/flash/ext/security/UserResolver.java @@ -0,0 +1,8 @@ +package dev.relism.flash.ext.security; + +/** The application user a verified principal belongs to — typically found, or provisioned, by issuer and subject. */ +@FunctionalInterface +public interface UserResolver { + + U resolve(Principal principal); +} diff --git a/flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/SecurityExtensionTest.java b/flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/SecurityExtensionTest.java new file mode 100644 index 0000000..c84ffd5 --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/SecurityExtensionTest.java @@ -0,0 +1,132 @@ +package dev.relism.flash.ext.security; + +import dev.relism.flash.ext.openapi.OpenApiExtension; +import dev.relism.flash.models.Request; +import dev.relism.flash.testing.FlashTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import java.time.Instant; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +class SecurityExtensionTest { + + /** {@code Authorization: Key [ ...]}; {@code Key !} is a presented, invalid credential. */ + record KeyPrincipal(String name, Set scopes) implements Principal { + @Override public boolean hasScope(String scope) { return scopes.contains(scope); } + } + + static final AuthenticationMechanism KEY = new AuthenticationMechanism() { + @Override + public Principal authenticate(Request req) { + String header = req.header("Authorization"); + if (header == null || !header.startsWith("Key ")) return null; + String[] parts = header.substring(4).split(" "); + if (parts[0].equals("!")) throw new AuthenticationFailedException("Key error=\"invalid_token\""); + return new KeyPrincipal(parts[0], Set.of(java.util.Arrays.copyOfRange(parts, 1, parts.length))); + } + + @Override + public SecurityScheme scheme() { + return SecurityScheme.bearer("key", "opaque"); + } + }; + + static final SecurityExtension security = new SecurityExtension() + .roles((identity, role, on) -> identity.principal().name().equals(role + "@" + on.get("project"))) + .mechanism(KEY) + .loginMethod(new LoginMethod("key", "Key", "/auth/key/login", LoginMethod.Kind.REDIRECT)) + .refresher(KeyPrincipal.class, expired -> new Session(expired.id(), expired.principal(), Instant.now().plusSeconds(60))); + + @RegisterExtension + static final FlashTest app = FlashTest.of(flash -> flash + .install(security) + .install(new OpenApiExtension("/openapi", "test", "1")) + .get("/me", (req, res) -> SecurityIdentity.current().principal().name(), security.enforce(SecurityPolicy.AUTHENTICATED)) + .post("/login", (req, res) -> { + security.signIn(req, res, new KeyPrincipal("carol", Set.of()), Instant.now().minusSeconds(1)); + return "signed in"; + }) + .scan("dev.relism.flash.ext.security.fixtures")); + + @Test + void aValidCredentialIdentifiesTheCaller() { + app.request().header("Authorization", "Key alice").get("/me").expectStatus(200).expectBody("alice"); + } + + /** Rejected is never anonymous: a browser presenting a bad credential must not be sent to sign in. */ + @Test + void aRejectedCredentialIs401WithItsOwnChallengeEvenForABrowser() { + app.request().header("Authorization", "Key !").header("Accept", "text/html").get("/me") + .expectStatus(401) + .expectHeader("WWW-Authenticate", "Key error=\"invalid_token\""); + } + + @Test + void anApiCallWithoutCredentialsIsChallengedForEveryMechanism() { + app.get("/me").expectStatus(401).expectHeader("WWW-Authenticate", "Bearer realm=\"key\""); + } + + @Test + void aBrowserWithoutCredentialsGoesStraightToTheOnlyLoginMethod() { + app.request().header("Accept", "text/html").get("/me?tab=keys") + .expectStatus(302) + .expectHeader("Location", "/auth/key/login?redirect=%2Fme%3Ftab%3Dkeys"); + } + + @Test + void permitAllIdentifiesWhoeverAuthenticatesAndToleratesEveryoneElse() { + app.request().header("Authorization", "Key bob").get("/open").expectBody("bob"); + app.request().header("Authorization", "Key !").get("/open").expectStatus(200).expectBody("anonymous"); + app.get("/open").expectBody("anonymous"); + } + + @Test + void rolesAreCheckedOnTheResourceThePathNames() { + app.request().header("Authorization", "Key MANAGER@42").get("/projects/42").expectStatus(200); + app.request().header("Authorization", "Key MANAGER@42").get("/projects/7").expectStatus(403); + } + + @Test + void aMissingScopeIs403WithAnInsufficientScopeChallenge() { + app.request().header("Authorization", "Key dave write").post("/write").expectStatus(200); + app.request().header("Authorization", "Key dave").post("/write") + .expectStatus(403) + .expectHeader("WWW-Authenticate", "Bearer error=\"insufficient_scope\", scope=\"write\""); + } + + /** The session is signed in already expired, so reaching /me proves the refresher ran. */ + @Test + void aSessionIsRefreshedWhenExpiredAndEndedBySigningOut() { + String cookie = app.request().post("/login").expectStatus(200).header("Set-Cookie"); + String session = cookie.substring(0, cookie.indexOf(';')); + + app.request().header("Cookie", session).get("/me").expectStatus(200).expectBody("carol"); + app.request().header("Cookie", session).post("/auth/logout").expectStatus(303).expectHeader("Location", "/"); + app.request().header("Cookie", session).get("/me").expectStatus(401); + } + + @Test + void loginMethodsAreListed() { + app.get("/auth/methods").expectStatus(200).expectBody("[{\"id\":\"key\",\"name\":\"Key\",\"url\":\"/auth/key/login\",\"kind\":\"redirect\"}]"); + } + + @Test + void openApiDocumentsEachSchemeAndWhatEachOperationRequires() { + app.get("/openapi.json").expectStatus(200) + .expectBodyContains("\"securitySchemes\"") + .expectBodyContains("\"bearerFormat\":\"opaque\"") + .expectBodyContains("Requires role MANAGER on project") + .expectBodyContains("Requires scopes write"); + } + + @Test + void declaringRolesWithoutAResolverFailsTheBoot() { + FlashTest broken = FlashTest.of(flash -> flash + .install(new SecurityExtension()) + .scan("dev.relism.flash.ext.security.fixtures")); + assertThrows(Exception.class, () -> broken.get("/open")); + } +} diff --git a/flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/SecurityPolicyTest.java b/flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/SecurityPolicyTest.java new file mode 100644 index 0000000..c733077 --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/SecurityPolicyTest.java @@ -0,0 +1,44 @@ +package dev.relism.flash.ext.security; + +import org.junit.jupiter.api.Test; + +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; + +class SecurityPolicyTest { + + static class Unannotated {} + + @PermitAll @RolesAllowed("ADMIN") + static class Contradictory {} + + @RolesAllowed({}) + static class NoRoles {} + + @RolesAllowed("ADMIN") @ScopesAllowed("write") + static class RolesAndScopes {} + + @Test + void anUnannotatedTypeHasNoPolicy() { + assertNull(SecurityPolicy.of(Unannotated.class)); + } + + @Test + void contradictionsAndEmptyRequirementsFailAtCompileTime() { + assertThrows(IllegalStateException.class, () -> SecurityPolicy.of(Contradictory.class)); + assertThrows(IllegalStateException.class, () -> SecurityPolicy.of(NoRoles.class)); + } + + @Test + void rolesAndScopesBothRequireAuthentication() { + SecurityPolicy policy = SecurityPolicy.of(RolesAndScopes.class); + assertTrue(policy.required()); + assertTrue(policy.requiresRoles()); + assertFalse(SecurityPolicy.of(OpenAccess.class).required()); + } + + @PermitAll + static class OpenAccess {} +} diff --git a/flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/fixtures/OpenHandler.java b/flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/fixtures/OpenHandler.java new file mode 100644 index 0000000..a840ee3 --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/fixtures/OpenHandler.java @@ -0,0 +1,18 @@ +package dev.relism.flash.ext.security.fixtures; + +import dev.relism.flash.ext.security.PermitAll; +import dev.relism.flash.ext.security.SecurityIdentity; +import dev.relism.flash.models.Request; +import dev.relism.flash.models.RequestHandler; +import dev.relism.flash.models.Response; +import dev.relism.flash.routing.GET; + +@GET("/open") +@PermitAll +public final class OpenHandler extends RequestHandler { + @Override + public Object handle(Request req, Response res) { + SecurityIdentity identity = SecurityIdentity.current(); + return identity == null ? "anonymous" : identity.principal().name(); + } +} diff --git a/flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/fixtures/ProjectHandler.java b/flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/fixtures/ProjectHandler.java new file mode 100644 index 0000000..9454cb2 --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/fixtures/ProjectHandler.java @@ -0,0 +1,19 @@ +package dev.relism.flash.ext.security.fixtures; + +import dev.relism.flash.ext.openapi.ApiOperation; +import dev.relism.flash.ext.security.RolesAllowed; +import dev.relism.flash.ext.security.SecurityIdentity; +import dev.relism.flash.models.Request; +import dev.relism.flash.models.RequestHandler; +import dev.relism.flash.models.Response; +import dev.relism.flash.routing.GET; + +@GET("/projects/{project}") +@ApiOperation(summary = "fixture") +@RolesAllowed(value = "MANAGER", on = "project") +public final class ProjectHandler extends RequestHandler { + @Override + public Object handle(Request req, Response res) { + return SecurityIdentity.current().principal().name(); + } +} diff --git a/flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/fixtures/WriteHandler.java b/flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/fixtures/WriteHandler.java new file mode 100644 index 0000000..72ee52d --- /dev/null +++ b/flash-extensions/flash-ext-security-core/src/test/java/dev/relism/flash/ext/security/fixtures/WriteHandler.java @@ -0,0 +1,18 @@ +package dev.relism.flash.ext.security.fixtures; + +import dev.relism.flash.ext.openapi.ApiOperation; +import dev.relism.flash.ext.security.ScopesAllowed; +import dev.relism.flash.models.Request; +import dev.relism.flash.models.RequestHandler; +import dev.relism.flash.models.Response; +import dev.relism.flash.routing.POST; + +@POST("/write") +@ApiOperation(summary = "fixture") +@ScopesAllowed("write") +public final class WriteHandler extends RequestHandler { + @Override + public Object handle(Request req, Response res) { + return "written"; + } +} diff --git a/flash-extensions/flash-ext-security-form/docs/README.md b/flash-extensions/flash-ext-security-form/docs/README.md new file mode 100644 index 0000000..9838c09 --- /dev/null +++ b/flash-extensions/flash-ext-security-form/docs/README.md @@ -0,0 +1,17 @@ +# flash-ext-security-form + +Password sign-in for [`flash-ext-security-core`](../../flash-ext-security-core/docs/README.md). + +```java +app.install(new SecurityExtension()) + .install(new FormLoginExtension(username -> accounts.find(username))); // PasswordStore +``` + +`POST /auth/form/login` takes `username` and `password` form-encoded, starts a session and answers +`303` to `?redirect=` (same-origin paths only) or `/`. A wrong password and an unknown account are the +same `401`, and cost the same time. The method is listed at `/auth/methods` with `"kind":"form"`, and +the entry point sends browsers to `SecurityExtension.loginPage` to render it. + +`PasswordEncoder.pbkdf2()` hashes (PBKDF2-HMAC-SHA256, 600k iterations, JDK only); use it to create +accounts, or pass another encoder to `passwordEncoder(...)`. Rate-limit the route with +`flash-ext-limiter`. diff --git a/flash-extensions/flash-ext-auth-core/pom.xml b/flash-extensions/flash-ext-security-form/pom.xml similarity index 72% rename from flash-extensions/flash-ext-auth-core/pom.xml rename to flash-extensions/flash-ext-security-form/pom.xml index abc299f..2da0682 100644 --- a/flash-extensions/flash-ext-auth-core/pom.xml +++ b/flash-extensions/flash-ext-security-form/pom.xml @@ -10,16 +10,21 @@ 2.1.0-SNAPSHOT - flash-ext-auth-core + flash-ext-security-form dev.relism - flash + flash-ext-security-core org.junit.jupiter junit-jupiter + + dev.relism + flash-testing + test + diff --git a/flash-extensions/flash-ext-security-form/src/main/java/dev/relism/flash/ext/security/form/FormLoginExtension.java b/flash-extensions/flash-ext-security-form/src/main/java/dev/relism/flash/ext/security/form/FormLoginExtension.java new file mode 100644 index 0000000..18c0f4d --- /dev/null +++ b/flash-extensions/flash-ext-security-form/src/main/java/dev/relism/flash/ext/security/form/FormLoginExtension.java @@ -0,0 +1,74 @@ +package dev.relism.flash.ext.security.form; + +import dev.relism.flash.exceptions.HttpException; +import dev.relism.flash.ext.security.LoginMethod; +import dev.relism.flash.ext.security.SecurityExtension; +import dev.relism.flash.extension.FlashContext; +import dev.relism.flash.extension.FlashExtension; +import dev.relism.flash.extension.FlashRegistrar; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; + +/** + * Password sign-in: {@code POST /auth/form/login} with {@code username} and {@code password} + * form-encoded — what an HTML form sends, and what {@code fetch} sends given {@code URLSearchParams}. + * Success starts a session and answers {@code 303} to {@code redirect} (same-origin paths only) or + * {@code /}; failure is a {@code 401}, identical for an unknown account and a wrong password. + * + *

    {@code
    + * app.install(new SecurityExtension())
    + *    .install(new FormLoginExtension(accounts::byUsername));
    + * }
    + * + * Rate-limit the route with {@code flash-ext-limiter}; this extension does not. + */ +public final class FormLoginExtension implements FlashExtension { + + public static final String LOGIN = "/auth/form/login"; + + private final PasswordStore store; + private PasswordEncoder encoder = PasswordEncoder.pbkdf2(); + private String unknownAccountHash; + + public FormLoginExtension(PasswordStore store) { + this.store = store; + } + + public FormLoginExtension passwordEncoder(PasswordEncoder encoder) { + this.encoder = encoder; + return this; + } + + @Override + public void configure(FlashRegistrar app, FlashContext ctx) { + // Checked against when no account matches, so an unknown username costs what a wrong password does. + unknownAccountHash = encoder.encode("unknown-account"); + app.post(LOGIN, (req, res) -> { + String body = new String(req.body().bytes(), StandardCharsets.UTF_8); + String username = field(body, "username"); + String password = field(body, "password"); + if (username == null || password == null) throw HttpException.badRequest("username and password are required"); + + PasswordStore.Account account = store.find(username); + boolean matches = encoder.matches(password, account == null ? unknownAccountHash : account.passwordHash()); + if (account == null || !matches) throw HttpException.unauthorized(); + + ctx.require(SecurityExtension.class).signIn(req, res, account.principal()); + String redirect = req.query("redirect"); + boolean local = redirect != null && redirect.startsWith("/") && !redirect.startsWith("//") && !redirect.startsWith("/\\"); + res.status(303).header("Location", local ? redirect : "/"); + return null; + }); + ctx.onReady(() -> ctx.require(SecurityExtension.class) + .loginMethod(new LoginMethod("form", "Password", LOGIN, LoginMethod.Kind.FORM))); + } + + private static String field(String body, String name) { + for (String pair : body.split("&")) { + int eq = pair.indexOf('='); + if (eq == name.length() && pair.startsWith(name)) return URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8); + } + return null; + } +} diff --git a/flash-extensions/flash-ext-security-form/src/main/java/dev/relism/flash/ext/security/form/PasswordEncoder.java b/flash-extensions/flash-ext-security-form/src/main/java/dev/relism/flash/ext/security/form/PasswordEncoder.java new file mode 100644 index 0000000..3b8210b --- /dev/null +++ b/flash-extensions/flash-ext-security-form/src/main/java/dev/relism/flash/ext/security/form/PasswordEncoder.java @@ -0,0 +1,58 @@ +package dev.relism.flash.ext.security.form; + +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; + +/** One-way password hashing. {@link #pbkdf2()} unless another is configured. */ +public interface PasswordEncoder { + + String encode(CharSequence password); + + /** Constant-time; {@code false} for anything this encoder did not produce. */ + boolean matches(CharSequence password, String encoded); + + /** PBKDF2-HMAC-SHA256, 600,000 iterations (OWASP 2023), a random 16-byte salt — JDK only. */ + static PasswordEncoder pbkdf2() { + return Pbkdf2.INSTANCE; + } + + enum Pbkdf2 implements PasswordEncoder { + INSTANCE; + + private static final int ITERATIONS = 600_000; + private static final SecureRandom RANDOM = new SecureRandom(); + + @Override + public String encode(CharSequence password) { + byte[] salt = new byte[16]; + RANDOM.nextBytes(salt); + return "pbkdf2$" + ITERATIONS + "$" + Base64.getEncoder().encodeToString(salt) + + "$" + Base64.getEncoder().encodeToString(derive(password, salt, ITERATIONS)); + } + + @Override + public boolean matches(CharSequence password, String encoded) { + String[] parts = encoded == null ? new String[0] : encoded.split("\\$"); + if (parts.length != 4 || !parts[0].equals("pbkdf2")) return false; + byte[] salt = Base64.getDecoder().decode(parts[2]); + return MessageDigest.isEqual(derive(password, salt, Integer.parseInt(parts[1])), Base64.getDecoder().decode(parts[3])); + } + + private static byte[] derive(CharSequence password, byte[] salt, int iterations) { + char[] chars = password.toString().toCharArray(); + PBEKeySpec spec = new PBEKeySpec(chars, salt, iterations, 256); + try { + return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).getEncoded(); + } catch (GeneralSecurityException impossible) { + throw new IllegalStateException(impossible); + } finally { + spec.clearPassword(); + java.util.Arrays.fill(chars, '\0'); + } + } + } +} diff --git a/flash-extensions/flash-ext-security-form/src/main/java/dev/relism/flash/ext/security/form/PasswordStore.java b/flash-extensions/flash-ext-security-form/src/main/java/dev/relism/flash/ext/security/form/PasswordStore.java new file mode 100644 index 0000000..c4fd1ef --- /dev/null +++ b/flash-extensions/flash-ext-security-form/src/main/java/dev/relism/flash/ext/security/form/PasswordStore.java @@ -0,0 +1,14 @@ +package dev.relism.flash.ext.security.form; + +import dev.relism.flash.ext.security.Principal; + +/** Where the application keeps the accounts that sign in with a password. */ +@FunctionalInterface +public interface PasswordStore { + + /** The account signing in as {@code username}, or {@code null}. */ + Account find(String username); + + /** @param passwordHash as produced by the configured {@link PasswordEncoder} */ + record Account(Principal principal, String passwordHash) {} +} diff --git a/flash-extensions/flash-ext-security-form/src/test/java/dev/relism/flash/ext/security/form/FormLoginExtensionTest.java b/flash-extensions/flash-ext-security-form/src/test/java/dev/relism/flash/ext/security/form/FormLoginExtensionTest.java new file mode 100644 index 0000000..9bd0280 --- /dev/null +++ b/flash-extensions/flash-ext-security-form/src/test/java/dev/relism/flash/ext/security/form/FormLoginExtensionTest.java @@ -0,0 +1,63 @@ +package dev.relism.flash.ext.security.form; + +import dev.relism.flash.ext.security.SecurityExtension; +import dev.relism.flash.ext.security.SecurityIdentity; +import dev.relism.flash.ext.security.SecurityPolicy; +import dev.relism.flash.testing.FlashResponse; +import dev.relism.flash.testing.FlashTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class FormLoginExtensionTest { + + static final String HASH = PasswordEncoder.pbkdf2().encode("s3cret"); + static final SecurityExtension security = new SecurityExtension(); + + @RegisterExtension + static final FlashTest app = FlashTest.of(flash -> flash + .install(security) + .install(new FormLoginExtension(username -> username.equals("alice") + ? new PasswordStore.Account(() -> "alice", HASH) : null)) + .get("/me", (req, res) -> SecurityIdentity.current().principal().name(), security.enforce(SecurityPolicy.AUTHENTICATED))); + + static FlashResponse login(String form, String query) { + return app.request().header("content-type", "application/x-www-form-urlencoded").body(form) + .post(FormLoginExtension.LOGIN + query); + } + + @Test + void theRightPasswordStartsASessionAndRedirectsToALocalPath() { + var response = login("username=alice&password=s3cret", "?redirect=%2Fprojects") + .expectStatus(303).expectHeader("Location", "/projects"); + String cookie = response.header("Set-Cookie"); + app.request().header("Cookie", cookie.substring(0, cookie.indexOf(';'))).get("/me").expectBody("alice"); + } + + @Test + void aForeignRedirectIsIgnored() { + login("username=alice&password=s3cret", "?redirect=%2F%2Fevil.example").expectHeader("Location", "/"); + } + + @Test + void aWrongPasswordAndAnUnknownAccountAreTheSame401() { + assertNull(login("username=alice&password=nope", "").expectStatus(401).header("Set-Cookie")); + login("username=mallory&password=s3cret", "").expectStatus(401); + } + + @Test + void pbkdf2RoundTripsAndRejectsWhatItDidNotProduce() { + PasswordEncoder encoder = PasswordEncoder.pbkdf2(); + assertTrue(encoder.matches("s3cret", HASH)); + assertFalse(encoder.matches("s3cret", "plain")); + assertFalse(encoder.matches("s3cret", null)); + } + + @Test + void theFormIsListedAsALoginMethod() { + app.get("/auth/methods").expectBodyContains("\"kind\":\"form\""); + } +} diff --git a/flash-extensions/flash-ext-security-oidc/docs/README.md b/flash-extensions/flash-ext-security-oidc/docs/README.md new file mode 100644 index 0000000..d186ba6 --- /dev/null +++ b/flash-extensions/flash-ext-security-oidc/docs/README.md @@ -0,0 +1,71 @@ +# flash-ext-security-oidc + +OpenID Connect for [`flash-ext-security-core`](../../flash-ext-security-core/docs/README.md): bearer +access tokens, and browser sign-in through the authorization code flow with PKCE. + +```java +app.install(new SecurityExtension()) + .install(new OidcExtension( + OidcProvider.of("sso", "https://id.example.com/realms/acme", "app", secret).name("Acme SSO"), + OidcProvider.of("partner", "https://login.partner.example/", "app", partnerSecret))); +``` + +Discovery runs at boot, so an unreachable provider fails the start rather than the first sign-in. + +## Bearer tokens + +`Authorization: Bearer ` is matched to its provider by `iss`, then verified against that +provider's keys (RS/PS/ES algorithms, `typ` `JWT` or `at+jwt`, `iss`, `sub`, `exp`). One parse, one map +lookup, however many providers are configured. A token from an unconfigured issuer is left to other +mechanisms; a token from a configured one that fails verification is `401 invalid_token`. + +## Sign-in + +| Route | | +|---|---| +| `GET /auth/oidc/{id}/login?redirect=/path` | redirects to the provider | +| `GET /auth/oidc/{id}/callback` | exchanges the code, verifies the ID token and nonce, starts a session | + +The PKCE verifier, nonce and state travel in a short-lived `HttpOnly` cookie scoped to `/auth/oidc`, +so sign-in needs no server-side state and works across instances. The session's principal is renewed +with the refresh token when its access token expires; `POST /auth/logout` ends it and continues to the +provider's `end_session_endpoint`. The client authenticates with `client_secret_basic`. Register +`{origin}/auth/oidc/{id}/callback` as a redirect URI and `{origin}/` as a post-logout redirect URI; +behind a proxy, forward `X-Forwarded-Proto` and `X-Forwarded-Host`. + +Each provider is listed at `/auth/methods` (`"kind":"redirect"`) and published to OpenAPI as an +`openIdConnect` scheme. + +## Providers added at runtime + +```java +OidcExtension oidc = new OidcExtension(central); +oidc.register(OidcProvider.of("acme", "https://login.acme.example/", clientId, secret)); // an organization's own IdP +oidc.unregister("acme"); +``` + +Discovery runs inside `register`, which refuses a provider — or any endpoint its discovery names — that +is not https on a public address: registration makes the server fetch URLs someone else chose. +`allowLocalProviders()` lifts that for development. Registered providers serve bearer tokens and +`/auth/oidc/{id}/login` immediately, but are not listed at `/auth/methods` or in OpenAPI: which provider +a given user signs in with is the application's decision — typically an `AuthenticationEntryPoint` that +picks one from the email domain. Their issuer is also a tenant boundary the application must enforce +in its `UserResolver`: a user belongs to the organizations whose issuer vouched for them. + +## Principal and roles + +`OidcPrincipal` carries the verified claims (`issuer()`, `name()` = `sub`, `email()`, `claim(...)`), +the access token and, for sessions, the refresh token. `hasScope` reads `scope`/`scp`; `hasAudience` +reads `aud`. + +Roles are the application's decision. To take them from the token instead: + +```java +new SecurityExtension().roles(ClaimRoles.at("realm_access.roles")) // Keycloak; "groups" for most others +``` + +## Testing + +`FakeOidcProvider` in [`flash-ext-security-test`](../../flash-ext-security-test/docs/README.md) serves +discovery, keys and the code and refresh flows on a local port; `OidcTokens.passwordGrant(...)` gets a +real token from a real provider such as Keycloak in Testcontainers. diff --git a/flash-extensions/flash-ext-security-oidc/pom.xml b/flash-extensions/flash-ext-security-oidc/pom.xml new file mode 100644 index 0000000..50cdd8d --- /dev/null +++ b/flash-extensions/flash-ext-security-oidc/pom.xml @@ -0,0 +1,33 @@ + + + 4.0.0 + + + dev.relism + flash-extensions + 2.1.0-SNAPSHOT + + + flash-ext-security-oidc + + + + dev.relism + flash-ext-security-core + + + com.nimbusds + nimbus-jose-jwt + + + org.junit.jupiter + junit-jupiter + + + dev.relism + flash-ext-security-test + + + diff --git a/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/ClaimRoles.java b/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/ClaimRoles.java new file mode 100644 index 0000000..0af47cb --- /dev/null +++ b/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/ClaimRoles.java @@ -0,0 +1,38 @@ +package dev.relism.flash.ext.security.oidc; + +import dev.relism.flash.ext.security.Principal; +import dev.relism.flash.ext.security.RoleResolver; +import dev.relism.flash.ext.security.SecurityIdentity; +import dev.relism.flash.ext.security.Target; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +/** Roles read from the provider's token instead of the application's own data. */ +public final class ClaimRoles implements RoleResolver { + + private final String[] path; + + private ClaimRoles(String claimPath) { + this.path = claimPath.split("\\."); + } + + /** Roles at a dot-separated claim path — {@code realm_access.roles} for Keycloak, {@code groups} for most others. */ + public static ClaimRoles at(String claimPath) { + return new ClaimRoles(claimPath); + } + + /** The roles {@code principal}'s token carries; none for a caller OIDC did not authenticate. */ + @SuppressWarnings("unchecked") + public List of(Principal principal) { + Object value = principal instanceof OidcPrincipal oidc ? oidc.claims() : null; + for (int i = 0; i < path.length && value != null; i++) value = value instanceof Map map ? map.get(path[i]) : null; + return value instanceof List roles ? (List) roles : value instanceof Collection roles ? List.copyOf((Collection) roles) : List.of(); + } + + @Override + public boolean hasRole(SecurityIdentity identity, String role, Target on) { + return of(identity.principal()).contains(role); + } +} diff --git a/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/OidcExtension.java b/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/OidcExtension.java new file mode 100644 index 0000000..5bda112 --- /dev/null +++ b/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/OidcExtension.java @@ -0,0 +1,239 @@ +package dev.relism.flash.ext.security.oidc; + +import com.nimbusds.jwt.SignedJWT; +import dev.relism.flash.exceptions.HttpException; +import dev.relism.flash.ext.security.AuthenticationFailedException; +import dev.relism.flash.ext.security.AuthenticationMechanism; +import dev.relism.flash.ext.security.LoginMethod; +import dev.relism.flash.ext.security.Principal; +import dev.relism.flash.ext.security.SecurityExtension; +import dev.relism.flash.ext.security.SecurityScheme; +import dev.relism.flash.ext.security.Session; +import dev.relism.flash.extension.FlashContext; +import dev.relism.flash.extension.FlashExtension; +import dev.relism.flash.extension.FlashRegistrar; +import dev.relism.flash.models.Request; +import dev.relism.flash.models.Response; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.Instant; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * OpenID Connect: bearer access tokens from any configured provider, and browser sign-in through the + * authorization code flow with PKCE, which ends in a {@link SecurityExtension} session renewed with + * the refresh token. + * + *
    {@code
    + * app.install(new SecurityExtension())
    + *    .install(new OidcExtension(OidcProvider.of("sso", "https://id.example.com/realms/acme", "app", secret)));
    + * }
    + * + *

    Routes: {@code GET /auth/oidc/{provider}/login?redirect=/path} and its callback. A bearer token + * is matched to its provider by {@code iss} before its signature is checked against that provider's + * keys, so any number of providers costs one lookup; a token from an issuer not configured here is + * left to other mechanisms. + */ +public final class OidcExtension implements FlashExtension, AuthenticationMechanism { + + private static final AuthenticationFailedException INVALID = new AuthenticationFailedException("Bearer error=\"invalid_token\""); + private static final String FLOW = "flash_oidc"; + private static final SecureRandom RANDOM = new SecureRandom(); + private static final Base64.Encoder BASE64URL = Base64.getUrlEncoder().withoutPadding(); + + private final OidcProvider[] configured; + private final Map byId = new ConcurrentHashMap<>(); + private final Map byIssuer = new ConcurrentHashMap<>(); + private SecurityExtension security; + private boolean localProviders; + + public OidcExtension(OidcProvider... providers) { + this.configured = providers; + } + + /** Lets {@link #register} accept http and private addresses — for a provider on a developer's machine, never in production. */ + public OidcExtension allowLocalProviders() { + this.localProviders = true; + return this; + } + + /** + * Trusts another provider from now on — an organization connecting its own. Discovery runs here, + * so a bad provider fails this call. It serves bearer tokens and {@code /auth/oidc/{id}/login}, but is + * not listed at {@code /auth/methods}: which provider a user signs in with is the application's call. + * + * @throws IllegalArgumentException the provider, or an endpoint it names, is not https on a public address, + * or its id is a configured provider's, which it would otherwise replace + */ + public void register(OidcProvider config) { + for (OidcProvider fixed : configured) { + if (fixed.id().equals(config.id())) throw new IllegalArgumentException("A configured provider already uses the id " + config.id()); + } + Provider provider = new Provider(config, !localProviders); + Provider replaced = byId.put(config.id(), provider); + if (replaced != null) byIssuer.remove(replaced.issuer); + byIssuer.put(provider.issuer, provider); + } + + /** + * Checks a provider before trusting it: discovery, the client ID and secret, and the redirect URI a + * sign-in from {@code origin} will send. Registers nothing, and holds {@link #register}'s address rules. + * + * @param origin where users will sign in from, as {@link Request#origin()} gives it + * @throws IllegalArgumentException naming what the provider refused + * @throws IllegalStateException the provider could not be reached + */ + public void verify(OidcProvider config, String origin) { + new Provider(config, !localProviders).verify(callbackUri(origin, config.id())); + } + + /** Stops trusting a provider: its tokens are no longer this mechanism's, its sessions stop renewing. */ + public void unregister(String id) { + Provider removed = byId.remove(id); + if (removed != null) byIssuer.remove(removed.issuer); + } + + @Override + public void configure(FlashRegistrar app, FlashContext ctx) { + for (OidcProvider config : configured) { + Provider provider = new Provider(config, false); + byId.put(config.id(), provider); + byIssuer.put(provider.issuer, provider); + } + ctx.provide(OidcExtension.class, this); + app.get("/auth/oidc/{provider}/login", this::login); + app.get("/auth/oidc/{provider}/callback", this::callback); + ctx.onReady(() -> { + security = ctx.require(SecurityExtension.class).mechanism(this).refresher(OidcPrincipal.class, this::refresh); + for (OidcProvider config : configured) { + security.scheme(SecurityScheme.openIdConnect(config.id(), byId.get(config.id()).issuer)) + .loginMethod(new LoginMethod(config.id(), config.name(), "/auth/oidc/" + config.id() + "/login", LoginMethod.Kind.REDIRECT)); + } + }); + } + + // -- Bearer access tokens ------------------------------------------------- + + @Override + public Principal authenticate(Request req) { + String header = req.header("Authorization"); + if (header == null || !header.startsWith("Bearer ") || header.indexOf('.') < 0 || header.indexOf('.') == header.lastIndexOf('.')) return null; + String token = header.substring(7); + Provider provider; + SignedJWT jwt; + try { + jwt = SignedJWT.parse(token); + provider = byIssuer.get(String.valueOf(jwt.getJWTClaimsSet().getIssuer())); + } catch (Exception malformed) { + throw INVALID; + } + if (provider == null) return null; + try { + Map claims = provider.verifyAccessToken(jwt); + return new OidcPrincipal(provider.config.id(), (String) claims.get("sub"), claims, token, null, null); + } catch (Exception invalid) { + throw INVALID; + } + } + + // -- Browser sign-in -------------------------------------------------------- + + private Object login(Request req, Response res) throws Exception { + Provider provider = provider(req); + String state = random(16); + String verifier = random(32); + String nonce = random(16); + res.header("Set-Cookie", FLOW + "=" + state + "." + verifier + "." + nonce + "." + + BASE64URL.encodeToString(localPath(req.query("redirect")).getBytes(StandardCharsets.UTF_8)) + + "; Path=/auth/oidc; Max-Age=600; HttpOnly; SameSite=Lax" + (req.origin().startsWith("https") ? "; Secure" : "")); + String challenge = BASE64URL.encodeToString(MessageDigest.getInstance("SHA-256").digest(verifier.getBytes(StandardCharsets.US_ASCII))); + res.redirect(provider.authorizeUrl(callbackUri(req.origin(), provider.config.id()), state, nonce, challenge)); + return null; + } + + private Object callback(Request req, Response res) { + Provider provider = provider(req); + String cookie = req.cookie(FLOW); + String[] flow = cookie == null ? null : cookie.split("\\."); + String state = req.query("state"); + res.header("Set-Cookie", FLOW + "=; Path=/auth/oidc; Max-Age=0"); + if (flow == null || flow.length != 4 || state == null + || !MessageDigest.isEqual(state.getBytes(StandardCharsets.US_ASCII), flow[0].getBytes(StandardCharsets.US_ASCII))) { + throw HttpException.badRequest("Sign-in state does not match; start again"); + } + if (req.query("error") != null) throw HttpException.badRequest("The identity provider refused sign-in: " + req.query("error")); + try { + Map tokens = provider.token("grant_type=authorization_code&code=" + Provider.encode(req.query("code")) + + "&redirect_uri=" + Provider.encode(callbackUri(req.origin(), provider.config.id())) + "&code_verifier=" + flow[1]); + String idToken = (String) tokens.get("id_token"); + Map claims = sessionClaims(provider.verifyIdToken(idToken), tokens); + if (!flow[2].equals(claims.get("nonce"))) throw new IllegalStateException("nonce mismatch"); + String logout = provider.endSessionEndpoint == null ? null : provider.endSessionEndpoint + + (provider.endSessionEndpoint.indexOf('?') < 0 ? '?' : '&') + "client_id=" + Provider.encode(provider.config.clientId()) + + "&id_token_hint=" + idToken + "&post_logout_redirect_uri=" + Provider.encode(req.origin() + "/"); + OidcPrincipal principal = new OidcPrincipal(provider.config.id(), (String) claims.get("sub"), claims, + (String) tokens.get("access_token"), (String) tokens.get("refresh_token"), logout); + security.signIn(req, res, principal, expiry(tokens)); + } catch (Exception failed) { + throw HttpException.badRequest("Sign-in could not be completed; start again"); + } + res.redirect(localPath(new String(Base64.getUrlDecoder().decode(flow[3]), StandardCharsets.UTF_8))); + return null; + } + + private Session refresh(Session expired) { + OidcPrincipal old = (OidcPrincipal) expired.principal(); + Provider provider = byId.get(old.provider()); + if (provider == null || old.refreshToken() == null) return null; + try { + Map tokens = provider.token("grant_type=refresh_token&refresh_token=" + Provider.encode(old.refreshToken())); + Map claims = tokens.get("id_token") == null ? old.claims() : sessionClaims(provider.verifyIdToken(tokens.get("id_token")), tokens); + if (!old.name().equals(claims.get("sub"))) return null; + Object refreshToken = tokens.getOrDefault("refresh_token", old.refreshToken()); + return new Session(expired.id(), new OidcPrincipal(old.provider(), old.name(), claims, + (String) tokens.get("access_token"), (String) refreshToken, old.logoutUrl()), expiry(tokens)); + } catch (Exception failed) { + return null; + } + } + + // -- Helpers ---------------------------------------------------------------- + + private Provider provider(Request req) { + Provider provider = byId.get(req.param("provider")); + if (provider == null) throw HttpException.notFound("OIDC provider"); + return provider; + } + + /** The ID token's claims plus the scopes the token response granted, which ID tokens do not carry. */ + private static Map sessionClaims(Map idClaims, Map tokens) { + if (tokens.get("scope") == null) return idClaims; + Map claims = new HashMap<>(idClaims); + claims.put("scope", tokens.get("scope")); + return claims; + } + + private static Instant expiry(Map tokens) { + return Instant.now().plusSeconds(tokens.get("expires_in") instanceof Number seconds ? seconds.longValue() : 300); + } + + private static String callbackUri(String origin, String provider) { + return origin + "/auth/oidc/" + provider + "/callback"; + } + + /** Only same-origin paths: anything else would make sign-in an open redirect. */ + private static String localPath(String path) { + return path != null && path.startsWith("/") && !path.startsWith("//") && !path.startsWith("/\\") ? path : "/"; + } + + private static String random(int bytes) { + byte[] value = new byte[bytes]; + RANDOM.nextBytes(value); + return BASE64URL.encodeToString(value); + } +} diff --git a/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/OidcPrincipal.java b/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/OidcPrincipal.java new file mode 100644 index 0000000..626b495 --- /dev/null +++ b/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/OidcPrincipal.java @@ -0,0 +1,49 @@ +package dev.relism.flash.ext.security.oidc; + +import dev.relism.flash.ext.security.Principal; + +import java.util.Collection; +import java.util.Map; + +/** + * A caller an OpenID Provider vouched for: through a bearer access token, or by signing in. Its + * {@link #name()} is the {@code sub} claim — unique per {@link #issuer()}, never across issuers. + * + * @param provider the {@link OidcProvider#id()} + * @param claims verified: the access token's for a bearer caller, the ID token's for a session + * @param refreshToken {@code null} for a bearer caller + */ +public record OidcPrincipal(String provider, String name, Map claims, + String accessToken, String refreshToken, String logoutUrl) implements Principal { + + public String issuer() { + return (String) claims.get("iss"); + } + + public String email() { + return (String) claims.get("email"); + } + + public Object claim(String name) { + return claims.get(name); + } + + /** From {@code scope} (space-delimited) or {@code scp}; a token that grants none grants none. */ + @Override + public boolean hasScope(String scope) { + Object granted = claims.containsKey("scope") ? claims.get("scope") : claims.get("scp"); + if (granted instanceof Collection list) return list.contains(scope); + if (!(granted instanceof String delimited)) return false; + for (int at = delimited.indexOf(scope); at >= 0; at = delimited.indexOf(scope, at + 1)) { + int end = at + scope.length(); + if ((at == 0 || delimited.charAt(at - 1) == ' ') && (end == delimited.length() || delimited.charAt(end) == ' ')) return true; + } + return false; + } + + @Override + public boolean hasAudience(String audience) { + Object aud = claims.get("aud"); + return aud instanceof Collection list ? list.contains(audience) : audience.equals(aud); + } +} diff --git a/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/OidcProvider.java b/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/OidcProvider.java new file mode 100644 index 0000000..d960b45 --- /dev/null +++ b/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/OidcProvider.java @@ -0,0 +1,32 @@ +package dev.relism.flash.ext.security.oidc; + +/** + * An OpenID Provider the application trusts, and the client the application is registered as there. + * + * @param id names the provider in routes ({@code /auth/oidc/{id}/login}), OpenAPI and principals + * @param name what a sign-in page shows + * @param issuer exactly as the provider publishes it — some end in {@code /} + * @param scopes requested at sign-in, space-delimited + */ +public record OidcProvider(String id, String name, String issuer, String clientId, String clientSecret, String scopes) { + + public OidcProvider { + if (!id.matches("[A-Za-z0-9_-]+")) throw new IllegalArgumentException("OIDC provider id must be URL-safe: " + id); + } + + public static OidcProvider of(String id, String issuer, String clientId, String clientSecret) { + return new OidcProvider(id, id, issuer, clientId, clientSecret, "openid profile email"); + } + + public OidcProvider name(String name) { + return new OidcProvider(id, name, issuer, clientId, clientSecret, scopes); + } + + public OidcProvider scopes(String scopes) { + return new OidcProvider(id, name, issuer, clientId, clientSecret, scopes); + } + + String discoveryUrl() { + return issuer + (issuer.endsWith("/") ? "" : "/") + ".well-known/openid-configuration"; + } +} diff --git a/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/Provider.java b/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/Provider.java new file mode 100644 index 0000000..de98265 --- /dev/null +++ b/flash-extensions/flash-ext-security-oidc/src/main/java/dev/relism/flash/ext/security/oidc/Provider.java @@ -0,0 +1,164 @@ +package dev.relism.flash.ext.security.oidc; + +import com.nimbusds.jose.JOSEObjectType; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.jwk.source.JWKSourceBuilder; +import com.nimbusds.jose.proc.DefaultJOSEObjectTypeVerifier; +import com.nimbusds.jose.proc.JWSVerificationKeySelector; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jose.util.JSONObjectUtils; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier; +import com.nimbusds.jwt.proc.DefaultJWTProcessor; + +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** One discovered provider: its endpoints, its signing keys, and the two token verifiers they back. */ +final class Provider { + + private static final HttpClient HTTP = HttpClient.newHttpClient(); + + final OidcProvider config; + final String issuer; + final String authorizationEndpoint; + final String endSessionEndpoint; + private final String tokenEndpoint; + private final String clientAuthorization; + private final DefaultJWTProcessor accessTokens; + private final DefaultJWTProcessor idTokens; + + /** + * Fetches discovery. {@code guarded} providers — registered at runtime, from input the operator does + * not control — must be https on public addresses, and so must every endpoint their discovery names: + * otherwise registering one is a request forgery against the server's own network. + */ + Provider(OidcProvider config, boolean guarded) { + this.config = config; + try { + if (guarded) requirePublic(config.discoveryUrl()); + Map discovery = JSONObjectUtils.parse(HTTP.send(HttpRequest.newBuilder(URI.create(config.discoveryUrl())).build(), + HttpResponse.BodyHandlers.ofString()).body()); + if (guarded) for (String key : new String[]{"authorization_endpoint", "token_endpoint", "jwks_uri"}) requirePublic((String) discovery.get(key)); + issuer = (String) discovery.get("issuer"); + authorizationEndpoint = (String) discovery.get("authorization_endpoint"); + tokenEndpoint = (String) discovery.get("token_endpoint"); + endSessionEndpoint = (String) discovery.get("end_session_endpoint"); + JWKSource keys = JWKSourceBuilder.create(URI.create((String) discovery.get("jwks_uri")).toURL()).retrying(true).build(); + accessTokens = processor(keys, null, new JOSEObjectType("at+jwt")); + idTokens = processor(keys, config.clientId(), null); + } catch (Exception e) { + if (e instanceof IllegalArgumentException rejected) throw rejected; + throw new IllegalStateException("OIDC discovery failed for " + config.discoveryUrl(), e); + } + if (issuer == null || authorizationEndpoint == null || tokenEndpoint == null) { + throw new IllegalStateException("Incomplete OIDC discovery document at " + config.discoveryUrl()); + } + clientAuthorization = "Basic " + Base64.getEncoder().encodeToString( + (encode(config.clientId()) + ":" + encode(config.clientSecret())).getBytes(StandardCharsets.UTF_8)); + } + + Map verifyAccessToken(SignedJWT token) throws Exception { + return accessTokens.process(token, null).getClaims(); + } + + Map verifyIdToken(Object idToken) throws Exception { + if (!(idToken instanceof String jwt)) throw new IllegalStateException("The provider returned no ID token"); + return idTokens.process(jwt, null).getClaims(); + } + + /** The sign-in request. {@link #verify} sends this same one, so it is refused exactly when a real sign-in would be. */ + String authorizeUrl(String redirectUri, String state, String nonce, String challenge) { + return authorizationEndpoint + (authorizationEndpoint.indexOf('?') < 0 ? '?' : '&') + + "response_type=code&client_id=" + encode(config.clientId()) + + "&redirect_uri=" + encode(redirectUri) + + "&scope=" + encode(config.scopes()) + + "&state=" + state + "&nonce=" + nonce + "&code_challenge=" + challenge + "&code_challenge_method=S256"; + } + + /** A token endpoint call, authenticated as the client ({@code client_secret_basic}). */ + Map token(String form) throws Exception { + HttpResponse response = HTTP.send(HttpRequest.newBuilder(URI.create(tokenEndpoint)) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Authorization", clientAuthorization) + .POST(HttpRequest.BodyPublishers.ofString(form)).build(), HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) throw new IllegalStateException("Token endpoint answered " + response.statusCode()); + return JSONObjectUtils.parse(response.body()); + } + + /** + * Throws naming what the provider refuses, without anyone signing in. Client authentication is + * checked before the grant (RFC 6749 §5.2), so a made-up code tells a bad secret (401) from a good + * one. A redirect URI the client has not registered is answered with an error page, never a + * redirect (§4.1.2.1), but not always on the first hop: some providers bounce to their login page + * first, so same-host redirects are followed. + */ + void verify(String redirectUri) { + try { + HttpResponse token = HTTP.send(HttpRequest.newBuilder(URI.create(tokenEndpoint)) + .header("Content-Type", "application/x-www-form-urlencoded") + .header("Authorization", clientAuthorization) + .POST(HttpRequest.BodyPublishers.ofString("grant_type=authorization_code&code=verify&redirect_uri=" + encode(redirectUri))) + .build(), HttpResponse.BodyHandlers.ofString()); + String error = token.body().contains("\"error\"") ? (String) JSONObjectUtils.parse(token.body()).get("error") : null; + if (token.statusCode() == 401 || "invalid_client".equals(error) || "unauthorized_client".equals(error)) { + throw new IllegalArgumentException("The provider rejected the client ID or secret"); + } + // RFC 7636's example challenge: well-formed, so a provider that insists on PKCE judges the rest. + URI hop = URI.create(authorizeUrl(redirectUri, "verify", "verify", "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")); + String authority = hop.getAuthority(); + for (int i = 0; i < 5; i++) { + HttpResponse answer = HTTP.send(HttpRequest.newBuilder(hop).build(), HttpResponse.BodyHandlers.discarding()); + if (answer.statusCode() >= 400) throw new IllegalArgumentException("The provider does not accept the redirect URI " + redirectUri); + String location = answer.headers().firstValue("Location").orElse(null); + if (answer.statusCode() / 100 != 3 || location == null) return; + hop = hop.resolve(location); + // Back to the client, or onwards to a login somewhere else: either way it was accepted. Authority, + // not host: in development the provider and the client share localhost on different ports. + if (hop.toString().startsWith(redirectUri) || !authority.equals(hop.getAuthority())) return; + } + } catch (IllegalArgumentException refused) { + throw refused; + } catch (Exception unreachable) { + throw new IllegalStateException("Could not reach " + config.issuer(), unreachable); + } + } + + /** ponytail: resolved once here and again by the HTTP client, so DNS rebinding between the two is not covered. */ + private static void requirePublic(String url) throws Exception { + URI uri = URI.create(url); + if (!"https".equals(uri.getScheme())) throw new IllegalArgumentException("Not https: " + url); + for (InetAddress address : InetAddress.getAllByName(uri.getHost())) { + if (address.isLoopbackAddress() || address.isSiteLocalAddress() || address.isLinkLocalAddress() || address.isAnyLocalAddress() + || address.isMulticastAddress() || (address instanceof Inet6Address && (address.getAddress()[0] & 0xfe) == 0xfc)) { + throw new IllegalArgumentException("Not a public address: " + url); + } + } + } + + static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + private DefaultJWTProcessor processor(JWKSource keys, String audience, JOSEObjectType type) { + Set algorithms = new HashSet<>(JWSAlgorithm.Family.RSA); + algorithms.addAll(JWSAlgorithm.Family.EC); + DefaultJWTProcessor processor = new DefaultJWTProcessor<>(); + processor.setJWSKeySelector(new JWSVerificationKeySelector<>(algorithms, keys)); + processor.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(audience, new JWTClaimsSet.Builder().issuer(issuer).build(), Set.of("sub", "exp"))); + if (type != null) processor.setJWSTypeVerifier(new DefaultJOSEObjectTypeVerifier<>(JOSEObjectType.JWT, type, null)); + return processor; + } +} diff --git a/flash-extensions/flash-ext-security-oidc/src/test/java/dev/relism/flash/ext/security/oidc/OidcExtensionTest.java b/flash-extensions/flash-ext-security-oidc/src/test/java/dev/relism/flash/ext/security/oidc/OidcExtensionTest.java new file mode 100644 index 0000000..47c3b62 --- /dev/null +++ b/flash-extensions/flash-ext-security-oidc/src/test/java/dev/relism/flash/ext/security/oidc/OidcExtensionTest.java @@ -0,0 +1,167 @@ +package dev.relism.flash.ext.security.oidc; + +import dev.relism.flash.ext.security.RolesAllowed; +import dev.relism.flash.ext.security.ScopesAllowed; +import dev.relism.flash.ext.security.SecurityExtension; +import dev.relism.flash.ext.security.SecurityIdentity; +import dev.relism.flash.ext.security.SecurityPolicy; +import dev.relism.flash.ext.security.test.FakeOidcProvider; +import dev.relism.flash.testing.FlashResponse; +import dev.relism.flash.testing.FlashTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class OidcExtensionTest { + + static final FakeOidcProvider provider = start(); + static final FakeOidcProvider stranger = start(); + static final SecurityExtension security = new SecurityExtension().roles(ClaimRoles.at("realm_access.roles")); + + @RolesAllowed("admin") static class AdminOnly {} + @ScopesAllowed("write") static class Writers {} + + @RegisterExtension + static final FlashTest app = FlashTest.of(flash -> flash + .install(security) + .install(new OidcExtension(OidcProvider.of("fake", provider.issuer(), "app", "secret").name("Fake"))) + .get("/me", (req, res) -> { + OidcPrincipal principal = SecurityIdentity.current().principal(OidcPrincipal.class); + return principal.name() + " " + principal.email(); + }, security.enforce(SecurityPolicy.AUTHENTICATED)) + .get("/admin", (req, res) -> "admin", security.enforce(SecurityPolicy.of(AdminOnly.class))) + .get("/write", (req, res) -> "write", security.enforce(SecurityPolicy.of(Writers.class)))); + + static FakeOidcProvider start() { + try { + return new FakeOidcProvider(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + // -- Bearer --------------------------------------------------------------- + + @Test + void aBearerTokenFromTheProviderAuthenticates() { + app.request().with(provider.bearer("bob", Map.of("email", "bob@example.test"))).get("/me") + .expectStatus(200).expectBody("bob bob@example.test"); + } + + @Test + void aTamperedTokenIsInvalid() { + String token = provider.token("bob", Map.of()); + app.request().header("Authorization", "Bearer " + token.substring(0, token.length() - 4) + "AAAA").get("/me") + .expectStatus(401).expectHeader("WWW-Authenticate", "Bearer error=\"invalid_token\""); + } + + /** Not rejected as invalid: an issuer this application does not know is some other mechanism's business. */ + @Test + void aTokenFromAnUnknownIssuerIsNotThisMechanisms() { + app.request().with(stranger.bearer("bob", Map.of())).get("/me") + .expectStatus(401).expectHeader("WWW-Authenticate", "Bearer realm=\"fake\""); + } + + @Test + void rolesAndScopesAreReadFromTheToken() { + app.request().with(provider.bearer("root", Map.of("realm_access", Map.of("roles", List.of("admin"))))).get("/admin").expectStatus(200); + app.request().with(provider.bearer("bob", Map.of("realm_access", Map.of("roles", List.of("user"))))).get("/admin").expectStatus(403); + app.request().with(provider.bearer("bob", Map.of("scope", "read write"))).get("/write").expectStatus(200); + app.request().with(provider.bearer("bob", Map.of("scope", "read"))).get("/write").expectStatus(403); + } + + // -- Browser sign-in -------------------------------------------------------- + + @Test + void signingInEndsInASessionThatRefreshesAndSignsOutAtTheProvider() throws Exception { + provider.signInAs("carol", Map.of("email", "carol@example.test")).expiresIn(0); + + FlashResponse login = app.request().get("/auth/oidc/fake/login?redirect=%2Fme").expectStatus(302); + String flow = cookie(login.headers().allValues("Set-Cookie"), "flash_oidc"); + HttpResponse approved = HttpClient.newHttpClient().send( + HttpRequest.newBuilder(URI.create(login.header("Location"))).build(), HttpResponse.BodyHandlers.ofString()); + String callback = URI.create(approved.headers().firstValue("Location").orElseThrow()).getRawPath() + + "?" + URI.create(approved.headers().firstValue("Location").orElseThrow()).getRawQuery(); + + FlashResponse signedIn = app.request().header("Cookie", flow).get(callback).expectStatus(302).expectHeader("Location", "/me"); + String session = cookie(signedIn.headers().allValues("Set-Cookie"), "flash_session"); + + // expiresIn(0): the session is already expired, so this answer came through the refresh token. + app.request().header("Cookie", session).get("/me").expectStatus(200).expectBody("carol carol@example.test"); + String logout = app.request().header("Cookie", session).post("/auth/logout").expectStatus(303).header("Location"); + assertTrue(logout.startsWith(provider.issuer() + "/logout?client_id=app&id_token_hint="), logout); + } + + @Test + void aCallbackWithoutItsFlowCookieIsRefused() { + app.get("/auth/oidc/fake/callback?code=x&state=y").expectStatus(400); + app.get("/auth/oidc/nobody/login").expectStatus(404); + } + + @Test + void theProviderIsListedAndDocumented() { + app.get("/auth/methods").expectBodyContains("{\"id\":\"fake\",\"name\":\"Fake\",\"url\":\"/auth/oidc/fake/login\",\"kind\":\"redirect\"}"); + } + + @Test + void aProviderRegisteredAtRuntimeIsTrustedUntilUnregistered() throws Exception { + OidcExtension extension = new OidcExtension().allowLocalProviders(); + SecurityExtension own = new SecurityExtension(); + FlashTest runtime = FlashTest.of(flash -> flash.install(own).install(extension) + .get("/me", (req, res) -> SecurityIdentity.current().principal().name(), own.enforce(SecurityPolicy.AUTHENTICATED))); + try { + runtime.get("/me").expectStatus(401); + extension.register(OidcProvider.of("byo", stranger.issuer(), "app", "secret")); + runtime.request().with(stranger.bearer("dora", Map.of())).get("/me").expectStatus(200).expectBody("dora"); + extension.unregister("byo"); + runtime.request().with(stranger.bearer("dora", Map.of())).get("/me").expectStatus(401); + } finally { + runtime.app().stop().join(); + } + } + + /** Checked before anyone trusts it, and told apart: a bad secret is not a bad redirect URI. */ + @Test + void aProviderIsVerifiedBeforeItIsTrusted() throws Exception { + try (FakeOidcProvider customer = new FakeOidcProvider().client("s3cret", "https://app.example/auth/oidc/acme/callback")) { + OidcExtension extension = new OidcExtension().allowLocalProviders(); + extension.verify(OidcProvider.of("acme", customer.issuer(), "app", "s3cret"), "https://app.example"); + + assertTrue(assertThrows(IllegalArgumentException.class, + () -> extension.verify(OidcProvider.of("acme", customer.issuer(), "app", "wrong"), "https://app.example")) + .getMessage().contains("client ID or secret")); + assertTrue(assertThrows(IllegalArgumentException.class, + () -> extension.verify(OidcProvider.of("acme", customer.issuer(), "app", "s3cret"), "https://elsewhere.example")) + .getMessage().contains("https://elsewhere.example/auth/oidc/acme/callback")); + // Verifying fetches URLs a customer chose, so it keeps register's address rules. + assertThrows(IllegalArgumentException.class, + () -> new OidcExtension().verify(OidcProvider.of("acme", customer.issuer(), "app", "s3cret"), "https://app.example")); + } + } + + /** A provider registered at runtime, from a customer's input, never replaces one the application configured. */ + @Test + void aRuntimeProviderCannotTakeAConfiguredId() { + OidcExtension extension = new OidcExtension(OidcProvider.of("central", provider.issuer(), "app", "secret")).allowLocalProviders(); + assertThrows(IllegalArgumentException.class, () -> extension.register(OidcProvider.of("central", stranger.issuer(), "app", "secret"))); + } + + /** Registering a provider makes the server fetch URLs a customer chose: not on its own network, not in clear text. */ + @Test + void aRuntimeProviderOnAPrivateAddressIsRefused() { + assertThrows(IllegalArgumentException.class, () -> new OidcExtension().register(OidcProvider.of("evil", stranger.issuer(), "app", "secret"))); + } + + private static String cookie(List setCookies, String name) { + return setCookies.stream().filter(c -> c.startsWith(name + "=")).map(c -> c.substring(0, c.indexOf(';'))).findFirst().orElseThrow(); + } +} diff --git a/flash-extensions/flash-ext-security-test/docs/README.md b/flash-extensions/flash-ext-security-test/docs/README.md new file mode 100644 index 0000000..706ca1f --- /dev/null +++ b/flash-extensions/flash-ext-security-test/docs/README.md @@ -0,0 +1,15 @@ +# flash-ext-security-test + +Test-scope utilities for applications on `flash-ext-security-core`. + +```java +FlashTest app = FlashTest.of(flash -> flash.apply(new MyApp()).install(new TestSecurity())); + +app.request().with(TestSecurity.as(() -> "alice")).get("/me"); // any principal +app.request().with(TestSecurity.as(new OidcPrincipal(...))).get("/projects"); // a mechanism's own type +``` + +`TestSecurity.as(principal)` hands the principal to the application by reference — `FlashTest` +serves it in the same JVM — so tests exercise the real `UserResolver`, `RoleResolver` and policies +with no identity provider running. Tests of the flows themselves use the mechanism's own kit: +`FakeOidcProvider` and `OidcTokens` in this module for OIDC, a real `POST` for form login. diff --git a/flash-extensions/flash-ext-security-test/pom.xml b/flash-extensions/flash-ext-security-test/pom.xml new file mode 100644 index 0000000..e3a9515 --- /dev/null +++ b/flash-extensions/flash-ext-security-test/pom.xml @@ -0,0 +1,34 @@ + + + 4.0.0 + + + dev.relism + flash-extensions + 2.1.0-SNAPSHOT + + + flash-ext-security-test + + + + dev.relism + flash-ext-security-core + + + dev.relism + flash-testing + compile + + + com.nimbusds + nimbus-jose-jwt + + + org.junit.jupiter + junit-jupiter + + + diff --git a/flash-extensions/flash-ext-security-test/src/main/java/dev/relism/flash/ext/security/test/FakeOidcProvider.java b/flash-extensions/flash-ext-security-test/src/main/java/dev/relism/flash/ext/security/test/FakeOidcProvider.java new file mode 100644 index 0000000..c90d03b --- /dev/null +++ b/flash-extensions/flash-ext-security-test/src/main/java/dev/relism/flash/ext/security/test/FakeOidcProvider.java @@ -0,0 +1,150 @@ +package dev.relism.flash.ext.security.test; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.JWSHeader; +import com.nimbusds.jose.crypto.RSASSASigner; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import com.nimbusds.jose.util.JSONObjectUtils; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import dev.relism.flash.testing.FlashRequest; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.net.URLEncoder; +import java.util.Set; +import java.util.Base64; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; + +/** + * An OpenID Provider on a random local port: discovery, keys, and a token endpoint for the + * authorization code and refresh flows. Sign-in is approved at once, as {@link #signInAs}. + */ +public final class FakeOidcProvider implements AutoCloseable { + + private final HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + private final RSAKey key = new RSAKeyGenerator(2048).keyID(UUID.randomUUID().toString()).generate(); + private final Map nonces = new ConcurrentHashMap<>(); + private final String issuer = "http://127.0.0.1:" + server.getAddress().getPort(); + private volatile String subject = "alice"; + private volatile Map claims = Map.of(); + private volatile long expiresIn = 300; + /** Null accepts any client, which is what every test that is not about client registration wants. */ + private volatile String clientSecret; + private volatile Set redirectUris = Set.of(); + + public FakeOidcProvider() throws Exception { + server.createContext("/.well-known/openid-configuration", ex -> send(ex, 200, JSONObjectUtils.toJSONString(Map.of( + "issuer", issuer, "authorization_endpoint", issuer + "/authorize", "token_endpoint", issuer + "/token", + "jwks_uri", issuer + "/jwks", "end_session_endpoint", issuer + "/logout")))); + server.createContext("/jwks", ex -> send(ex, 200, new JWKSet(key.toPublicJWK()).toString())); + server.createContext("/authorize", ex -> { + Map query = form(ex.getRequestURI().getRawQuery()); + if (!redirectUris.isEmpty() && !redirectUris.contains(query.get("redirect_uri"))) { + send(ex, 400, "Unregistered redirect_uri"); + return; + } + String code = UUID.randomUUID().toString(); + nonces.put(code, query.get("nonce")); + ex.getResponseHeaders().add("Location", query.get("redirect_uri") + "?code=" + code + "&state=" + query.get("state")); + send(ex, 302, ""); + }); + server.createContext("/token", ex -> { + String basic = ex.getRequestHeaders().getFirst("Authorization"); + if (clientSecret != null && (basic == null || !new String(Base64.getDecoder().decode(basic.substring(6)), StandardCharsets.UTF_8) + .endsWith(":" + URLEncoder.encode(clientSecret, StandardCharsets.UTF_8)))) { + send(ex, 401, "{\"error\":\"invalid_client\"}"); + return; + } + Map body = form(new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + String nonce = nonces.remove(body.getOrDefault("code", "")); + if (body.get("grant_type").equals("authorization_code") && nonce == null) { + send(ex, 400, "{\"error\":\"invalid_grant\"}"); + return; + } + Map id = new HashMap<>(claims); + id.put("aud", "app"); + if (nonce != null) id.put("nonce", nonce); + send(ex, 200, JSONObjectUtils.toJSONString(Map.of("access_token", token(subject, claims), "id_token", token(subject, id), + "refresh_token", UUID.randomUUID().toString(), "expires_in", expiresIn, "scope", "openid email"))); + }); + server.start(); + } + + /** Refuses, from now on, any other secret or redirect URI — as a provider with this client registered would. */ + public FakeOidcProvider client(String secret, String... redirectUris) { + this.clientSecret = secret; + this.redirectUris = Set.of(redirectUris); + return this; + } + + public String issuer() { + return issuer; + } + + /** Who sign-in and refresh vouch for, with what extra claims; the client id is always {@code app}. */ + public FakeOidcProvider signInAs(String subject, Map claims) { + this.subject = subject; + this.claims = claims; + return this; + } + + /** Lifetime of the tokens sign-in and refresh issue — {@code 0} expires a session at once. */ + public FakeOidcProvider expiresIn(long seconds) { + this.expiresIn = seconds; + return this; + } + + /** A signed token for {@code subject}, valid five minutes, with {@code claims} added. */ + public String token(String subject, Map claims) { + try { + JWTClaimsSet.Builder set = new JWTClaimsSet.Builder().issuer(issuer).subject(subject) + .issueTime(new Date()).expirationTime(new Date(System.currentTimeMillis() + 300_000)); + claims.forEach(set::claim); + SignedJWT jwt = new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(key.getKeyID()).build(), set.build()); + jwt.sign(new RSASSASigner(key)); + return jwt.serialize(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + /** Sends the request with a bearer {@link #token}. */ + public Consumer bearer(String subject, Map claims) { + String header = "Bearer " + token(subject, claims); + return request -> request.header("Authorization", header); + } + + @Override + public void close() { + server.stop(0); + } + + private static Map form(String encoded) { + Map fields = new HashMap<>(); + if (encoded != null) for (String pair : encoded.split("&")) { + int eq = pair.indexOf('='); + if (eq > 0) fields.put(pair.substring(0, eq), URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8)); + } + return fields; + } + + private static void send(HttpExchange exchange, int status, String body) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, bytes.length == 0 ? -1 : bytes.length); + if (bytes.length > 0) try (var out = exchange.getResponseBody()) { out.write(bytes); } + exchange.close(); + } +} diff --git a/flash-extensions/flash-ext-security-test/src/main/java/dev/relism/flash/ext/security/test/OidcTokens.java b/flash-extensions/flash-ext-security-test/src/main/java/dev/relism/flash/ext/security/test/OidcTokens.java new file mode 100644 index 0000000..1ff2de9 --- /dev/null +++ b/flash-extensions/flash-ext-security-test/src/main/java/dev/relism/flash/ext/security/test/OidcTokens.java @@ -0,0 +1,47 @@ +package dev.relism.flash.ext.security.test; + +import com.nimbusds.jose.util.JSONObjectUtils; +import dev.relism.flash.testing.FlashRequest; + +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.function.Consumer; + +/** Real tokens from a real provider — Keycloak in Testcontainers, typically. */ +public final class OidcTokens { + + private static final HttpClient HTTP = HttpClient.newHttpClient(); + + private OidcTokens() {} + + /** + * Sends the request with an access token obtained by the resource owner password grant. The + * client must allow direct access grants — a test realm's setting, never a production one. + */ + public static Consumer passwordGrant(String issuer, String clientId, String clientSecret, String username, String password) { + try { + String discovery = issuer + (issuer.endsWith("/") ? "" : "/") + ".well-known/openid-configuration"; + String tokenEndpoint = (String) JSONObjectUtils.parse(get(HttpRequest.newBuilder(URI.create(discovery)))).get("token_endpoint"); + String form = "grant_type=password&scope=openid&client_id=" + encode(clientId) + "&client_secret=" + encode(clientSecret) + + "&username=" + encode(username) + "&password=" + encode(password); + String header = "Bearer " + JSONObjectUtils.parse(get(HttpRequest.newBuilder(URI.create(tokenEndpoint)) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(form)))).get("access_token"); + return request -> request.header("Authorization", header); + } catch (Exception e) { + throw new IllegalStateException("Could not obtain a token from " + issuer, e); + } + } + + private static String get(HttpRequest.Builder request) throws Exception { + return HTTP.send(request.build(), HttpResponse.BodyHandlers.ofString()).body(); + } + + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } +} diff --git a/flash-extensions/flash-ext-security-test/src/main/java/dev/relism/flash/ext/security/test/TestSecurity.java b/flash-extensions/flash-ext-security-test/src/main/java/dev/relism/flash/ext/security/test/TestSecurity.java new file mode 100644 index 0000000..4b453bd --- /dev/null +++ b/flash-extensions/flash-ext-security-test/src/main/java/dev/relism/flash/ext/security/test/TestSecurity.java @@ -0,0 +1,46 @@ +package dev.relism.flash.ext.security.test; + +import dev.relism.flash.ext.security.Principal; +import dev.relism.flash.ext.security.SecurityExtension; +import dev.relism.flash.extension.FlashContext; +import dev.relism.flash.extension.FlashExtension; +import dev.relism.flash.extension.FlashRegistrar; +import dev.relism.flash.testing.FlashRequest; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; + +/** + * Authenticates test requests as any {@link Principal}, whatever mechanisms the application + * installs — an OIDC user, an API key, a plain name — without an identity provider. + * + *

    {@code
    + * FlashTest app = FlashTest.of(flash -> flash.apply(new MyApp()).install(new TestSecurity()));
    + * app.request().with(TestSecurity.as(() -> "alice")).get("/me");
    + * }
    + * + *

    Works because {@code FlashTest} serves the application in the test's own JVM: the principal + * is handed over by reference, under a token only this process can have issued. + */ +public final class TestSecurity implements FlashExtension { + + private static final String SCHEME = "Test "; + private static final Map PRINCIPALS = new ConcurrentHashMap<>(); + + /** Sends the request as {@code principal}. */ + public static Consumer as(Principal principal) { + String token = UUID.randomUUID().toString(); + PRINCIPALS.put(token, principal); + return request -> request.header("Authorization", SCHEME + token); + } + + @Override + public void configure(FlashRegistrar app, FlashContext ctx) { + ctx.onReady(() -> ctx.require(SecurityExtension.class).mechanism(req -> { + String header = req.header("Authorization"); + return header != null && header.startsWith(SCHEME) ? PRINCIPALS.get(header.substring(SCHEME.length())) : null; + })); + } +} diff --git a/flash-extensions/flash-ext-security-test/src/test/java/dev/relism/flash/ext/security/test/TestSecurityTest.java b/flash-extensions/flash-ext-security-test/src/test/java/dev/relism/flash/ext/security/test/TestSecurityTest.java new file mode 100644 index 0000000..0305524 --- /dev/null +++ b/flash-extensions/flash-ext-security-test/src/test/java/dev/relism/flash/ext/security/test/TestSecurityTest.java @@ -0,0 +1,29 @@ +package dev.relism.flash.ext.security.test; + +import dev.relism.flash.ext.security.SecurityExtension; +import dev.relism.flash.ext.security.SecurityIdentity; +import dev.relism.flash.ext.security.SecurityPolicy; +import dev.relism.flash.testing.FlashTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +class TestSecurityTest { + + static final SecurityExtension security = new SecurityExtension(); + + @RegisterExtension + static final FlashTest app = FlashTest.of(flash -> flash + .install(security) + .install(new TestSecurity()) + .get("/me", (req, res) -> SecurityIdentity.current().principal().name(), security.enforce(SecurityPolicy.AUTHENTICATED))); + + @Test + void aRequestIsAuthenticatedAsTheGivenPrincipal() { + app.request().with(TestSecurity.as(() -> "alice")).get("/me").expectStatus(200).expectBody("alice"); + } + + @Test + void anUnknownTokenIsNobody() { + app.request().header("Authorization", "Test forged").get("/me").expectStatus(401); + } +} diff --git a/flash-extensions/pom.xml b/flash-extensions/pom.xml index fb4b3fb..62ed536 100644 --- a/flash-extensions/pom.xml +++ b/flash-extensions/pom.xml @@ -16,8 +16,11 @@ flash-ext-jackson flash-ext-openapi - flash-ext-auth-core - flash-ext-auth-oidc + flash-ext-security-core + flash-ext-security-oidc + flash-ext-security-apikey + flash-ext-security-form + flash-ext-security-test flash-ext-routeviewer flash-ext-view-core flash-ext-view-jte @@ -48,7 +51,7 @@ dev.relism - flash-ext-auth-core + flash-ext-security-core ${project.version} @@ -66,6 +69,12 @@ caffeine ${caffeine.version} + + dev.relism + flash-ext-security-test + ${project.version} + test + dev.relism flash-testing diff --git a/pom.xml b/pom.xml index 14a638e..97064c4 100644 --- a/pom.xml +++ b/pom.xml @@ -106,7 +106,7 @@ dev.relism - flash-ext-auth-oidc + flash-ext-security-oidc ${project.version} -- 2.54.0