refactor(ext-auth): generic sessions, shared annotation wiring, rename to flash-ext-auth-oidc
AuthMiddleware.install(ctx, config, source) now owns the annotation processor and the flash.auth.policy key, so a second credential source gets annotation-driven authorization without copying the wiring. The key is public: an extension that contributes middleware can order itself around authentication. OidcSession becomes Session in auth-core, carrying claims, an expiry and an opaque attribute map. OpenID Connect keeps its access, id and refresh tokens in that map under its own keys, so renewal stays its business and core has no OAuth2 vocabulary in it. isAccessTokenExpired() becomes isExpired(), with the 30s eager-renewal window it always had and now a test for it. flash-ext-oidc is renamed flash-ext-auth-oidc, matching cache-core/cache-caffeine and data-core/data-hibernate.
This commit is contained in:
@@ -0,0 +1,462 @@
|
||||
# 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).
|
||||
|
||||
## 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) |
|
||||
| `@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) |
|
||||
|
||||
## Dependencies
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-auth-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
|
||||
|
||||
```java
|
||||
FlashApp.create(8080)
|
||||
.install(new JacksonExtension())
|
||||
.install(new OpenApiExtension(...)) // optional — enables Swagger security
|
||||
.install(new OidcExtension(
|
||||
OidcConfig.builder(
|
||||
"https://idp.example.com",
|
||||
"my-client", "my-secret", "/auth/callback")
|
||||
.build()
|
||||
))
|
||||
.start();
|
||||
```
|
||||
|
||||
Install order is irrelevant. The two-phase extension model guarantees all services
|
||||
(including `OpenApiSecurityRegistry` from `flash-ext-openapi`) are registered before
|
||||
any extension's routes phase runs.
|
||||
|
||||
### Keycloak shortcut
|
||||
|
||||
```java
|
||||
OidcConfig.keycloak(
|
||||
"https://keycloak.example.com", // server URL (no realm)
|
||||
"myrealm", // realm
|
||||
"my-client", "my-secret", // client credentials
|
||||
"/auth/callback") // redirect URI (server-relative)
|
||||
.https() // behind TLS
|
||||
.build()
|
||||
```
|
||||
|
||||
`keycloak()` pre-sets `rolesClaimPath("realm_access.roles")` and constructs the issuer as
|
||||
`{serverUrl}/realms/{realm}`.
|
||||
|
||||
### Authelia / generic IdP
|
||||
|
||||
```java
|
||||
OidcConfig.builder("https://auth.example.com", "my-client", "secret", "/auth/callback")
|
||||
.rolesClaimPath("groups")
|
||||
.build()
|
||||
```
|
||||
|
||||
## OidcConfig reference
|
||||
|
||||
### Required fields
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `issuer` | Provider base URL — also used for OIDC discovery |
|
||||
| `clientId` | OAuth2 client ID |
|
||||
| `clientSecret` | OAuth2 client secret |
|
||||
| `redirectUri` | Callback URI; server-relative paths (starting with `/`) are resolved at request time |
|
||||
|
||||
### Builder options
|
||||
|
||||
| Method | Default | Description |
|
||||
|---|---|---|
|
||||
| `.scopes("openid profile email")` | `"openid profile email"` | Space-separated requested scopes |
|
||||
| `.routePrefix("/auth")` | `"/auth"` | Prefix for login/callback/logout routes |
|
||||
| `.selfScheme("http")` | `"http"` | Scheme used when resolving server-relative redirect URIs |
|
||||
| `.https()` | — | Shorthand for `.selfScheme("https")` |
|
||||
| `.rolesClaimPath("realm_access.roles")` | `"realm_access.roles"` | Dot-path to the roles array in JWT claims |
|
||||
| `.scopeClaimPaths("scope,scp")` | `"scope,scp"` | Comma-separated claim paths used to resolve OAuth scopes |
|
||||
| `.algorithm("RS256")` | `"RS256"` | JWS algorithm for token validation |
|
||||
| `.postLogoutRedirectUri("/")` | `"/"` | Where to redirect after logout |
|
||||
| `.sessionStore(store)` | `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_SCOPE_CLAIMS default: scope,scp
|
||||
OIDC_ALGORITHM default: RS256
|
||||
OIDC_POST_LOGOUT_REDIRECT default: /
|
||||
OIDC_CLIENT_AUTH_METHOD default: POST
|
||||
```
|
||||
|
||||
## Protecting routes
|
||||
|
||||
### Class-based handlers (annotations)
|
||||
|
||||
```java
|
||||
@Route(method = HttpMethod.GET, path = "/me")
|
||||
@Authenticated
|
||||
public class MePage extends JacksonHandler {
|
||||
@Override
|
||||
public Object handle(Request req, Response res) {
|
||||
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 { ... }
|
||||
|
||||
@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 `OidcMiddleware`
|
||||
from the context inside another extension's `routes()` phase, or after `start()`:
|
||||
|
||||
```java
|
||||
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());
|
||||
}, oidc.protect());
|
||||
|
||||
// Authentication + role check
|
||||
app.delete("/api/admin/users/{id}", (req, res) -> {
|
||||
OidcUser u = ClaimsHolder.user();
|
||||
// ...
|
||||
}, oidc.requireRole("admin"));
|
||||
|
||||
// Multiple roles (OR): passes if user holds any one of them
|
||||
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(...)` / `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
|
||||
|
||||
`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)
|
||||
|
||||
```java
|
||||
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");
|
||||
|
||||
// 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)
|
||||
|
||||
```java
|
||||
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_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 `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.
|
||||
For clustered deployments, implement `OidcSessionStore`:
|
||||
|
||||
```java
|
||||
public interface OidcSessionStore {
|
||||
void save(OidcSession session);
|
||||
Optional<OidcSession> find(String sessionId);
|
||||
void delete(String sessionId);
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
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):
|
||||
|
||||
```html
|
||||
<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):
|
||||
|
||||
```java
|
||||
OidcConfig tenantA = OidcConfig.builder("https://idp/realms/a", "clientA", "secretA", "/a/auth/callback")
|
||||
.routePrefix("/a/auth").schemeName("tenantA").build();
|
||||
|
||||
OidcConfig tenantB = OidcConfig.builder("https://idp/realms/b", "clientB", "secretB", "/b/auth/callback")
|
||||
.routePrefix("/b/auth").schemeName("tenantB").build();
|
||||
|
||||
app.install(new OidcExtension(tenantA))
|
||||
.install(new OidcExtension(tenantB));
|
||||
```
|
||||
|
||||
To reference a specific tenant's middleware on lambda routes, keep the extension instances
|
||||
and retrieve `OidcMiddleware` from context after `start()`:
|
||||
|
||||
```java
|
||||
OidcExtension extA = new OidcExtension(tenantA);
|
||||
OidcExtension extB = new OidcExtension(tenantB);
|
||||
|
||||
FlashApp app = FlashApp.create(8080)
|
||||
.install(extA)
|
||||
.install(extB)
|
||||
.start()
|
||||
.join(); // wait for bind
|
||||
|
||||
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 processor's middleware. For true multi-tenant class-based routing, install
|
||||
tenant-specific annotation processors with different annotations.
|
||||
|
||||
## OpenAPI integration
|
||||
|
||||
If `flash-ext-openapi` is on the classpath and installed (order irrelevant),
|
||||
the extension automatically:
|
||||
|
||||
- Adds a `components.securitySchemes` entry for the provider (OAuth2, authorizationCode flow)
|
||||
- Adds `security` requirements to every operation whose handler carries `@Authenticated`
|
||||
, `@RolesAllowed`, or `@ScopesAllowed`
|
||||
|
||||
No extra code needed. To customize the scheme name:
|
||||
|
||||
```java
|
||||
OidcConfig.builder(...).schemeName("keycloak").build()
|
||||
```
|
||||
|
||||
If `flash-ext-openapi` is absent the integration is silently skipped.
|
||||
Reference in New Issue
Block a user