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:
- Bearer token (
Authorization: Bearer <jwt>) — validated against JWKS. - Session cookie (
oidc_session) — looked up in the session store; transparently refreshed if the access token is expired (silent refresh via refresh token). - No valid credentials:
- Browser clients (no
Accept: application/json) → redirect to{prefix}/login?redirect={path} - API clients →
401 Unauthorized
- Browser clients (no
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:
- Reads the
oidc_sessioncookie, looks up the session, retrieves theid_token. - Deletes the local session and clears the cookie (
Max-Age=0). - 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. - 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.securitySchemesentry for the provider (OAuth2, authorizationCode flow) - Adds
securityrequirements to every operation whose handler carries@Authenticatedor@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.