Files
Flash5/flash-extensions/flash-ext-auth-oidc/README.md
T
Zakaria El Orche 829b9bf348 feat(ext-mcp): let applications put middleware on the MCP route, and document the auth split
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.
2026-09-10 19:12:44 +00:00

18 KiB

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, which owns everything downstream of identifying the caller. Shorter guides live in docs/, including migration notes 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 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

<dependency>
    <groupId>dev.relism</groupId>
    <artifactId>flash-ext-auth-oidc</artifactId>
    <version>2.1.0-SNAPSHOT</version>
</dependency>

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

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

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

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)

@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():

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)

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<String> roles = u.roles("realm_access.roles");   // Keycloak realm roles
List<String> clientRoles = u.roles("resource_access.my-client.roles"); // Keycloak client roles
List<String> groups = u.roles("groups");              // Authelia

boolean isAdmin = u.hasRole("realm_access.roles", "admin");

// Scopes (OIDC/OAuth2 generic): checks "scope" then "scp"
List<String> scopes = u.scopes();
boolean canWrite = u.hasScope("orders:write");

// Custom claim path resolution (for provider-specific payloads)
List<String> 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<String, Object> all = u.claims();

Raw access (escape hatch)

Map<String, Object> 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 <jwt>) — 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="<schemeName>"
  • invalid bearer token: Bearer realm="<schemeName>", error="invalid_token"
  • insufficient scopes: Bearer realm="<schemeName>", error="insufficient_scope", scope="<required scopes>"

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 -> <your-client> -> 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.

The default InMemorySessionStore is sufficient for single-instance deployments. For clustered deployments, implement SessionStore:

public interface SessionStore {
    void              save(Session session);
    Optional<Session> find(String sessionId);
    void              delete(String sessionId);
}
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 <form> is sufficient (no JavaScript needed):

<form method="POST" action="/auth/logout">
    <button type="submit">Logout</button>
</form>

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=<idToken>&post_logout_redirect_uri=<postLogoutRedirectUri> — 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 <access_token>

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):

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():

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:

OidcConfig.builder(...).schemeName("keycloak").build()

If flash-ext-openapi is absent the integration is silently skipped.