resultBuf = ThreadLocal.withInitial(() -> new long[2]);
+ byte[] limitHeader = ("X-RateLimit-Limit: " + cfg.limit() + "\r\n")
+ .getBytes(StandardCharsets.UTF_8);
return next -> (req, res) -> {
String key = resolver.resolve(req);
@@ -112,8 +112,7 @@ public final class LimiterExtension implements FlashExtension {
boolean allowed = cfg.strategy().check(bucket, cfg, out);
- // Always inject rate-limit headers — useful even on allowed requests.
- res.header("X-RateLimit-Limit", String.valueOf(cfg.limit()));
+ res.header(limitHeader);
res.header("X-RateLimit-Remaining", String.valueOf(out[0]));
res.header("X-RateLimit-Reset", String.valueOf(out[1]));
diff --git a/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/RateLimitStrategy.java b/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/RateLimitStrategy.java
index 3434f49..b5584d6 100644
--- a/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/RateLimitStrategy.java
+++ b/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/RateLimitStrategy.java
@@ -11,6 +11,7 @@ package dev.relism.ext.limiter;
* Called on every request — must not allocate on the hot path.
*
* @see dev.relism.ext.limiter.strategy.FixedWindowStrategy
+ * @see dev.relism.ext.limiter.strategy.SlidingWindowStrategy
* @see dev.relism.ext.limiter.strategy.TokenBucketStrategy
*/
public interface RateLimitStrategy {
diff --git a/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/strategy/SlidingWindowStrategy.java b/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/strategy/SlidingWindowStrategy.java
new file mode 100644
index 0000000..0e2dedd
--- /dev/null
+++ b/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/strategy/SlidingWindowStrategy.java
@@ -0,0 +1,86 @@
+package dev.relism.ext.limiter.strategy;
+
+import dev.relism.ext.limiter.Bucket;
+import dev.relism.ext.limiter.LimitConfig;
+import dev.relism.ext.limiter.RateLimitStrategy;
+
+/**
+ * Sliding-window counter rate limit: approximates a true sliding window by interpolating
+ * between the previous fixed window's count and the current window's count.
+ *
+ *
+ * estimate = prevCount × (1 − elapsed / windowMs) + currentCount
+ *
+ *
+ * This is the same approximation used by Redis. It eliminates the boundary burst
+ * problem of {@link FixedWindowStrategy} while staying O(1) memory and lock-free.
+ * The error is bounded: in the worst case the true rate at the boundary can exceed
+ * the limit by at most {@code limit × (1 − elapsed/windowMs)} — typically a few percent.
+ *
+ *
Slot layout
+ *
+ * - {@link Bucket#slot0} — packed {@code (reducedEpoch << 32 | currentCount)}
+ * - {@link Bucket#slot1} — count from the immediately preceding epoch (0 = none)
+ *
+ *
+ * On a window transition the thread that wins the {@code slot0} CAS also writes
+ * {@code slot1}. A concurrent thread that reads {@code slot0} after the transition but
+ * before {@code slot1} is written sees a slightly stale previous count — acceptable for
+ * an approximation algorithm.
+ */
+public final class SlidingWindowStrategy implements RateLimitStrategy {
+
+ @Override
+ public boolean check(Bucket bucket, LimitConfig cfg, long[] out) {
+ long now = System.currentTimeMillis();
+ long windowMs = cfg.windowMs();
+ long absEpoch = now / windowMs;
+ int epoch = (int)(absEpoch & 0xFFFFFFFFL);
+ long elapsed = now % windowMs; // ms elapsed inside the current window
+
+ while (true) {
+ long packed = bucket.slot0.get();
+ int storedEpoch = (int)(packed >>> 32);
+ int count = (int)(packed & 0xFFFFFFFFL);
+
+ if (storedEpoch == epoch) {
+ // ── Same window ──────────────────────────────────────────────────
+ long prevCount = bucket.slot1.get();
+ // Integer interpolation — no floating-point on hot path.
+ long estimate = (prevCount * (windowMs - elapsed)) / windowMs + count + 1;
+
+ if (estimate > cfg.limit()) {
+ out[0] = 0L;
+ out[1] = (absEpoch + 1) * windowMs / 1000L;
+ return false;
+ }
+
+ int newCount = Math.min(count + 1, cfg.limit() + 1);
+ long newPacked = ((long) epoch << 32) | (newCount & 0xFFFFFFFFL);
+ if (!bucket.slot0.compareAndSet(packed, newPacked)) continue; // CAS lost, retry
+
+ out[0] = Math.max(0L, cfg.limit() - estimate);
+ out[1] = (absEpoch + 1) * windowMs / 1000L;
+ return true;
+
+ } else {
+ // ── Window transition ────────────────────────────────────────────
+ // If the stored epoch is exactly the one before ours, carry its count forward.
+ // If it's older (gap ≥ 2 windows), the previous window is effectively empty.
+ int prevEpoch = (int)((absEpoch - 1) & 0xFFFFFFFFL);
+ long oldCount = (storedEpoch == prevEpoch) ? count : 0L;
+
+ long newPacked = ((long) epoch << 32) | 1L;
+ if (!bucket.slot0.compareAndSet(packed, newPacked)) continue; // CAS lost, retry
+
+ // Won the transition: publish old count so the same-window branch can read it.
+ bucket.slot1.set(oldCount);
+
+ long estimate = (oldCount * (windowMs - elapsed)) / windowMs + 1;
+ out[0] = Math.max(0L, cfg.limit() - estimate);
+ out[1] = (absEpoch + 1) * windowMs / 1000L;
+ return estimate <= cfg.limit();
+ }
+ }
+ }
+}
diff --git a/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/strategy/TokenBucketStrategy.java b/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/strategy/TokenBucketStrategy.java
index f77545a..cb49430 100644
--- a/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/strategy/TokenBucketStrategy.java
+++ b/flash-extensions/flash-ext-limiter/src/main/java/dev/relism/ext/limiter/strategy/TokenBucketStrategy.java
@@ -17,9 +17,8 @@ import dev.relism.ext.limiter.RateLimitStrategy;
*
{@link Bucket#slot1} — last-refill timestamp in ms. 0 = not yet initialised.
*
*
- * Each request CAS-loops on {@code slot0}; {@code slot1} is updated best-effort after a
- * successful CAS. The resulting inaccuracy is bounded by the nanoseconds between the CAS
- * and the {@code set} — negligible and self-correcting for rate limiting purposes.
+ *
Each request CAS-loops on {@code slot0}; {@code slot1} is advanced monotonically via CAS
+ * after a successful token consumption — never regresses to an older timestamp under concurrent load.
*/
public final class TokenBucketStrategy implements RateLimitStrategy {
@@ -57,8 +56,8 @@ public final class TokenBucketStrategy implements RateLimitStrategy {
long newTokens = currentTokens - SCALE;
if (bucket.slot0.compareAndSet(rawTokens, newTokens)) {
- // Consumed successfully. Update refill baseline best-effort.
- bucket.slot1.set(now);
+ // Advance refill baseline: CAS ensures we never regress to an older timestamp.
+ if (lastMs < now) bucket.slot1.compareAndSet(lastMs, now);
out[0] = newTokens / SCALE;
out[1] = now / 1000L;
return true;
diff --git a/flash-extensions/flash-ext-oidc/README.md b/flash-extensions/flash-ext-oidc/README.md
index 1814a03..8382dce 100644
--- a/flash-extensions/flash-ext-oidc/README.md
+++ b/flash-extensions/flash-ext-oidc/README.md
@@ -3,6 +3,9 @@
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).
+
## What it provides
| Component | Description |
@@ -12,6 +15,7 @@ Supports Keycloak, Authelia, Auth0, Google, and any RFC 8414-compliant provider.
| `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 |
| `JwtValidator` | JWKS-backed JWT validator (PKCE + key rotation + caching) |
@@ -40,9 +44,14 @@ FlashApp.create(8080)
"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
@@ -86,6 +95,7 @@ OidcConfig.builder("https://auth.example.com", "my-client", "secret", "/auth/cal
| `.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)` | `InMemoryOidcSessionStore` | Custom session store (see below) |
@@ -104,6 +114,7 @@ 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
@@ -128,14 +139,35 @@ public class MePage extends JacksonHandler {
@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 you must apply the middleware explicitly. Retrieve it from the context
-after `install()` completes:
+For lambda routes, pass the middleware as a varargs argument. Retrieve `OidcMiddleware`
+from the context inside another extension's `routes()` phase, or after `start()`:
```java
OidcMiddleware oidc = app.ctx().require(OidcMiddleware.class);
@@ -144,21 +176,26 @@ OidcMiddleware oidc = app.ctx().require(OidcMiddleware.class);
app.get("/api/me", (req, res) -> {
OidcUser u = ClaimsHolder.user(); // never null here
return Map.of("sub", u.sub(), "email", u.email());
-}).with(oidc.protect());
+}, oidc.protect());
// Authentication + role check
app.delete("/api/admin/users/{id}", (req, res) -> {
OidcUser u = ClaimsHolder.user();
// ...
-}).with(oidc.requireRole("admin"));
+}, oidc.requireRole("admin"));
// Multiple roles (OR): passes if user holds any one of them
-app.get("/api/reports", (req, res) -> { ... })
- .with(oidc.requireRole("admin", "reports-viewer"));
+app.get("/api/reports", (req, res) -> { ... }, oidc.requireRole("admin", "reports-viewer"));
+
+// Require all listed scopes
+app.post("/api/orders", (req, res) -> { ... }, oidc.requireScopes("orders:write", "payments:write"));
+
+// Require at least one listed scope
+app.post("/api/payments", (req, res) -> { ... }, oidc.requireAnyScope("payments:write", "payments:admin"));
```
-`oidc.protect()` / `oidc.requireRole(...)` return a `Middleware` — a composable
-`Handler -> Handler` wrapper. Flash applies middleware right-to-left so the OIDC check
+`oidc.protect()` / `oidc.requireRole(...)` / `oidc.requireScopes(...)` return a `Middleware` — a composable
+`Handler → Handler` wrapper. Flash applies middleware right-to-left so the OIDC check
runs before your handler.
## Accessing the authenticated user
@@ -185,6 +222,14 @@ 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);
@@ -207,7 +252,7 @@ The middleware adds negligible overhead on the hot path for authenticated reques
| Step | Cost |
|---|---|
| `Authorization` header check | `O(1)` map lookup |
-| Cookie parse | `O(cookie_count)` string split |
+| 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()` |
@@ -216,6 +261,8 @@ No network calls, no cryptography, no JSON parsing on the happy path (valid sess
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:
@@ -227,6 +274,16 @@ On each request the middleware resolves credentials in this order:
- 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 |
@@ -249,6 +306,52 @@ At callback time the extension merges access token + ID token claims:
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 `oidc.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
The default `InMemoryOidcSessionStore` is sufficient for single-instance deployments.
@@ -316,32 +419,39 @@ app.install(new OidcExtension(tenantA))
.install(new OidcExtension(tenantB));
```
-To reference a specific tenant's middleware on lambda routes, keep the extension reference
-and retrieve `OidcMiddleware` from context **after** each install:
+To reference a specific tenant's middleware on lambda routes, keep the extension instances
+and retrieve `OidcMiddleware` from context after `start()`:
```java
-app.install(new OidcExtension(tenantA));
-OidcMiddleware mwA = app.ctx().require(OidcMiddleware.class); // tenantA's middleware
+OidcExtension extA = new OidcExtension(tenantA);
+OidcExtension extB = new OidcExtension(tenantB);
-app.install(new OidcExtension(tenantB));
-OidcMiddleware mwB = app.ctx().require(OidcMiddleware.class); // tenantB's middleware
+FlashApp app = FlashApp.create(8080)
+ .install(extA)
+ .install(extB)
+ .start()
+ .join(); // wait for bind
-app.get("/a/dashboard", (req, res) -> { ... }).with(mwA.protect());
-app.get("/b/dashboard", (req, res) -> { ... }).with(mwB.protect());
+OidcMiddleware mwA = app.ctx().require(OidcMiddleware.class); // last registered = tenantB
```
+> **Note:** because both extensions register `OidcMiddleware.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 middleware injected. For multi-tenant class-based handlers, use lambdas or
-install tenant-specific annotation processors.
+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 **before** `flash-ext-oidc`,
+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`
- or `@RolesAllowed`
+ , `@RolesAllowed`, or `@ScopesAllowed`
No extra code needed. To customize the scheme name:
diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcAuthPolicy.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcAuthPolicy.java
new file mode 100644
index 0000000..23ccce7
--- /dev/null
+++ b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcAuthPolicy.java
@@ -0,0 +1,98 @@
+package dev.relism.ext.oidc;
+
+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.
+ */
+final class OidcAuthPolicy {
+
+ private static final String[] EMPTY = new String[0];
+
+ private static final OidcAuthPolicy AUTH_REQUIRED = new OidcAuthPolicy(
+ false, EMPTY, EMPTY, ScopesAllowed.Match.ALL);
+ private static final OidcAuthPolicy AUTH_OPTIONAL = new OidcAuthPolicy(
+ true, EMPTY, EMPTY, ScopesAllowed.Match.ALL);
+
+ private final boolean optionalAuth;
+ private final String[] requiredRoles;
+ private final String[] requiredScopes;
+ private final ScopesAllowed.Match scopeMatch;
+
+ private OidcAuthPolicy(boolean optionalAuth,
+ String[] requiredRoles,
+ String[] requiredScopes,
+ ScopesAllowed.Match scopeMatch) {
+ this.optionalAuth = optionalAuth;
+ this.requiredRoles = requiredRoles;
+ this.requiredScopes = requiredScopes;
+ this.scopeMatch = scopeMatch;
+ }
+
+ static OidcAuthPolicy authenticated() { return AUTH_REQUIRED; }
+
+ static OidcAuthPolicy optional() { return AUTH_OPTIONAL; }
+
+ static OidcAuthPolicy rolesAny(String... roles) {
+ return new OidcAuthPolicy(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);
+ }
+
+ static OidcAuthPolicy 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 OidcAuthPolicy(optionalAuth, requiredRoles, requiredScopes, scopeMatch);
+ }
+
+ 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()));
+ }
+
+ boolean optionalAuth() { return optionalAuth; }
+
+ String[] requiredRoles() { return requiredRoles; }
+
+ String[] requiredScopes() { return requiredScopes; }
+
+ 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-oidc/src/main/java/dev/relism/ext/oidc/OidcConfig.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcConfig.java
index c8e0390..4820097 100644
--- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcConfig.java
+++ b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcConfig.java
@@ -17,6 +17,7 @@ package dev.relism.ext.oidc;
* "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
@@ -24,6 +25,7 @@ package dev.relism.ext.oidc;
* "https://auth.example.com",
* "my-app", "secret", "/auth/callback")
* .rolesClaimPath("groups")
+ * .scopeClaimPaths("scope,scp")
* .build();
*
* // Two tenants on one server
@@ -45,6 +47,7 @@ public final class OidcConfig {
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 OidcSessionStore sessionStore;
@@ -61,6 +64,7 @@ public final class OidcConfig {
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
@@ -80,6 +84,8 @@ public final class OidcConfig {
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 OidcSessionStore sessionStore() { return sessionStore; }
@@ -102,6 +108,7 @@ public final class OidcConfig {
* 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: /
*
@@ -113,6 +120,7 @@ public final class OidcConfig {
.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(
@@ -179,6 +187,7 @@ public final class OidcConfig {
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 OidcSessionStore sessionStore;
@@ -203,6 +212,8 @@ public final class OidcConfig {
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 /}). */
diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcExtension.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcExtension.java
index 9ac0a66..940fbd0 100644
--- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcExtension.java
+++ b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcExtension.java
@@ -21,21 +21,22 @@ import java.util.UUID;
/**
* Full OIDC Authorization Code + PKCE flow for Flash.
*
- * On {@link #install}, the extension:
+ *
At {@link #provide}, the extension:
*
* - Fetches the provider discovery document — fail-fast at startup.
- * - Registers three routes on the {@link FlashApp}:
- *
- * - {@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 the provider's
- * end-session endpoint (if available) or to {@link OidcConfig#postLogoutRedirectUri()}.
- *
- *
* - Provides {@link OidcMiddleware} and {@link JwtValidator} in the context.
- * - Registers an annotation processor for {@link Authenticated} and {@link RolesAllowed}.
+ * - Registers annotation processors for {@link Authenticated}, {@link RolesAllowed}
+ * and {@link ScopesAllowed}.
*
*
+ * 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(
@@ -54,41 +55,48 @@ 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 OidcMiddleware oidcMw;
+
public OidcExtension(OidcConfig config) {
this.config = config;
}
- @Override
- public void install(FlashRegistrar app, FlashContext ctx) {
+ // ── Phase 1: services ─────────────────────────────────────────────────────
- // 1. Build the shared HttpClient (optionally with TLS verification disabled)
+ @Override
+ public void provide(FlashContext ctx) {
HttpClient http = buildHttpClient(config);
- // 2. Discover provider endpoints (blocking; fail fast at startup)
- OidcProviderMetadata meta;
+ // 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);
+ throw new IllegalStateException("OIDC discovery failed for issuer: " + config.issuer(), e);
}
- // 3. JWKS-backed access-token validator
- JwtValidator validator = new JwtValidator(
- meta.jwksUri(), config.issuer(), config.clientId(),
- config.algorithm(), http);
+ 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);
- // 4. PKCE state store (per extension instance — safe for multi-tenant)
- OidcStateStore stateStore = new OidcStateStore();
-
- // 5. Shared token client (injected into middleware for refresh)
- TokenClient tokenClient = new TokenClient(http, config);
-
- // 6. Middleware (also exposed in context for manual lambda-route protection)
- OidcMiddleware oidcMw = new OidcMiddleware(validator, config, meta, tokenClient);
ctx.provide(OidcMiddleware.class, oidcMw);
ctx.provide(JwtValidator.class, validator);
+ ctx.addAnnotationProcessor(handlerClass -> {
+ OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(handlerClass);
+ return policy != null ? List.of(oidcMw.policyMiddleware(policy)) : List.of();
+ });
+ }
+
+ // ── Phase 2: routes ───────────────────────────────────────────────────────
+
+ @Override
+ public void routes(FlashRegistrar> app, FlashContext ctx) {
String prefix = config.routePrefix();
// ── GET {prefix}/login ────────────────────────────────────────────────
@@ -97,11 +105,10 @@ public class OidcExtension implements FlashExtension {
app.get(prefix + "/login", (req, res) -> {
String verifier = PkceUtils.generateVerifier();
String challenge = PkceUtils.computeChallenge(verifier);
- String state = UUID.randomUUID().toString(); // CSRF protection
- String nonce = UUID.randomUUID().toString(); // ID token replay protection
+ String state = UUID.randomUUID().toString();
+ String nonce = UUID.randomUUID().toString();
String redirect = req.query("redirect");
- // Only allow relative paths — prevents open-redirect attacks
if (redirect == null || !redirect.startsWith("/")) redirect = "/";
stateStore.put(state, redirect, verifier, nonce);
@@ -118,7 +125,7 @@ public class OidcExtension implements FlashExtension {
res.redirect(authUrl);
return null;
- }).with();
+ });
// ── GET {prefix}/callback ─────────────────────────────────────────────
// Validates state, exchanges code for tokens, creates session, redirects.
@@ -154,32 +161,24 @@ public class OidcExtension implements FlashExtension {
}
Map claims = mergeClaims(tokens);
-
OidcSession session = new OidcSession(
UUID.randomUUID().toString(),
- tokens.accessToken(),
- tokens.idToken(),
- tokens.refreshToken(),
- Instant.now().plusSeconds(tokens.expiresIn()),
- claims
- );
+ 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;
- }).with();
+ });
// ── POST {prefix}/logout ──────────────────────────────────────────────
- // Invalidates the local session and redirects to the provider's
- // end_session_endpoint (with id_token_hint) if available.
+ // Invalidates the local session and redirects to end_session_endpoint.
app.post(prefix + "/logout", (req, res) -> {
String sessionId = OidcMiddleware.cookieValue(req, "oidc_session");
String idTokenHint = null;
if (sessionId != null) {
- config.sessionStore().find(sessionId)
- .ifPresent(s -> {}); // capture id_token before delete
OidcSession session = config.sessionStore().find(sessionId).orElse(null);
if (session != null) idTokenHint = session.idToken();
config.sessionStore().delete(sessionId);
@@ -199,25 +198,11 @@ public class OidcExtension implements FlashExtension {
location = config.postLogoutRedirectUri();
}
- res.header("Set-Cookie", clearCookie)
- .redirect(location);
+ res.header("Set-Cookie", clearCookie).redirect(location);
return null;
- }).with();
-
- // 5. Annotation processor for @Authenticated / @RolesAllowed
- ctx.addAnnotationProcessor(handlerClass -> {
- RolesAllowed roles = handlerClass.getAnnotation(RolesAllowed.class);
- if (roles != null) return List.of(oidcMw.rolesMiddleware(roles.value()));
-
- Authenticated auth = handlerClass.getAnnotation(Authenticated.class);
- if (auth != null) return List.of(auth.optional()
- ? oidcMw.optionalMiddleware()
- : oidcMw.authenticatedMiddleware());
-
- return List.of();
});
- // Register OpenAPI security scheme if flash-ext-openapi is on the classpath
+ // Register OpenAPI security scheme if flash-ext-openapi is on the classpath.
try {
OpenApiIntegration.register(ctx, config, meta);
} catch (NoClassDefFoundError ignored) {
@@ -225,24 +210,16 @@ public class OidcExtension implements FlashExtension {
}
}
- // -- Helpers --------------------------------------------------------------
+ // ── Helpers ───────────────────────────────────────────────────────────────
/**
* Merges claims from both the access token and the ID token.
- * The access token carries provider-specific data like {@code realm_access.roles};
- * the ID token carries standard identity claims (sub, email, name, …).
* ID token values win on conflict so that verified identity claims are authoritative.
*/
private static Map mergeClaims(OidcTokenResponse tokens) {
Map merged = new HashMap<>();
- // Access token first — provides roles, resource_access, etc.
- if (tokens.accessToken() != null) {
- merged.putAll(JwtUtils.parseClaims(tokens.accessToken()));
- }
- // ID token overrides — its identity claims (sub, email, name, …) take priority.
- if (tokens.idToken() != null) {
- merged.putAll(JwtUtils.parseClaims(tokens.idToken()));
- }
+ if (tokens.accessToken() != null) merged.putAll(JwtUtils.parseClaims(tokens.accessToken()));
+ if (tokens.idToken() != null) merged.putAll(JwtUtils.parseClaims(tokens.idToken()));
return Map.copyOf(merged);
}
@@ -255,22 +232,18 @@ public class OidcExtension implements FlashExtension {
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) {}
+ public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
+ public void checkClientTrusted(X509Certificate[] c, String a) {}
+ public void checkServerTrusted(X509Certificate[] c, String a) {}
}};
- SSLContext ctx = SSLContext.getInstance("TLS");
- ctx.init(null, trustAll, new SecureRandom());
- return HttpClient.newBuilder().sslContext(ctx).build();
+ 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);
}
}
- /**
- * Resolves the configured {@code redirectUri}. If it starts with {@code /},
- * prepends {@code selfScheme://Host} from the current request.
- */
private String absoluteRedirectUri(dev.relism.models.Request req) {
return absoluteSelf(req, config.redirectUri());
}
@@ -291,7 +264,6 @@ public class OidcExtension implements FlashExtension {
/**
* Loaded lazily so that {@code flash-ext-openapi} classes are only resolved at
* runtime when {@link dev.relism.ext.openapi.OpenApiSecurityRegistry} is actually on the classpath.
- * If not present, the {@link NoClassDefFoundError} is caught at the call site.
*/
private static final class OpenApiIntegration {
static void register(dev.relism.extension.FlashContext ctx,
@@ -304,9 +276,6 @@ public class OidcExtension implements FlashExtension {
@Override
public java.util.Map schemeDefinition() {
- // Declare only the authorizationCode flow so Swagger UI shows
- // a single clean "Authorize" dialog instead of expanding every
- // grant type from the discovery document.
java.util.Map scopesMap = new java.util.LinkedHashMap<>();
for (String s : config.scopes().split("\\s+")) {
if (!s.isBlank()) scopesMap.put(s, s);
@@ -322,19 +291,11 @@ public class OidcExtension implements FlashExtension {
return scheme;
}
- @Override
- public java.util.List requiredFor(Class> handlerClass) {
- dev.relism.ext.oidc.RolesAllowed roles =
- handlerClass.getAnnotation(dev.relism.ext.oidc.RolesAllowed.class);
- if (roles != null) return java.util.Arrays.asList(roles.value());
-
- dev.relism.ext.oidc.Authenticated auth =
- handlerClass.getAnnotation(dev.relism.ext.oidc.Authenticated.class);
- if (auth != null) return java.util.List.of();
-
- return null; // not secured by this contributor
- }
- }));
+ @Override
+ public java.util.List requiredFor(Class> handlerClass) {
+ return OidcAuthPolicy.openApiScopesFor(handlerClass);
+ }
+ }));
}
}
}
diff --git a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcMiddleware.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcMiddleware.java
index 58952ae..a64c260 100644
--- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcMiddleware.java
+++ b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcMiddleware.java
@@ -7,6 +7,7 @@ import dev.relism.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;
@@ -15,7 +16,7 @@ import java.util.Optional;
/**
* Request-level OIDC middleware. Exposed in the {@link dev.relism.extension.FlashContext}
* for manual use on lambda routes; injected automatically for handlers annotated with
- * {@link Authenticated} or {@link RolesAllowed}.
+ * {@link Authenticated}, {@link RolesAllowed} or {@link ScopesAllowed}.
*
* Resolution order on each request:
*
@@ -36,10 +37,14 @@ import java.util.Optional;
*/
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) {
@@ -47,6 +52,8 @@ public class OidcMiddleware {
this.config = config;
this.meta = meta;
this.tokenClient = tokenClient;
+ this.roleClaimPathParts = splitClaimPath(config.rolesClaimPath());
+ this.scopeClaimPathParts = splitClaimPaths(config.scopeClaimPaths());
}
// -- Public API -----------------------------------------------------------
@@ -75,7 +82,7 @@ public class OidcMiddleware {
* logged in (e.g. showing a username on a landing page).
*
* {@code
- * app.get("/", handler).with(oidc.optional());
+ * app.get("/", handler, oidc.optional());
* // Inside handler: ClaimsHolder.user() is non-null iff the user is logged in.
* }
*/
@@ -92,15 +99,15 @@ public class OidcMiddleware {
}
/**
- * 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()}.
+ * Compiled authorization policy path used by annotation-driven mounting.
+ * The policy is immutable and built once at boot.
*/
- public Middleware requireRole(String... roles) {
+ public Middleware authorize(OidcAuthPolicy policy) {
+ if (policy.optionalAuth()) return optional();
return next -> (req, res) -> {
Map claims = resolve(req, res);
if (claims == null) return null;
- checkRoles(claims, roles);
+ enforcePolicy(claims, policy, res);
ClaimsHolder.set(claims);
try {
return next.handle(req, res);
@@ -110,11 +117,40 @@ public class OidcMiddleware {
};
}
+ /**
+ * 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 ------------------------------------------------------------
@@ -123,9 +159,14 @@ public class OidcMiddleware {
* when no valid credentials are present. Used by {@link #optional()}.
*/
private Map resolveQuiet(Request req) {
- String auth = req.header("Authorization");
- if (auth != null && auth.startsWith("Bearer "))
- return validator.validate(auth.substring(7));
+ 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) {
@@ -153,9 +194,15 @@ public class OidcMiddleware {
*/
private Map resolve(Request req, dev.relism.models.Response res) {
// 1. Bearer token
- String auth = req.header("Authorization");
- if (auth != null && auth.startsWith("Bearer "))
- return validator.validate(auth.substring(7));
+ String bearerToken = extractBearerToken(req.header("Authorization"));
+ if (bearerToken != null) {
+ try {
+ return validator.validate(bearerToken);
+ } catch (HttpException e) {
+ res.header("WWW-Authenticate", invalidTokenChallenge());
+ throw e;
+ }
+ }
// 2. Session cookie
String sessionId = cookieValue(req, "oidc_session");
@@ -183,8 +230,10 @@ public class OidcMiddleware {
// 3. No valid credentials
String accept = req.header("Accept");
- if (accept != null && accept.contains("application/json"))
+ if (accept != null && accept.contains("application/json")) {
+ res.header("WWW-Authenticate", bearerChallenge());
throw HttpException.unauthorized();
+ }
// Browser — redirect to login, preserving the original URL in state
String loginUrl = config.routePrefix() + "/login?redirect="
@@ -209,25 +258,200 @@ public class OidcMiddleware {
);
}
+ private void enforcePolicy(Map claims, OidcAuthPolicy policy, dev.relism.models.Response res) {
+ checkRoles(claims, policy.requiredRoles());
+ checkScopes(claims, policy.requiredScopes(), policy.scopeMatch(), res);
+ }
+
private void checkRoles(Map claims, String[] required) {
- List actual = extractRoles(claims);
- for (String role : required) {
- if (actual.contains(role)) return;
- }
+ if (required.length == 0) return;
+ if (rolesAllowed(claims, required)) return;
throw HttpException.forbidden();
}
- @SuppressWarnings("unchecked")
- private List extractRoles(Map claims) {
- String[] parts = config.rolesClaimPath().split("\\.");
- Object current = claims;
- for (String part : parts) {
- if (!(current instanceof Map, ?> m)) return List.of();
- current = m.get(part);
+ private void checkScopes(Map claims, String[] required, ScopesAllowed.Match match,
+ dev.relism.models.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 (current instanceof List> list)
- return list.stream().map(Object::toString).toList();
- return List.of();
+ 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 BEARER + " realm=\"" + quoted(config.schemeName()) + "\"";
+ }
+
+ String invalidTokenChallenge() {
+ return bearerChallenge() + ", error=\"invalid_token\"";
+ }
+
+ String insufficientScopeChallenge(String[] requiredScopes) {
+ return bearerChallenge() + ", error=\"insufficient_scope\", scope=\""
+ + quoted(spaceDelimited(requiredScopes)) + "\"";
+ }
+
+ 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) {
@@ -246,10 +470,20 @@ public class OidcMiddleware {
static String cookieValue(Request req, String name) {
String header = req.header("Cookie");
if (header == null || header.isBlank()) return null;
- for (String part : header.split(";")) {
- int eq = part.indexOf('=');
- if (eq > 0 && part.substring(0, eq).strip().equals(name))
- return part.substring(eq + 1).strip();
+ 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/ext/oidc/OidcUser.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcUser.java
index 2d73f2e..e8382b1 100644
--- a/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcUser.java
+++ b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/OidcUser.java
@@ -2,6 +2,7 @@ package dev.relism.ext.oidc;
import java.util.List;
import java.util.Map;
+import java.util.ArrayList;
/**
* Type-safe view over the JWT claims stored in {@link ClaimsHolder}.
@@ -16,7 +17,7 @@ import java.util.Map;
* // 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());
+ * 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):
@@ -80,6 +81,67 @@ public final class OidcUser {
return roles(claimPath).contains(role);
}
+ // -- Scopes ---------------------------------------------------------------
+
+ /**
+ * Resolves OAuth2 scopes from standard OIDC/OAuth claims using 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 ─────────────────────────────────────────────
/**
@@ -103,4 +165,73 @@ public final class OidcUser {
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-oidc/src/main/java/dev/relism/ext/oidc/ScopesAllowed.java b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/ScopesAllowed.java
new file mode 100644
index 0000000..5213e6d
--- /dev/null
+++ b/flash-extensions/flash-ext-oidc/src/main/java/dev/relism/ext/oidc/ScopesAllowed.java
@@ -0,0 +1,45 @@
+package dev.relism.ext.oidc;
+
+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 token carries the required OAuth2 scopes.
+ * Authentication is implicitly required.
+ *
+ * Scopes are resolved from the configured claim paths in
+ * {@link OidcConfig#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-oidc/src/test/java/dev/relism/ext/oidc/OidcAuthPolicyTest.java b/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/ext/oidc/OidcAuthPolicyTest.java
new file mode 100644
index 0000000..0223937
--- /dev/null
+++ b/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/ext/oidc/OidcAuthPolicyTest.java
@@ -0,0 +1,94 @@
+package dev.relism.ext.oidc;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class OidcAuthPolicyTest {
+
+ 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(OidcAuthPolicy.compileFromAnnotations(PlainHandler.class));
+ }
+
+ @Test
+ void compileFromAnnotations_authenticated_createsRequiredAuthPolicy() {
+ OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(AuthenticatedHandler.class);
+ assertNotNull(policy);
+ assertFalse(policy.optionalAuth());
+ assertEquals(0, policy.requiredRoles().length);
+ assertEquals(0, policy.requiredScopes().length);
+ }
+
+ @Test
+ void compileFromAnnotations_optionalAuth_createsOptionalPolicy() {
+ OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(OptionalHandler.class);
+ assertNotNull(policy);
+ assertTrue(policy.optionalAuth());
+ }
+
+ @Test
+ void compileFromAnnotations_rolesAndScopes_areNormalizedAndMerged() {
+ OidcAuthPolicy policy = OidcAuthPolicy.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() {
+ OidcAuthPolicy policy = OidcAuthPolicy.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,
+ () -> OidcAuthPolicy.compileFromAnnotations(InvalidOptionalHandler.class));
+ }
+
+ @Test
+ void openApiScopesFor_returnsScopesWhenPresent() {
+ assertEquals(List.of("orders:write", "payments:write"),
+ OidcAuthPolicy.openApiScopesFor(ScopesHandler.class));
+ }
+
+ @Test
+ void openApiScopesFor_rolesOnly_returnsEmptyList() {
+ assertEquals(List.of(), OidcAuthPolicy.openApiScopesFor(RolesHandler.class));
+ }
+
+ @Test
+ void openApiScopesFor_noSecurity_returnsNull() {
+ assertNull(OidcAuthPolicy.openApiScopesFor(PlainHandler.class));
+ }
+}
diff --git a/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/ext/oidc/OidcMiddlewareAuthzTest.java b/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/ext/oidc/OidcMiddlewareAuthzTest.java
new file mode 100644
index 0000000..588777c
--- /dev/null
+++ b/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/ext/oidc/OidcMiddlewareAuthzTest.java
@@ -0,0 +1,72 @@
+package dev.relism.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/ext/oidc/OidcUserScopesTest.java b/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/ext/oidc/OidcUserScopesTest.java
new file mode 100644
index 0000000..5657000
--- /dev/null
+++ b/flash-extensions/flash-ext-oidc/src/test/java/dev/relism/ext/oidc/OidcUserScopesTest.java
@@ -0,0 +1,47 @@
+package dev.relism.ext.oidc;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class OidcUserScopesTest {
+
+ @Test
+ void scopes_readsStandardScopeString() {
+ OidcUser user = new OidcUser(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() {
+ OidcUser user = new OidcUser(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() {
+ OidcUser user = new OidcUser(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() {
+ OidcUser user = new OidcUser(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-openapi/README.md b/flash-extensions/flash-ext-openapi/README.md
index 5994374..25f95c3 100644
--- a/flash-extensions/flash-ext-openapi/README.md
+++ b/flash-extensions/flash-ext-openapi/README.md
@@ -15,8 +15,9 @@ from class-based handlers annotated with `@ApiOperation`.
## Dependencies
-Requires `flash-ext-jackson` installed **before** this extension (shares its `ObjectMapper` from context).
-If `flash-ext-oidc` is installed **after** this extension, OIDC security schemes are injected automatically.
+Requires `flash-ext-jackson` (shares its `ObjectMapper` from context).
+If `flash-ext-oidc` is also installed, OIDC security schemes are injected automatically.
+Install order is irrelevant — the two-phase extension model handles dependency ordering.
```xml
@@ -51,7 +52,7 @@ All annotations target the **handler class** (`@Target(ElementType.TYPE)`).
### @ApiOperation
```java
-@Route(method = HttpMethod.GET, path = "/api/blogs")
+@GET("/api/blogs")
@ApiOperation(
summary = "List all blogs",
description = "Returns a paginated list of published blog posts.",
@@ -98,7 +99,7 @@ Repeatable — declare query, path, header, or cookie parameters explicitly.
public class GetBlog extends JacksonHandler { ... }
```
-> Path parameters declared in `@Route(path = "/blogs/{id}")` are extracted and added automatically
+> Path parameters in the route (e.g. `@GET("/blogs/{id}")` or `@Route(path = "/blogs/{id}")`) are extracted automatically
> as required path parameters — you only need `@ApiParam` for query / header / cookie params.
`@ApiParam` is repeatable. If you prefer grouping them, `@ApiParams({ @ApiParam(...), @ApiParam(...) })` is
diff --git a/flash-extensions/flash-ext-openapi/pom.xml b/flash-extensions/flash-ext-openapi/pom.xml
index 448ade5..aa85872 100644
--- a/flash-extensions/flash-ext-openapi/pom.xml
+++ b/flash-extensions/flash-ext-openapi/pom.xml
@@ -15,7 +15,7 @@
dev.relism
- flash-ext-jackson
+ flash
com.fasterxml.jackson.dataformat
diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiOperation.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiOperation.java
index 8cb4c93..3817cb0 100644
--- a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiOperation.java
+++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiOperation.java
@@ -10,7 +10,7 @@ import java.lang.annotation.Target;
* Picked up by {@link OpenApiExtension} via {@link FlashApp#register}.
*
* {@code
- * @Route(method = HttpMethod.GET, path = "/api/blogs")
+ * @GET("/api/blogs")
* @ApiOperation(summary = "List all blogs", tags = {"blogs"})
* public class ListBlogs extends JacksonHandler { ... }
* }
diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiResponse.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiResponse.java
index 282e1b6..773506b 100644
--- a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiResponse.java
+++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ApiResponse.java
@@ -20,4 +20,5 @@ public @interface ApiResponse {
int status();
String description() default "";
Class> schema() default Void.class;
+ boolean useReturnType() default false;
}
diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ArraySchema.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ArraySchema.java
new file mode 100644
index 0000000..93253a8
--- /dev/null
+++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/ArraySchema.java
@@ -0,0 +1,16 @@
+package dev.relism.ext.openapi;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/** Optional array-specific schema metadata. */
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.FIELD, ElementType.METHOD})
+public @interface ArraySchema {
+ Class> itemClass() default Void.class;
+ boolean uniqueItems() default false;
+ int minItems() default -1;
+ int maxItems() default -1;
+}
diff --git a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiBuilder.java b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiBuilder.java
index 499602e..4b7bd26 100644
--- a/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiBuilder.java
+++ b/flash-extensions/flash-ext-openapi/src/main/java/dev/relism/ext/openapi/OpenApiBuilder.java
@@ -1,68 +1,65 @@
package dev.relism.ext.openapi;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonProperty.Access;
import dev.relism.routing.Route;
+import java.lang.reflect.*;
+import java.time.*;
import java.util.*;
/**
- * Accumulates OpenAPI 3.0 operations and builds the spec document as a plain
- * {@code Map} for Jackson to serialize. Operations are added at registration time
- * via {@link OpenApiExtension}'s {@link dev.relism.AnnotationProcessor}.
+ * OpenAPI document assembler.
+ *
+ * Collects operation metadata at route registration time and renders an OpenAPI 3.0.3 map.
+ * Response schemas are resolved automatically into components.schemas.
*/
-public class OpenApiBuilder {
+public final class OpenApiBuilder {
- private String title = "API";
+ private String title = "API";
private String version = "1.0.0";
private String description = "";
private final Map> paths = new LinkedHashMap<>();
private final Map>> operationHandlers = new LinkedHashMap<>();
+ private final SchemaRegistry schemas = new SchemaRegistry();
private OpenApiSecurityRegistry securityRegistry;
+ // Build cache: OpenAPI is rendered only when the document revision changes.
+ private int revision;
+ private int builtRevision = -1;
+ private Map cachedSpec;
- // ── Configuration ─────────────────────────────────────────────────────────
-
- public OpenApiBuilder title(String title) { this.title = title; return this; }
- public OpenApiBuilder version(String version) { this.version = version; return this; }
+ public OpenApiBuilder title(String title) { this.title = title; return this; }
+ public OpenApiBuilder version(String version) { this.version = version; return this; }
public OpenApiBuilder description(String description) { this.description = description; return this; }
+ void setSecurityRegistry(OpenApiSecurityRegistry registry) { this.securityRegistry = registry; }
- void setSecurityRegistry(OpenApiSecurityRegistry registry) {
- this.securityRegistry = registry;
- }
-
- // ── Operation registration ────────────────────────────────────────────────
-
- /**
- * Adds an operation derived from the handler's {@link Route}, {@link ApiOperation},
- * {@link ApiResponse}, and {@link ApiParam} annotations.
- */
public void addOperation(Route route, ApiOperation op, Class> handlerClass) {
- String path = normalizePath(route.path());
- String method = route.method().name().toLowerCase();
+ String path = normalizePath(route.path());
+ String method = route.method().name().toLowerCase(Locale.ROOT);
- Map pathItem = paths.computeIfAbsent(path, k -> new LinkedHashMap<>());
Map operation = new LinkedHashMap<>();
-
- if (!op.operationId().isEmpty()) operation.put("operationId", op.operationId());
- if (!op.summary().isEmpty()) operation.put("summary", op.summary());
- if (!op.description().isEmpty()) operation.put("description", op.description());
- if (op.tags().length > 0) operation.put("tags", Arrays.asList(op.tags()));
- if (op.deprecated()) operation.put("deprecated", true);
+ if (!op.operationId().isEmpty()) operation.put("operationId", op.operationId());
+ if (!op.summary().isEmpty()) operation.put("summary", op.summary());
+ if (!op.description().isEmpty()) operation.put("description", op.description());
+ if (op.tags().length > 0) operation.put("tags", Arrays.asList(op.tags()));
+ if (op.deprecated()) operation.put("deprecated", true);
buildParameters(operation, handlerClass, route);
buildResponses(operation, handlerClass);
- pathItem.put(method, operation);
+ paths.computeIfAbsent(path, k -> new LinkedHashMap<>()).put(method, operation);
operationHandlers.computeIfAbsent(path, k -> new LinkedHashMap<>()).put(method, handlerClass);
+ revision++;
}
- // ── Spec build ────────────────────────────────────────────────────────────
-
- /**
- * Returns the complete OpenAPI 3.0.3 spec as a plain map ready for JSON
- * serialization. Called on each request to {@code /openapi.json} so that
- * handlers registered after the extension is installed are included.
- */
public Map build() {
+ int r = revision;
+ Map cached = cachedSpec;
+ if (cached != null && builtRevision == r) return cached;
+
Map info = new LinkedHashMap<>();
info.put("title", title);
info.put("version", version);
@@ -71,8 +68,6 @@ public class OpenApiBuilder {
List contributors = securityRegistry != null
? securityRegistry.contributors() : List.of();
- // Build paths with security injected per-operation (fresh copy each time so
- // repeated calls don't accumulate duplicate security entries)
Map renderedPaths = new LinkedHashMap<>();
for (var pathEntry : paths.entrySet()) {
Map renderedPathItem = new LinkedHashMap<>();
@@ -80,7 +75,7 @@ public class OpenApiBuilder {
for (var methodEntry : pathEntry.getValue().entrySet()) {
@SuppressWarnings("unchecked")
Map original = (Map) methodEntry.getValue();
- Map op = new LinkedHashMap<>(original); // shallow copy
+ Map op = new LinkedHashMap<>(original);
Class> handler = handlers.get(methodEntry.getKey());
if (handler != null && !contributors.isEmpty()) {
List