pre-major refactoring + ext api.
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
# 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
|
||||
|
||||
```xml
|
||||
<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
|
||||
|
||||
```java
|
||||
FlashApp.of(new HttpServer(config))
|
||||
.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
|
||||
|
||||
```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 |
|
||||
| `.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)
|
||||
|
||||
```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 { ... }
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
```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"));
|
||||
```
|
||||
|
||||
`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)
|
||||
|
||||
```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");
|
||||
|
||||
// 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_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`:
|
||||
|
||||
```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 reference
|
||||
and retrieve `OidcMiddleware` from context **after** each install:
|
||||
|
||||
```java
|
||||
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) -> { ... }, mwA.protect());
|
||||
app.get("/b/dashboard", (req, res) -> { ... }, 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:
|
||||
|
||||
```java
|
||||
OidcConfig.builder(...).schemeName("keycloak").build()
|
||||
```
|
||||
|
||||
If `flash-ext-openapi` is absent the integration is silently skipped.
|
||||
Reference in New Issue
Block a user