Files
Flash5/flash-extensions/flash-ext-oidc/README.md
T

12 KiB

flash-ext-oidc

Full OIDC Authorization Code + PKCE flow for the Flash HTTP server. Supports Keycloak, Authelia, Auth0, Google, and any RFC 8414-compliant provider.

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
@Authenticated Annotation: protects a class-based handler (redirects browsers, 401 for API clients)
@RolesAllowed(...) Annotation: protects with role check (OR semantics)
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)

Dependencies

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

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

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
.algorithm("RS256") "RS256" JWS algorithm for token validation
.postLogoutRedirectUri("/") "/" Where to redirect after logout
.sessionStore(store) InMemoryOidcSessionStore 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_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) {
        OidcUser u = ClaimsHolder.user();
        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 { ... }

The middleware is injected automatically by the annotation processor — no manual wiring needed.

Lambda routes (manual middleware)

For lambda routes you must apply the middleware explicitly. Retrieve it from the context after install() completes:

OidcMiddleware oidc = app.ctx().require(OidcMiddleware.class);

// Authentication only
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());

// Authentication + role check
app.delete("/api/admin/users/{id}", (req, res) -> {
    OidcUser u = ClaimsHolder.user();
    // ...
}).with(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"));

oidc.protect() / oidc.requireRole(...) 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

ClaimsHolder holds the JWT claims for the current request in a ThreadLocal. It is populated by the OIDC middleware before your handler runs and cleared in the finally block afterward. It is safe with virtual threads (each request gets its own virtual thread, so ThreadLocal values are naturally isolated).

OidcUser (preferred)

OidcUser u = ClaimsHolder.user();   // 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");

// 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.get();
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_count) string split
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.

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

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.

Session store

The default InMemoryOidcSessionStore is sufficient for single-instance deployments. For clustered deployments, implement OidcSessionStore:

public interface OidcSessionStore {
    void              save(OidcSession session);
    Optional<OidcSession> find(String sessionId);
    void              delete(String sessionId);
}
OidcConfig.builder(...)
    .sessionStore(new RedisOidcSessionStore(redisClient))
    .build()

OidcSession 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.user() 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 reference and retrieve OidcMiddleware from context after each install:

app.install(new OidcExtension(tenantA));
OidcMiddleware mwA = app.ctx().require(OidcMiddleware.class);   // tenantA's middleware

app.install(new OidcExtension(tenantB));
OidcMiddleware mwB = app.ctx().require(OidcMiddleware.class);   // tenantB's middleware

app.get("/a/dashboard", (req, res) -> { ... }).with(mwA.protect());
app.get("/b/dashboard", (req, res) -> { ... }).with(mwB.protect());

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.

OpenAPI integration

If flash-ext-openapi is on the classpath and installed before flash-ext-oidc, 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

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.