pre-major refactoring + ext api.
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
# flash-ext-jackson
|
||||
|
||||
Jackson JSON integration for the Flash HTTP server.
|
||||
|
||||
## What it provides
|
||||
|
||||
| Component | Description |
|
||||
|---|---|
|
||||
| `JacksonExtension` | Installs Jackson into the extension layer |
|
||||
| `JacksonHandler` | Base class for handlers that need JSON I/O |
|
||||
| Global exception handler | Maps `HttpException` → JSON error; all other exceptions → 500 |
|
||||
|
||||
## Installation
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-jackson</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
Install before any extension that needs JSON (e.g. `flash-ext-openapi`, `flash-ext-oidc`):
|
||||
|
||||
```java
|
||||
FlashApp.of(new HttpServer(config))
|
||||
.install(new JacksonExtension())
|
||||
// ... other extensions
|
||||
```
|
||||
|
||||
### Custom mapper
|
||||
|
||||
```java
|
||||
ObjectMapper mapper = JsonMapper.builder()
|
||||
.addModule(new JavaTimeModule())
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
.build();
|
||||
|
||||
FlashApp.of(new HttpServer(config))
|
||||
.install(new JacksonExtension(mapper));
|
||||
```
|
||||
|
||||
## JacksonHandler
|
||||
|
||||
Extend `JacksonHandler` to get `bodyAs` and `json` helpers without constructor boilerplate.
|
||||
The `ObjectMapper` is injected once at startup and shared across all subclasses.
|
||||
|
||||
```java
|
||||
@Route(method = HttpMethod.POST, path = "/api/blogs")
|
||||
@ApiOperation(summary = "Create blog")
|
||||
@ApiResponse(status = 201, description = "Created", schema = Blog.class)
|
||||
@ApiResponse(status = 400, description = "Invalid body")
|
||||
public class CreateBlog extends JacksonHandler {
|
||||
|
||||
@Override
|
||||
public Object handle(Request req, Response res) throws Exception {
|
||||
CreateBlogRequest body = bodyAs(req, CreateBlogRequest.class);
|
||||
Blog created = service.create(body);
|
||||
res.setStatusCode(201);
|
||||
return json(res, created); // sets Content-Type: application/json
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Methods
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `bodyAs(req, Type.class)` | Deserializes the request body; throws `HttpException` 400 on parse errors |
|
||||
| `json(res, obj)` | Serializes `obj`, sets `Content-Type: application/json`, returns the JSON string |
|
||||
| `jsonView(res, obj, View.class)` | Like `json` but applies a Jackson `@JsonView` filter |
|
||||
|
||||
## Exception handling
|
||||
|
||||
`JacksonExtension` installs a global exception handler that applies to every route:
|
||||
|
||||
```
|
||||
HttpException(status, message) → HTTP <status> {"error": "<message>"}
|
||||
Any other Throwable → HTTP 500 {"error": "Internal Server Error"}
|
||||
```
|
||||
|
||||
To throw a handled HTTP error from any handler:
|
||||
|
||||
```java
|
||||
throw HttpException.notFound("Blog not found");
|
||||
throw HttpException.badRequest("Missing field: title");
|
||||
throw HttpException.unauthorized();
|
||||
throw HttpException.forbidden();
|
||||
```
|
||||
|
||||
## Lambda routes
|
||||
|
||||
For lambda-style routes, use the `ObjectMapper` directly from the context:
|
||||
|
||||
```java
|
||||
ObjectMapper mapper = app.ctx().require(ObjectMapper.class);
|
||||
|
||||
app.get("/api/status", (req, res) -> {
|
||||
res.setContentType(ContentType.JSON);
|
||||
return mapper.writeValueAsString(Map.of("status", "ok"));
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-jackson</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package dev.relism.ext.jackson;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||
import dev.relism.exceptions.HttpException;
|
||||
import dev.relism.extension.ExtensionContext;
|
||||
import dev.relism.extension.FlashApp;
|
||||
import dev.relism.extension.FlashExtension;
|
||||
import dev.relism.http.ContentType;
|
||||
|
||||
/**
|
||||
* Registers Jackson into the extension layer.
|
||||
*
|
||||
* <p>What this installs:
|
||||
* <ul>
|
||||
* <li>Exposes the {@link ObjectMapper} in {@link ExtensionContext} — consumed by
|
||||
* {@code flash-ext-openapi} and any extension that needs JSON serialization.</li>
|
||||
* <li>Sets a global exception handler that maps {@link HttpException} to a JSON
|
||||
* error body and catches all other exceptions as 500.</li>
|
||||
* <li>Injects the mapper into {@link JacksonHandler} so all subclasses gain
|
||||
* {@code bodyAs} and {@code json} without constructor boilerplate.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <pre>{@code
|
||||
* FlashApp.of(new HttpServer(config))
|
||||
* .install(new JacksonExtension());
|
||||
*
|
||||
* // Custom mapper:
|
||||
* ObjectMapper mapper = JsonMapper.builder()
|
||||
* .addModule(new JavaTimeModule())
|
||||
* .build();
|
||||
* .install(new JacksonExtension(mapper));
|
||||
* }</pre>
|
||||
*/
|
||||
public class JacksonExtension implements FlashExtension {
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
public JacksonExtension() {
|
||||
this(JsonMapper.builder().build());
|
||||
}
|
||||
|
||||
public JacksonExtension(ObjectMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void install(FlashApp app, ExtensionContext ctx) {
|
||||
ctx.provide(ObjectMapper.class, mapper);
|
||||
JacksonHandler.mapper = mapper;
|
||||
|
||||
app.onException((ex, req, res) -> {
|
||||
if (ex instanceof HttpException e) {
|
||||
res.setStatusCode(e.status());
|
||||
res.setContentType(ContentType.JSON);
|
||||
return "{\"error\":\"" + escapeJson(e.getMessage()) + "\"}";
|
||||
}
|
||||
res.setStatusCode(500);
|
||||
res.setContentType(ContentType.JSON);
|
||||
return "{\"error\":\"Internal Server Error\"}";
|
||||
});
|
||||
}
|
||||
|
||||
private static String escapeJson(String s) {
|
||||
if (s == null) return "";
|
||||
return s.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t");
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package dev.relism.ext.jackson;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.relism.exceptions.HttpException;
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.models.Request;
|
||||
import dev.relism.models.RequestHandler;
|
||||
import dev.relism.models.Response;
|
||||
|
||||
/**
|
||||
* Base class for handlers that need JSON I/O via Jackson.
|
||||
*
|
||||
* <p>The {@link ObjectMapper} is injected by {@link JacksonExtension#install} once at
|
||||
* startup — all subclasses share the same instance. If no {@code JacksonExtension} is
|
||||
* installed the field remains {@code null} and the first call to {@link #bodyAs} or
|
||||
* {@link #json} will throw an {@link IllegalStateException}.
|
||||
*
|
||||
* <pre>{@code
|
||||
* @Route(method = HttpMethod.POST, path = "/api/blogs")
|
||||
* public class CreateBlog extends JacksonHandler {
|
||||
* public Object handle(Request req, Response res) throws Exception {
|
||||
* CreateBlogRequest body = bodyAs(req, CreateBlogRequest.class);
|
||||
* Blog created = service.create(body);
|
||||
* res.setStatusCode(201);
|
||||
* return json(res, created);
|
||||
* }
|
||||
* }
|
||||
* }</pre>
|
||||
*/
|
||||
public abstract class JacksonHandler extends RequestHandler {
|
||||
|
||||
/**
|
||||
* Shared mapper set by {@link JacksonExtension}. Package-visible so the extension
|
||||
* can assign it; {@code volatile} ensures visibility across virtual threads.
|
||||
*/
|
||||
static volatile ObjectMapper mapper;
|
||||
|
||||
/**
|
||||
* Deserializes the request body bytes into {@code type}.
|
||||
* Wraps Jackson parse errors as {@link HttpException} 400.
|
||||
*/
|
||||
protected <T> T bodyAs(Request req, Class<T> type) throws Exception {
|
||||
requireMapper();
|
||||
try {
|
||||
return mapper.readValue(req.body().bytes(), type);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw HttpException.badRequest("Invalid request body: " + e.getOriginalMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes {@code obj} to JSON, sets {@code Content-Type: application/json},
|
||||
* and returns the JSON string as the response body.
|
||||
*/
|
||||
protected String json(Response res, Object obj) throws Exception {
|
||||
requireMapper();
|
||||
res.setContentType(ContentType.JSON);
|
||||
return mapper.writeValueAsString(obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link #json} but serializes only fields visible under the given
|
||||
* {@code view} class (see Jackson {@code @JsonView}).
|
||||
*/
|
||||
protected String jsonView(Response res, Object obj, Class<?> view) throws Exception {
|
||||
requireMapper();
|
||||
res.setContentType(ContentType.JSON);
|
||||
return mapper.writerWithView(view).writeValueAsString(obj);
|
||||
}
|
||||
|
||||
private static void requireMapper() {
|
||||
if (mapper == null)
|
||||
throw new IllegalStateException(
|
||||
"JacksonExtension not installed: call FlashApp.install(new JacksonExtension()) first");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-oidc</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-openapi</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.nimbusds</groupId>
|
||||
<artifactId>nimbus-jose-jwt</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>net.minidev</groupId>
|
||||
<artifactId>json-smart</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,23 @@
|
||||
package dev.relism.ext.oidc;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* Marks a handler as requiring a valid JWT. Any bearer token that passes
|
||||
* signature + expiry + issuer validation is accepted — no role check is performed.
|
||||
*
|
||||
* <p>For role-based access use {@link RolesAllowed} instead (it implies authentication).
|
||||
*
|
||||
* <pre>{@code
|
||||
* @Route(method = HttpMethod.GET, path = "/api/profile")
|
||||
* @Authenticated
|
||||
* public class GetProfile extends JacksonHandler { ... }
|
||||
* }</pre>
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public @interface Authenticated {
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package dev.relism.ext.oidc;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Thread-local store for JWT claims, populated by the OIDC middleware before
|
||||
* the handler runs and cleared in the {@code finally} block afterward.
|
||||
*
|
||||
* <p>Safe with virtual threads: each request gets its own virtual thread, so
|
||||
* {@link ThreadLocal} values are naturally isolated per request.
|
||||
*
|
||||
* <pre>{@code
|
||||
* // Inside any handler protected by @Authenticated or @RolesAllowed:
|
||||
*
|
||||
* // Preferred — typed wrapper:
|
||||
* OidcUser user = ClaimsHolder.user();
|
||||
* String email = user.email();
|
||||
* List<String> roles = user.roles("realm_access.roles");
|
||||
*
|
||||
* // Raw escape hatch:
|
||||
* Map<String, Object> all = ClaimsHolder.get();
|
||||
* }</pre>
|
||||
*/
|
||||
public final class ClaimsHolder {
|
||||
|
||||
private static final ThreadLocal<Map<String, Object>> HOLDER = new ThreadLocal<>();
|
||||
|
||||
private ClaimsHolder() {}
|
||||
|
||||
/** Called by the OIDC middleware after successful token validation. */
|
||||
static void set(Map<String, Object> claims) {
|
||||
HOLDER.set(claims);
|
||||
}
|
||||
|
||||
/** Called by the OIDC middleware in the {@code finally} block. */
|
||||
static void clear() {
|
||||
HOLDER.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a type-safe {@link OidcUser} view of the current request's claims,
|
||||
* or {@code null} if the route is not protected by OIDC middleware.
|
||||
*
|
||||
* <p>This is the preferred entry point for both lambda and class-based handlers.
|
||||
*/
|
||||
public static OidcUser user() {
|
||||
Map<String, Object> claims = HOLDER.get();
|
||||
return claims != null ? new OidcUser(claims) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw claims map for the current request, or {@code null} if
|
||||
* the route is not protected by OIDC middleware.
|
||||
*
|
||||
* @see #user() for the preferred type-safe accessor
|
||||
*/
|
||||
public static Map<String, Object> get() {
|
||||
return HOLDER.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value of a single claim as a String, or {@code null} if
|
||||
* the claim is absent or the request is not authenticated.
|
||||
*/
|
||||
public static String claim(String key) {
|
||||
Map<String, Object> claims = HOLDER.get();
|
||||
if (claims == null) return null;
|
||||
Object v = claims.get(key);
|
||||
return v != null ? v.toString() : null;
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package dev.relism.ext.oidc;
|
||||
|
||||
/**
|
||||
* OAuth2 client authentication method for the token endpoint (RFC 6749 §2.3).
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link #POST} — credentials sent as {@code client_id} / {@code client_secret}
|
||||
* form fields (default; most providers).</li>
|
||||
* <li>{@link #BASIC} — credentials sent as an {@code Authorization: Basic} header;
|
||||
* body contains only grant-specific parameters.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public enum ClientAuthMethod {
|
||||
/** {@code client_secret_post} — credentials in the request body. */
|
||||
POST,
|
||||
/** {@code client_secret_basic} — credentials in the {@code Authorization} header. */
|
||||
BASIC
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package dev.relism.ext.oidc;
|
||||
|
||||
import net.minidev.json.JSONValue;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Fetches and parses the OIDC provider discovery document at
|
||||
* {@code {issuer}/.well-known/openid-configuration}.
|
||||
*/
|
||||
final class DiscoveryClient {
|
||||
|
||||
private DiscoveryClient() {}
|
||||
|
||||
static OidcProviderMetadata fetch(String issuer, HttpClient http) throws Exception {
|
||||
String url = issuer.endsWith("/")
|
||||
? issuer + ".well-known/openid-configuration"
|
||||
: issuer + "/.well-known/openid-configuration";
|
||||
|
||||
HttpResponse<String> resp = http.send(
|
||||
HttpRequest.newBuilder().uri(URI.create(url)).GET().build(),
|
||||
HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (resp.statusCode() != 200)
|
||||
throw new IllegalStateException(
|
||||
"OIDC discovery failed [" + resp.statusCode() + "]: " + url);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> doc = (Map<String, Object>) JSONValue.parse(resp.body());
|
||||
|
||||
return new OidcProviderMetadata(
|
||||
require(doc, "authorization_endpoint"),
|
||||
require(doc, "token_endpoint"),
|
||||
(String) doc.get("userinfo_endpoint"), // optional
|
||||
require(doc, "jwks_uri"),
|
||||
(String) doc.get("end_session_endpoint") // optional
|
||||
);
|
||||
}
|
||||
|
||||
private static String require(Map<String, Object> doc, String key) {
|
||||
Object v = doc.get(key);
|
||||
if (v == null) throw new IllegalStateException(
|
||||
"Discovery doc missing required field: " + key);
|
||||
return v.toString();
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package dev.relism.ext.oidc;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Thread-safe in-memory {@link OidcSessionStore}.
|
||||
*
|
||||
* <p>Sessions are lost on restart and not shared across instances. For
|
||||
* production deployments with multiple nodes or restart-persistence requirements,
|
||||
* supply a custom implementation via {@link OidcConfig.Builder#sessionStore}.
|
||||
*/
|
||||
public final class InMemoryOidcSessionStore implements OidcSessionStore {
|
||||
|
||||
private final ConcurrentHashMap<String, OidcSession> store = new ConcurrentHashMap<>();
|
||||
|
||||
@Override public void save(OidcSession s) { store.put(s.id(), s); }
|
||||
@Override public Optional<OidcSession> find(String id) { return Optional.ofNullable(store.get(id)); }
|
||||
@Override public void delete(String id) { store.remove(id); }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package dev.relism.ext.oidc;
|
||||
|
||||
import net.minidev.json.JSONValue;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Low-level JWT payload extraction — no signature or expiry validation.
|
||||
*
|
||||
* <p>Use only for tokens received directly from the provider over a trusted TLS
|
||||
* connection (e.g. {@code id_token} from the token endpoint). Bearer tokens on
|
||||
* incoming requests must go through {@link JwtValidator#validate(String)} instead.
|
||||
*/
|
||||
final class JwtUtils {
|
||||
|
||||
private JwtUtils() {}
|
||||
|
||||
/**
|
||||
* Base64URL-decodes the JWT payload and returns the claims as a map.
|
||||
* Signature, expiry, and issuer are NOT checked.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
static Map<String, Object> parseClaims(String jwt) {
|
||||
String[] parts = jwt.split("\\.");
|
||||
if (parts.length < 2) throw new IllegalArgumentException("Malformed JWT: " + jwt);
|
||||
// Pad to a multiple of 4 for the standard decoder
|
||||
String padded = parts[1];
|
||||
switch (padded.length() % 4) {
|
||||
case 2 -> padded += "==";
|
||||
case 3 -> padded += "=";
|
||||
}
|
||||
byte[] payload = Base64.getUrlDecoder().decode(padded);
|
||||
return (Map<String, Object>) JSONValue.parse(
|
||||
new String(payload, StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package dev.relism.ext.oidc;
|
||||
|
||||
import com.nimbusds.jose.JWSAlgorithm;
|
||||
import com.nimbusds.jose.jwk.source.JWKSource;
|
||||
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
|
||||
import com.nimbusds.jose.proc.JWSKeySelector;
|
||||
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
|
||||
import com.nimbusds.jose.proc.SecurityContext;
|
||||
import com.nimbusds.jose.util.Resource;
|
||||
import com.nimbusds.jose.util.ResourceRetriever;
|
||||
import com.nimbusds.jwt.JWTClaimsSet;
|
||||
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
|
||||
import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier;
|
||||
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
|
||||
import dev.relism.exceptions.HttpException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URL;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Validates JWTs against a remote JWKS endpoint using Nimbus JOSE+JWT.
|
||||
*
|
||||
* <p>Two validation modes:
|
||||
* <ul>
|
||||
* <li>{@link #validate(String)} — access token bearer validation per request (hot path).
|
||||
* Checks signature, {@code iss}, {@code exp}, {@code iat}, {@code sub}.
|
||||
* Throws {@link HttpException} 401 so the middleware can short-circuit.</li>
|
||||
* <li>{@link #validateIdToken(String, String)} — ID token validation at callback time.
|
||||
* Checks signature, {@code iss}, {@code aud} == clientId, {@code exp}, {@code iat},
|
||||
* {@code sub}, and {@code nonce} (if provided).
|
||||
* Throws {@link OidcValidationException} (not 401 — it is a provider/protocol error).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>JWKS handling: the shared {@link JWKSource} uses caching + rate-limiting + automatic
|
||||
* retry-on-key-miss (key rotation). Both processors share the same source — one JWKS
|
||||
* fetch serves both token types.
|
||||
*/
|
||||
public class JwtValidator {
|
||||
|
||||
private final JWKSource<SecurityContext> jwkSource;
|
||||
private final ConfigurableJWTProcessor<SecurityContext> accessTokenProcessor;
|
||||
private final ConfigurableJWTProcessor<SecurityContext> idTokenProcessor;
|
||||
private final String algorithm;
|
||||
|
||||
/**
|
||||
* @param jwksUri JWKS endpoint URI
|
||||
* @param issuer Expected {@code iss} claim
|
||||
* @param clientId OAuth2 client ID — used as expected {@code aud} in ID tokens
|
||||
* @param algorithm JWS algorithm (e.g. {@code "RS256"})
|
||||
* @param http Shared {@link HttpClient} used for all JWKS fetches — already configured
|
||||
* with the correct TLS policy (trust-all or default trust store).
|
||||
*/
|
||||
public JwtValidator(String jwksUri, String issuer, String clientId,
|
||||
String algorithm, HttpClient http) {
|
||||
try {
|
||||
// Use the caller-supplied HttpClient for JWKS retrieval so that TLS policy
|
||||
// (insecureTls / custom trust store) is applied consistently everywhere.
|
||||
this.jwkSource = JWKSourceBuilder
|
||||
.create(new URL(jwksUri), httpRetriever(http))
|
||||
.cache(true)
|
||||
.rateLimited(true)
|
||||
.retrying(true)
|
||||
.build();
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to init JWKS source: " + jwksUri, e);
|
||||
}
|
||||
this.algorithm = algorithm;
|
||||
this.accessTokenProcessor = buildAccessTokenProcessor(jwkSource, issuer, algorithm);
|
||||
this.idTokenProcessor = buildIdTokenProcessor(jwkSource, issuer, clientId, algorithm);
|
||||
}
|
||||
|
||||
// -- Public API -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Validates a JWT access token (bearer on incoming request).
|
||||
* Returns claims on success; throws {@link HttpException} 401 on any failure.
|
||||
*/
|
||||
public Map<String, Object> validate(String token) {
|
||||
if (!isJwt(token)) throw HttpException.unauthorized(); // opaque token — can't validate
|
||||
try {
|
||||
return accessTokenProcessor.process(token, null).getClaims();
|
||||
} catch (Exception e) {
|
||||
throw HttpException.unauthorized();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an ID token received directly from the token endpoint.
|
||||
*
|
||||
* <p>Checks: signature (JWKS), {@code iss}, {@code aud} == clientId,
|
||||
* {@code exp}, {@code iat}, {@code sub}, and {@code nonce} if provided.
|
||||
*
|
||||
* @param idToken Raw ID token string
|
||||
* @param nonce Nonce sent in the authorization request; {@code null} to skip check
|
||||
* @throws OidcValidationException on any validation failure
|
||||
*/
|
||||
public Map<String, Object> validateIdToken(String idToken, String nonce) {
|
||||
try {
|
||||
Map<String, Object> claims = idTokenProcessor.process(idToken, null).getClaims();
|
||||
if (nonce != null && !nonce.equals(claims.get("nonce")))
|
||||
throw new OidcValidationException("ID token nonce mismatch", null);
|
||||
return claims;
|
||||
} catch (OidcValidationException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new OidcValidationException("ID token validation failed: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns {@code true} if {@code token} is a signed JWT (three dot-separated Base64URL parts).
|
||||
* Used to detect opaque access tokens before attempting JWKS validation.
|
||||
*/
|
||||
public static boolean isJwt(String token) {
|
||||
if (token == null || token.isBlank()) return false;
|
||||
int dots = 0;
|
||||
for (int i = 0; i < token.length(); i++) if (token.charAt(i) == '.') dots++;
|
||||
return dots == 2;
|
||||
}
|
||||
|
||||
// -- Processors -----------------------------------------------------------
|
||||
|
||||
private static ConfigurableJWTProcessor<SecurityContext> buildAccessTokenProcessor(
|
||||
JWKSource<SecurityContext> src, String issuer, String algorithm) {
|
||||
|
||||
ConfigurableJWTProcessor<SecurityContext> p = new DefaultJWTProcessor<>();
|
||||
p.setJWSKeySelector(keySelector(src, algorithm));
|
||||
// iss required; aud not enforced on ATs (varies by provider)
|
||||
if (issuer != null && !issuer.isBlank()) {
|
||||
p.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(
|
||||
new JWTClaimsSet.Builder().issuer(issuer).build(),
|
||||
Set.of("sub", "iat", "exp")));
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
private static ConfigurableJWTProcessor<SecurityContext> buildIdTokenProcessor(
|
||||
JWKSource<SecurityContext> src, String issuer, String clientId, String algorithm) {
|
||||
|
||||
ConfigurableJWTProcessor<SecurityContext> p = new DefaultJWTProcessor<>();
|
||||
p.setJWSKeySelector(keySelector(src, algorithm));
|
||||
// iss + aud = clientId strictly required (OIDC Core §3.1.3.7)
|
||||
JWTClaimsSet.Builder required = new JWTClaimsSet.Builder();
|
||||
if (issuer != null) required.issuer(issuer);
|
||||
if (clientId != null) required.audience(clientId);
|
||||
p.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(
|
||||
required.build(), Set.of("sub", "iat", "exp")));
|
||||
return p;
|
||||
}
|
||||
|
||||
private static JWSKeySelector<SecurityContext> keySelector(
|
||||
JWKSource<SecurityContext> src, String algorithm) {
|
||||
return new JWSVerificationKeySelector<>(JWSAlgorithm.parse(algorithm), src);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a {@link HttpClient} as a Nimbus {@link ResourceRetriever}.
|
||||
* The client already carries the correct TLS policy (trust-all or default),
|
||||
* so JWKS fetches honour the same SSL configuration as discovery and token requests.
|
||||
*/
|
||||
private static ResourceRetriever httpRetriever(HttpClient http) {
|
||||
return url -> {
|
||||
try {
|
||||
HttpResponse<String> resp = http.send(
|
||||
HttpRequest.newBuilder().uri(url.toURI()).GET().build(),
|
||||
HttpResponse.BodyHandlers.ofString());
|
||||
if (resp.statusCode() != 200)
|
||||
throw new IOException("JWKS fetch failed [" + resp.statusCode() + "]: " + url);
|
||||
String contentType = resp.headers()
|
||||
.firstValue("Content-Type").orElse("application/json");
|
||||
return new Resource(resp.body(), contentType);
|
||||
} catch (IOException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new IOException("JWKS retrieval error: " + e.getMessage(), e);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package dev.relism.ext.oidc;
|
||||
|
||||
/**
|
||||
* Full OIDC client configuration. Build via
|
||||
* {@link #builder(String, String, String, String)} or {@link #fromEnv()}.
|
||||
*
|
||||
* <p>Required fields: {@code issuer}, {@code clientId}, {@code clientSecret},
|
||||
* {@code redirectUri}. Everything else has a sensible default.
|
||||
*
|
||||
* <p>If {@code redirectUri} starts with {@code /} it is treated as server-relative:
|
||||
* the absolute URL is resolved at request time using {@link #selfScheme()} and the
|
||||
* incoming {@code Host} header. Use {@link Builder#https()} when behind TLS.
|
||||
*
|
||||
* <pre>{@code
|
||||
* // Keycloak
|
||||
* OidcConfig.builder(
|
||||
* "https://keycloak.example.com/realms/myrealm",
|
||||
* "my-app", "secret", "/auth/callback")
|
||||
* .rolesClaimPath("realm_access.roles") // Keycloak default
|
||||
* .build();
|
||||
*
|
||||
* // Authelia
|
||||
* OidcConfig.builder(
|
||||
* "https://auth.example.com",
|
||||
* "my-app", "secret", "/auth/callback")
|
||||
* .rolesClaimPath("groups")
|
||||
* .build();
|
||||
*
|
||||
* // Two tenants on one server
|
||||
* OidcConfig tenantA = OidcConfig.builder("https://idp/realms/a", ..., "/tenantA/auth/callback")
|
||||
* .routePrefix("/tenantA/auth").build();
|
||||
* OidcConfig tenantB = OidcConfig.builder("https://idp/realms/b", ..., "/tenantB/auth/callback")
|
||||
* .routePrefix("/tenantB/auth").build();
|
||||
* app.install(new OidcExtension(tenantA))
|
||||
* .install(new OidcExtension(tenantB));
|
||||
* }</pre>
|
||||
*/
|
||||
public final class OidcConfig {
|
||||
|
||||
private final String issuer;
|
||||
private final String clientId;
|
||||
private final String clientSecret;
|
||||
private final String redirectUri;
|
||||
private final String scopes;
|
||||
private final String routePrefix;
|
||||
private final String selfScheme;
|
||||
private final String rolesClaimPath;
|
||||
private final String algorithm;
|
||||
private final String postLogoutRedirectUri;
|
||||
private final OidcSessionStore sessionStore;
|
||||
private final boolean insecureTls;
|
||||
private final ClientAuthMethod clientAuthMethod;
|
||||
private final String schemeName;
|
||||
|
||||
private OidcConfig(Builder b) {
|
||||
this.issuer = require(b.issuer, "issuer");
|
||||
this.clientId = require(b.clientId, "clientId");
|
||||
this.clientSecret = require(b.clientSecret, "clientSecret");
|
||||
this.redirectUri = require(b.redirectUri, "redirectUri");
|
||||
this.scopes = b.scopes;
|
||||
this.routePrefix = b.routePrefix;
|
||||
this.selfScheme = b.selfScheme;
|
||||
this.rolesClaimPath = b.rolesClaimPath;
|
||||
this.algorithm = b.algorithm;
|
||||
this.postLogoutRedirectUri = b.postLogoutRedirectUri;
|
||||
this.sessionStore = b.sessionStore != null ? b.sessionStore
|
||||
: new InMemoryOidcSessionStore();
|
||||
this.insecureTls = b.insecureTls;
|
||||
this.clientAuthMethod = b.clientAuthMethod;
|
||||
this.schemeName = b.schemeName != null ? b.schemeName : deriveScheme(this.issuer);
|
||||
}
|
||||
|
||||
// -- Getters --------------------------------------------------------------
|
||||
|
||||
public String issuer() { return issuer; }
|
||||
public String clientId() { return clientId; }
|
||||
public String clientSecret() { return clientSecret; }
|
||||
public String redirectUri() { return redirectUri; }
|
||||
public String scopes() { return scopes; }
|
||||
public String routePrefix() { return routePrefix; }
|
||||
public String selfScheme() { return selfScheme; }
|
||||
public String rolesClaimPath() { return rolesClaimPath; }
|
||||
public String algorithm() { return algorithm; }
|
||||
public String postLogoutRedirectUri() { return postLogoutRedirectUri; }
|
||||
public OidcSessionStore sessionStore() { return sessionStore; }
|
||||
/** If {@code true}, TLS certificate validation is skipped. <b>Never use in production.</b> */
|
||||
public boolean insecureTls() { return insecureTls; }
|
||||
public ClientAuthMethod clientAuthMethod() { return clientAuthMethod; }
|
||||
/** OpenAPI security scheme name (derived from issuer if not set explicitly). */
|
||||
public String schemeName() { return schemeName; }
|
||||
|
||||
// -- Factory --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Reads configuration from environment variables:
|
||||
* <pre>
|
||||
* 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: /
|
||||
* </pre>
|
||||
*/
|
||||
public static OidcConfig fromEnv() {
|
||||
return builder(env("OIDC_ISSUER"), env("OIDC_CLIENT_ID"),
|
||||
env("OIDC_CLIENT_SECRET"), env("OIDC_REDIRECT_URI"))
|
||||
.scopes (envOr("OIDC_SCOPES", "openid profile email"))
|
||||
.routePrefix (envOr("OIDC_ROUTE_PREFIX", "/auth"))
|
||||
.selfScheme (envOr("OIDC_SELF_SCHEME", "http"))
|
||||
.rolesClaimPath (envOr("OIDC_ROLES_CLAIM", "realm_access.roles"))
|
||||
.algorithm (envOr("OIDC_ALGORITHM", "RS256"))
|
||||
.postLogoutRedirectUri(envOr("OIDC_POST_LOGOUT_REDIRECT", "/"))
|
||||
.clientAuthMethod(ClientAuthMethod.valueOf(
|
||||
envOr("OIDC_CLIENT_AUTH_METHOD", "POST").toUpperCase()))
|
||||
.build();
|
||||
}
|
||||
|
||||
public static Builder builder(String issuer, String clientId,
|
||||
String clientSecret, String redirectUri) {
|
||||
return new Builder(issuer, clientId, clientSecret, redirectUri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience factory for Keycloak: constructs the issuer as
|
||||
* {@code {serverUrl}/realms/{realm}} automatically.
|
||||
*
|
||||
* <pre>{@code
|
||||
* OidcConfig.keycloak(
|
||||
* "https://keycloak.example.com", "flashboard",
|
||||
* "my-app", "secret", "/auth/callback")
|
||||
* .https()
|
||||
* .build();
|
||||
* }</pre>
|
||||
*/
|
||||
public static Builder keycloak(String serverUrl, String realm,
|
||||
String clientId, String clientSecret,
|
||||
String redirectUri) {
|
||||
String base = serverUrl.endsWith("/") ? serverUrl.substring(0, serverUrl.length() - 1) : serverUrl;
|
||||
String issuer = base + "/realms/" + realm;
|
||||
return new Builder(issuer, clientId, clientSecret, redirectUri)
|
||||
.rolesClaimPath("realm_access.roles"); // Keycloak default
|
||||
}
|
||||
|
||||
// -- Helpers --------------------------------------------------------------
|
||||
|
||||
private static String require(String v, String name) {
|
||||
if (v == null || v.isBlank())
|
||||
throw new IllegalArgumentException("OidcConfig: " + name + " is required");
|
||||
return v;
|
||||
}
|
||||
|
||||
private static String env(String key) {
|
||||
String v = System.getenv(key);
|
||||
if (v == null || v.isBlank())
|
||||
throw new IllegalArgumentException("Missing required env var: " + key);
|
||||
return v;
|
||||
}
|
||||
|
||||
private static String envOr(String key, String def) {
|
||||
String v = System.getenv(key);
|
||||
return (v != null && !v.isBlank()) ? v : def;
|
||||
}
|
||||
|
||||
// -- Builder --------------------------------------------------------------
|
||||
|
||||
public static final class Builder {
|
||||
|
||||
private final String issuer;
|
||||
private final String clientId;
|
||||
private final String clientSecret;
|
||||
private final String redirectUri;
|
||||
|
||||
private String scopes = "openid profile email";
|
||||
private String routePrefix = "/auth";
|
||||
private String selfScheme = "http";
|
||||
private String rolesClaimPath = "realm_access.roles";
|
||||
private String algorithm = "RS256";
|
||||
private String postLogoutRedirectUri = "/";
|
||||
private OidcSessionStore sessionStore;
|
||||
private boolean insecureTls = false;
|
||||
private ClientAuthMethod clientAuthMethod = ClientAuthMethod.POST;
|
||||
private String schemeName = null;
|
||||
|
||||
private Builder(String issuer, String clientId, String clientSecret, String redirectUri) {
|
||||
this.issuer = issuer;
|
||||
this.clientId = clientId;
|
||||
this.clientSecret = clientSecret;
|
||||
this.redirectUri = redirectUri;
|
||||
}
|
||||
|
||||
/** Override requested scopes (default: {@code openid profile email}). */
|
||||
public Builder scopes(String scopes) { this.scopes = scopes; return this; }
|
||||
/** Route prefix for login/callback/logout (default: {@code /auth}). */
|
||||
public Builder routePrefix(String prefix) { this.routePrefix = prefix; return this; }
|
||||
/** Scheme used when resolving self-relative redirect URIs (default: {@code http}). */
|
||||
public Builder selfScheme(String scheme) { this.selfScheme = scheme; return this; }
|
||||
/** Shorthand for {@code selfScheme("https")}. */
|
||||
public Builder https() { return selfScheme("https"); }
|
||||
/** Dot-separated path to the roles array in JWT claims (default: {@code realm_access.roles}). */
|
||||
public Builder rolesClaimPath(String path) { this.rolesClaimPath = path; return this; }
|
||||
/** JWS algorithm (default: {@code RS256}). */
|
||||
public Builder algorithm(String algorithm) { this.algorithm = algorithm; return this; }
|
||||
/** Where to redirect after logout (default: {@code /}). */
|
||||
public Builder postLogoutRedirectUri(String uri) { this.postLogoutRedirectUri = uri; return this; }
|
||||
/** Custom session store (default: {@link InMemoryOidcSessionStore}). */
|
||||
public Builder sessionStore(OidcSessionStore store) { this.sessionStore = store; return this; }
|
||||
/**
|
||||
* Disables TLS certificate verification for all HTTP calls made by this extension.
|
||||
* <b>Only use in development with self-signed certificates — never in production.</b>
|
||||
*/
|
||||
public Builder insecureTls() { this.insecureTls = true; return this; }
|
||||
/** Token endpoint client authentication method (default: {@link ClientAuthMethod#POST}). */
|
||||
public Builder clientAuthMethod(ClientAuthMethod method) { this.clientAuthMethod = method; return this; }
|
||||
/** Override the OpenAPI security scheme name (default: derived from the issuer URI). */
|
||||
public Builder schemeName(String name) { this.schemeName = name; return this; }
|
||||
|
||||
public OidcConfig build() { return new OidcConfig(this); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a short, human-readable scheme name from the issuer URI.
|
||||
* Takes the last non-empty path segment; falls back to the host.
|
||||
*
|
||||
* <p>Examples:
|
||||
* <ul>
|
||||
* <li>{@code https://keycloak.dev.home/realms/flashboard} → {@code "flashboard"}</li>
|
||||
* <li>{@code https://auth.example.com} → {@code "auth.example.com"}</li>
|
||||
* </ul>
|
||||
*/
|
||||
private static String deriveScheme(String issuer) {
|
||||
try {
|
||||
java.net.URI uri = new java.net.URI(issuer);
|
||||
String path = uri.getPath();
|
||||
if (path != null && !path.isEmpty()) {
|
||||
String[] parts = path.split("/");
|
||||
for (int i = parts.length - 1; i >= 0; i--) {
|
||||
if (!parts[i].isEmpty()) return parts[i];
|
||||
}
|
||||
}
|
||||
return uri.getHost();
|
||||
} catch (Exception e) {
|
||||
return "oidc";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
package dev.relism.ext.oidc;
|
||||
|
||||
import dev.relism.extension.ExtensionContext;
|
||||
import dev.relism.extension.FlashApp;
|
||||
import dev.relism.extension.FlashExtension;
|
||||
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.TrustManager;
|
||||
import javax.net.ssl.X509TrustManager;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Full OIDC Authorization Code + PKCE flow for Flash.
|
||||
*
|
||||
* <p>On {@link #install}, the extension:
|
||||
* <ol>
|
||||
* <li>Fetches the provider discovery document — fail-fast at startup.</li>
|
||||
* <li>Registers three routes on the {@link FlashApp}:
|
||||
* <ul>
|
||||
* <li>{@code GET {prefix}/login} — builds the authorization URL and redirects.</li>
|
||||
* <li>{@code GET {prefix}/callback} — exchanges the code, creates a session, redirects.</li>
|
||||
* <li>{@code POST {prefix}/logout} — invalidates the session, redirects to the provider's
|
||||
* end-session endpoint (if available) or to {@link OidcConfig#postLogoutRedirectUri()}.</li>
|
||||
* </ul>
|
||||
* </li>
|
||||
* <li>Provides {@link OidcMiddleware} and {@link JwtValidator} in the context.</li>
|
||||
* <li>Registers an annotation processor for {@link Authenticated} and {@link RolesAllowed}.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <pre>{@code
|
||||
* // Keycloak
|
||||
* app.install(new OidcExtension(
|
||||
* OidcConfig.builder(
|
||||
* "https://keycloak.example.com/realms/myrealm",
|
||||
* "my-app", "secret", "/auth/callback")
|
||||
* .rolesClaimPath("realm_access.roles")
|
||||
* .build()));
|
||||
*
|
||||
* // Two providers / tenants on one server
|
||||
* app.install(new OidcExtension(tenantAConfig))
|
||||
* .install(new OidcExtension(tenantBConfig));
|
||||
* }</pre>
|
||||
*/
|
||||
public class OidcExtension implements FlashExtension {
|
||||
|
||||
private final OidcConfig config;
|
||||
|
||||
public OidcExtension(OidcConfig config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void install(FlashApp app, ExtensionContext ctx) {
|
||||
|
||||
// 1. Build the shared HttpClient (optionally with TLS verification disabled)
|
||||
HttpClient http = buildHttpClient(config);
|
||||
|
||||
// 2. Discover provider endpoints (blocking; fail fast at startup)
|
||||
OidcProviderMetadata meta;
|
||||
try {
|
||||
meta = DiscoveryClient.fetch(config.issuer(), http);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(
|
||||
"OIDC discovery failed for issuer: " + config.issuer(), e);
|
||||
}
|
||||
|
||||
// 3. JWKS-backed access-token validator
|
||||
JwtValidator validator = new JwtValidator(
|
||||
meta.jwksUri(), config.issuer(), config.clientId(),
|
||||
config.algorithm(), http);
|
||||
|
||||
// 4. PKCE state store (per extension instance — safe for multi-tenant)
|
||||
OidcStateStore stateStore = new OidcStateStore();
|
||||
|
||||
// 5. Shared token client (injected into middleware for refresh)
|
||||
TokenClient tokenClient = new TokenClient(http, config);
|
||||
|
||||
// 6. Middleware (also exposed in context for manual lambda-route protection)
|
||||
OidcMiddleware oidcMw = new OidcMiddleware(validator, config, meta, tokenClient);
|
||||
ctx.provide(OidcMiddleware.class, oidcMw);
|
||||
ctx.provide(JwtValidator.class, validator);
|
||||
|
||||
String prefix = config.routePrefix();
|
||||
|
||||
// ── GET {prefix}/login ────────────────────────────────────────────────
|
||||
// Builds the provider authorization URL with PKCE + state and redirects.
|
||||
// Optional query param: ?redirect={relative-url} (default: /)
|
||||
app.get(prefix + "/login", (req, res) -> {
|
||||
String verifier = PkceUtils.generateVerifier();
|
||||
String challenge = PkceUtils.computeChallenge(verifier);
|
||||
String state = UUID.randomUUID().toString(); // CSRF protection
|
||||
String nonce = UUID.randomUUID().toString(); // ID token replay protection
|
||||
|
||||
String redirect = req.query("redirect");
|
||||
// Only allow relative paths — prevents open-redirect attacks
|
||||
if (redirect == null || !redirect.startsWith("/")) redirect = "/";
|
||||
|
||||
stateStore.put(state, redirect, verifier, nonce);
|
||||
|
||||
String authUrl = meta.authorizationEndpoint()
|
||||
+ "?response_type=code"
|
||||
+ "&client_id=" + enc(config.clientId())
|
||||
+ "&redirect_uri=" + enc(absoluteRedirectUri(req))
|
||||
+ "&scope=" + enc(config.scopes())
|
||||
+ "&state=" + state
|
||||
+ "&nonce=" + enc(nonce)
|
||||
+ "&code_challenge=" + challenge
|
||||
+ "&code_challenge_method=S256";
|
||||
|
||||
res.status(302).header("Location", authUrl);
|
||||
return null;
|
||||
}).with();
|
||||
|
||||
// ── GET {prefix}/callback ─────────────────────────────────────────────
|
||||
// Validates state, exchanges code for tokens, creates session, redirects.
|
||||
app.get(prefix + "/callback", (req, res) -> {
|
||||
String error = req.query("error");
|
||||
if (error != null) {
|
||||
res.status(400);
|
||||
return "Authentication error: " + error
|
||||
+ (req.query("error_description") != null
|
||||
? " — " + req.query("error_description") : "");
|
||||
}
|
||||
|
||||
String code = req.query("code");
|
||||
String state = req.query("state");
|
||||
|
||||
OidcStateStore.Entry entry = stateStore.consumeAndRemove(state).orElse(null);
|
||||
if (entry == null) {
|
||||
res.status(400);
|
||||
return "Invalid or expired state parameter";
|
||||
}
|
||||
|
||||
OidcTokenResponse tokens = tokenClient.exchangeCode(
|
||||
meta.tokenEndpoint(), code, absoluteRedirectUri(req), entry.codeVerifier());
|
||||
|
||||
// Validate ID token: signature + iss + aud + exp + iat + sub + nonce (OIDC Core §3.1.3.7)
|
||||
if (tokens.idToken() != null) {
|
||||
try {
|
||||
validator.validateIdToken(tokens.idToken(), entry.nonce());
|
||||
} catch (OidcValidationException e) {
|
||||
res.status(400);
|
||||
return "ID token validation failed: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> claims = mergeClaims(tokens);
|
||||
|
||||
OidcSession session = new OidcSession(
|
||||
UUID.randomUUID().toString(),
|
||||
tokens.accessToken(),
|
||||
tokens.idToken(),
|
||||
tokens.refreshToken(),
|
||||
Instant.now().plusSeconds(tokens.expiresIn()),
|
||||
claims
|
||||
);
|
||||
config.sessionStore().save(session);
|
||||
|
||||
res.status(302)
|
||||
.header("Set-Cookie", sessionCookie(session.id()))
|
||||
.header("Location", entry.originalUrl());
|
||||
return null;
|
||||
}).with();
|
||||
|
||||
// ── POST {prefix}/logout ──────────────────────────────────────────────
|
||||
// Invalidates the local session and redirects to the provider's
|
||||
// end_session_endpoint (with id_token_hint) if available.
|
||||
app.post(prefix + "/logout", (req, res) -> {
|
||||
String sessionId = OidcMiddleware.cookieValue(req, "oidc_session");
|
||||
String idTokenHint = null;
|
||||
|
||||
if (sessionId != null) {
|
||||
config.sessionStore().find(sessionId)
|
||||
.ifPresent(s -> {}); // capture id_token before delete
|
||||
OidcSession session = config.sessionStore().find(sessionId).orElse(null);
|
||||
if (session != null) idTokenHint = session.idToken();
|
||||
config.sessionStore().delete(sessionId);
|
||||
}
|
||||
|
||||
String clearCookie = "oidc_session=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax";
|
||||
String location;
|
||||
|
||||
if (meta.endSessionEndpoint() != null) {
|
||||
String postLogout = absoluteSelf(req, config.postLogoutRedirectUri());
|
||||
StringBuilder url = new StringBuilder(meta.endSessionEndpoint())
|
||||
.append("?post_logout_redirect_uri=").append(enc(postLogout));
|
||||
if (idTokenHint != null)
|
||||
url.append("&id_token_hint=").append(enc(idTokenHint));
|
||||
location = url.toString();
|
||||
} else {
|
||||
location = config.postLogoutRedirectUri();
|
||||
}
|
||||
|
||||
res.status(302)
|
||||
.header("Set-Cookie", clearCookie)
|
||||
.header("Location", location);
|
||||
return null;
|
||||
}).with();
|
||||
|
||||
// 5. Annotation processor for @Authenticated / @RolesAllowed
|
||||
ctx.addAnnotationProcessor(handlerClass -> {
|
||||
RolesAllowed roles = handlerClass.getAnnotation(RolesAllowed.class);
|
||||
if (roles != null) return List.of(oidcMw.rolesMiddleware(roles.value()));
|
||||
|
||||
Authenticated auth = handlerClass.getAnnotation(Authenticated.class);
|
||||
if (auth != null) return List.of(oidcMw.authenticatedMiddleware());
|
||||
|
||||
return List.of();
|
||||
});
|
||||
|
||||
// Register OpenAPI security scheme if flash-ext-openapi is on the classpath
|
||||
try {
|
||||
OpenApiIntegration.register(ctx, config, meta);
|
||||
} catch (NoClassDefFoundError ignored) {
|
||||
// flash-ext-openapi not available — OpenAPI integration disabled
|
||||
}
|
||||
}
|
||||
|
||||
// -- Helpers --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Merges claims from both the access token and the ID token.
|
||||
* The access token carries provider-specific data like {@code realm_access.roles};
|
||||
* the ID token carries standard identity claims (sub, email, name, …).
|
||||
* ID token values win on conflict so that verified identity claims are authoritative.
|
||||
*/
|
||||
private static Map<String, Object> mergeClaims(OidcTokenResponse tokens) {
|
||||
Map<String, Object> merged = new HashMap<>();
|
||||
// Access token first — provides roles, resource_access, etc.
|
||||
if (tokens.accessToken() != null) {
|
||||
merged.putAll(JwtUtils.parseClaims(tokens.accessToken()));
|
||||
}
|
||||
// ID token overrides — its identity claims (sub, email, name, …) take priority.
|
||||
if (tokens.idToken() != null) {
|
||||
merged.putAll(JwtUtils.parseClaims(tokens.idToken()));
|
||||
}
|
||||
return Map.copyOf(merged);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an {@link HttpClient}. If {@link OidcConfig#insecureTls()} is set,
|
||||
* installs a trust-all {@link SSLContext} that accepts any certificate.
|
||||
* <b>Only safe for development with self-signed certificates.</b>
|
||||
*/
|
||||
private static HttpClient buildHttpClient(OidcConfig config) {
|
||||
if (!config.insecureTls()) return HttpClient.newHttpClient();
|
||||
try {
|
||||
TrustManager[] trustAll = { new X509TrustManager() {
|
||||
public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
|
||||
public void checkClientTrusted(X509Certificate[] c, String a) {}
|
||||
public void checkServerTrusted(X509Certificate[] c, String a) {}
|
||||
}};
|
||||
SSLContext ctx = SSLContext.getInstance("TLS");
|
||||
ctx.init(null, trustAll, new SecureRandom());
|
||||
return HttpClient.newBuilder().sslContext(ctx).build();
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to create trust-all SSLContext", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the configured {@code redirectUri}. If it starts with {@code /},
|
||||
* prepends {@code selfScheme://Host} from the current request.
|
||||
*/
|
||||
private String absoluteRedirectUri(dev.relism.models.Request req) {
|
||||
return absoluteSelf(req, config.redirectUri());
|
||||
}
|
||||
|
||||
private String absoluteSelf(dev.relism.models.Request req, String uri) {
|
||||
if (!uri.startsWith("/")) return uri;
|
||||
return config.selfScheme() + "://" + req.header("Host") + uri;
|
||||
}
|
||||
|
||||
private static String enc(String v) {
|
||||
return URLEncoder.encode(v, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static String sessionCookie(String id) {
|
||||
return "oidc_session=" + id + "; HttpOnly; Path=/; SameSite=Lax";
|
||||
}
|
||||
|
||||
/**
|
||||
* Loaded lazily so that {@code flash-ext-openapi} classes are only resolved at
|
||||
* runtime when {@link dev.relism.ext.openapi.OpenApiSecurityRegistry} is actually on the classpath.
|
||||
* If not present, the {@link NoClassDefFoundError} is caught at the call site.
|
||||
*/
|
||||
private static final class OpenApiIntegration {
|
||||
static void register(dev.relism.extension.ExtensionContext ctx,
|
||||
OidcConfig config, OidcProviderMetadata meta) {
|
||||
ctx.find(dev.relism.ext.openapi.OpenApiSecurityRegistry.class)
|
||||
.ifPresent(registry -> registry.add(new dev.relism.ext.openapi.OpenApiSecurityContributor() {
|
||||
|
||||
@Override
|
||||
public String schemeName() { return config.schemeName(); }
|
||||
|
||||
@Override
|
||||
public java.util.Map<String, Object> schemeDefinition() {
|
||||
// Declare only the authorizationCode flow so Swagger UI shows
|
||||
// a single clean "Authorize" dialog instead of expanding every
|
||||
// grant type from the discovery document.
|
||||
java.util.Map<String, String> scopesMap = new java.util.LinkedHashMap<>();
|
||||
for (String s : config.scopes().split("\\s+")) {
|
||||
if (!s.isBlank()) scopesMap.put(s, s);
|
||||
}
|
||||
java.util.Map<String, Object> flow = new java.util.LinkedHashMap<>();
|
||||
flow.put("authorizationUrl", meta.authorizationEndpoint());
|
||||
flow.put("tokenUrl", meta.tokenEndpoint());
|
||||
flow.put("scopes", scopesMap);
|
||||
|
||||
java.util.Map<String, Object> scheme = new java.util.LinkedHashMap<>();
|
||||
scheme.put("type", "oauth2");
|
||||
scheme.put("flows", java.util.Map.of("authorizationCode", flow));
|
||||
return scheme;
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.List<String> requiredFor(Class<?> handlerClass) {
|
||||
dev.relism.ext.oidc.RolesAllowed roles =
|
||||
handlerClass.getAnnotation(dev.relism.ext.oidc.RolesAllowed.class);
|
||||
if (roles != null) return java.util.Arrays.asList(roles.value());
|
||||
|
||||
dev.relism.ext.oidc.Authenticated auth =
|
||||
handlerClass.getAnnotation(dev.relism.ext.oidc.Authenticated.class);
|
||||
if (auth != null) return java.util.List.of();
|
||||
|
||||
return null; // not secured by this contributor
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
package dev.relism.ext.oidc;
|
||||
|
||||
import dev.relism.exceptions.HttpException;
|
||||
import dev.relism.models.Request;
|
||||
import dev.relism.routing.Middleware;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Request-level OIDC middleware. Exposed in the {@link dev.relism.extension.ExtensionContext}
|
||||
* for manual use on lambda routes; injected automatically for handlers annotated with
|
||||
* {@link Authenticated} or {@link RolesAllowed}.
|
||||
*
|
||||
* <p>Resolution order on each request:
|
||||
* <ol>
|
||||
* <li>{@code Authorization: Bearer ...} header — validated via JWKS ({@link JwtValidator}).</li>
|
||||
* <li>{@code oidc_session} cookie — looked up in {@link OidcSessionStore}; transparently
|
||||
* refreshed if the access token is expired.</li>
|
||||
* <li>Browser clients (no {@code Accept: application/json}) → redirect to
|
||||
* {@code {routePrefix}/login?redirect={path}}.</li>
|
||||
* <li>API clients → 401.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <pre>{@code
|
||||
* // Manual use on a lambda route:
|
||||
* OidcMiddleware oidc = app.ctx().require(OidcMiddleware.class);
|
||||
* app.get("/api/me", (req, res) -> ClaimsHolder.claim("sub"), oidc.protect());
|
||||
* app.delete("/admin/users/{id}", handler, oidc.requireRole("admin"));
|
||||
* }</pre>
|
||||
*/
|
||||
public class OidcMiddleware {
|
||||
|
||||
private final JwtValidator validator;
|
||||
private final OidcConfig config;
|
||||
private final OidcProviderMetadata meta;
|
||||
private final TokenClient tokenClient;
|
||||
|
||||
OidcMiddleware(JwtValidator validator, OidcConfig config,
|
||||
OidcProviderMetadata meta, TokenClient tokenClient) {
|
||||
this.validator = validator;
|
||||
this.config = config;
|
||||
this.meta = meta;
|
||||
this.tokenClient = tokenClient;
|
||||
}
|
||||
|
||||
// -- Public API -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Validates the bearer token or session cookie. Browser clients are redirected
|
||||
* to the login page on failure; API clients receive 401.
|
||||
*/
|
||||
public Middleware protect() {
|
||||
return next -> (req, res) -> {
|
||||
Map<String, Object> claims = resolve(req, res);
|
||||
if (claims == null) return null; // redirect already written
|
||||
ClaimsHolder.set(claims);
|
||||
try {
|
||||
return next.handle(req, res);
|
||||
} finally {
|
||||
ClaimsHolder.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link #protect()} but also enforces that the caller holds at least one
|
||||
* of the given roles (OR semantics). Roles are extracted via
|
||||
* {@link OidcConfig#rolesClaimPath()}.
|
||||
*/
|
||||
public Middleware requireRole(String... roles) {
|
||||
return next -> (req, res) -> {
|
||||
Map<String, Object> claims = resolve(req, res);
|
||||
if (claims == null) return null;
|
||||
checkRoles(claims, roles);
|
||||
ClaimsHolder.set(claims);
|
||||
try {
|
||||
return next.handle(req, res);
|
||||
} finally {
|
||||
ClaimsHolder.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// -- Package-private: AnnotationProcessor hooks ---------------------------
|
||||
|
||||
Middleware authenticatedMiddleware() { return protect(); }
|
||||
Middleware rolesMiddleware(String[] required) { return requireRole(required); }
|
||||
|
||||
// -- Internals ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns claims on success, or {@code null} if a redirect was already written to
|
||||
* {@code res}. Throws {@link HttpException} 401/403 for API clients.
|
||||
*/
|
||||
private Map<String, Object> resolve(Request req, dev.relism.models.Response res) {
|
||||
// 1. Bearer token
|
||||
String auth = req.header("Authorization");
|
||||
if (auth != null && auth.startsWith("Bearer "))
|
||||
return validator.validate(auth.substring(7));
|
||||
|
||||
// 2. Session cookie
|
||||
String sessionId = cookieValue(req, "oidc_session");
|
||||
if (sessionId != null) {
|
||||
Optional<OidcSession> found = config.sessionStore().find(sessionId);
|
||||
if (found.isPresent()) {
|
||||
OidcSession session = found.get();
|
||||
|
||||
if (!session.isAccessTokenExpired())
|
||||
return session.claims();
|
||||
|
||||
// Access token expired — try silent refresh
|
||||
if (session.refreshToken() != null) {
|
||||
try {
|
||||
OidcSession refreshed = doRefresh(session);
|
||||
config.sessionStore().save(refreshed);
|
||||
return refreshed.claims();
|
||||
} catch (Exception ignored) {
|
||||
// Refresh failed — fall through to re-authenticate
|
||||
}
|
||||
}
|
||||
config.sessionStore().delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. No valid credentials
|
||||
String accept = req.header("Accept");
|
||||
if (accept != null && accept.contains("application/json"))
|
||||
throw HttpException.unauthorized();
|
||||
|
||||
// Browser — redirect to login, preserving the original URL in state
|
||||
String loginUrl = config.routePrefix() + "/login?redirect="
|
||||
+ URLEncoder.encode(req.path(), StandardCharsets.UTF_8);
|
||||
res.status(302).header("Location", loginUrl);
|
||||
return null;
|
||||
}
|
||||
|
||||
private OidcSession doRefresh(OidcSession old) throws Exception {
|
||||
OidcTokenResponse tokens = tokenClient.refresh(
|
||||
meta.tokenEndpoint(), old.refreshToken());
|
||||
|
||||
Map<String, Object> claims = mergeRefreshedClaims(tokens, old);
|
||||
|
||||
return new OidcSession(
|
||||
old.id(),
|
||||
tokens.accessToken(),
|
||||
tokens.idToken() != null ? tokens.idToken() : old.idToken(),
|
||||
tokens.refreshToken() != null ? tokens.refreshToken() : old.refreshToken(),
|
||||
Instant.now().plusSeconds(tokens.expiresIn()),
|
||||
claims
|
||||
);
|
||||
}
|
||||
|
||||
private void checkRoles(Map<String, Object> claims, String[] required) {
|
||||
List<String> actual = extractRoles(claims);
|
||||
for (String role : required) {
|
||||
if (actual.contains(role)) return;
|
||||
}
|
||||
throw HttpException.forbidden();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<String> extractRoles(Map<String, Object> claims) {
|
||||
String[] parts = config.rolesClaimPath().split("\\.");
|
||||
Object current = claims;
|
||||
for (String part : parts) {
|
||||
if (!(current instanceof Map<?, ?> m)) return List.of();
|
||||
current = m.get(part);
|
||||
}
|
||||
if (current instanceof List<?> list)
|
||||
return list.stream().map(Object::toString).toList();
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private static Map<String, Object> mergeRefreshedClaims(OidcTokenResponse tokens, OidcSession old) {
|
||||
Map<String, Object> merged = new HashMap<>();
|
||||
// Fall back to old claims first, then overlay fresh token claims
|
||||
merged.putAll(old.claims());
|
||||
if (tokens.accessToken() != null)
|
||||
merged.putAll(JwtUtils.parseClaims(tokens.accessToken()));
|
||||
if (tokens.idToken() != null)
|
||||
merged.putAll(JwtUtils.parseClaims(tokens.idToken()));
|
||||
return Map.copyOf(merged);
|
||||
}
|
||||
|
||||
// -- Shared cookie utility (also used by OidcExtension) -------------------
|
||||
|
||||
static String cookieValue(Request req, String name) {
|
||||
String header = req.header("Cookie");
|
||||
if (header == null || header.isBlank()) return null;
|
||||
for (String part : header.split(";")) {
|
||||
int eq = part.indexOf('=');
|
||||
if (eq > 0 && part.substring(0, eq).strip().equals(name))
|
||||
return part.substring(eq + 1).strip();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package dev.relism.ext.oidc;
|
||||
|
||||
/**
|
||||
* OIDC provider endpoints discovered from {@code {issuer}/.well-known/openid-configuration}.
|
||||
*
|
||||
* <p>{@link #endSessionEndpoint()} may be {@code null} — not all providers expose it
|
||||
* (e.g. some Authelia configurations omit it).
|
||||
*/
|
||||
public record OidcProviderMetadata(
|
||||
String authorizationEndpoint,
|
||||
String tokenEndpoint,
|
||||
String userinfoEndpoint,
|
||||
String jwksUri,
|
||||
String endSessionEndpoint // nullable
|
||||
) {}
|
||||
@@ -0,0 +1,47 @@
|
||||
package dev.relism.ext.oidc;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* An authenticated user's OIDC session — persisted in {@link OidcSessionStore} and
|
||||
* looked up via the {@code oidc_session} cookie on every request.
|
||||
*
|
||||
* <p>Sessions are immutable; a refreshed access token produces a new instance
|
||||
* that replaces the old one in the store (same {@link #id()}).
|
||||
*/
|
||||
public final class OidcSession {
|
||||
|
||||
private final String id;
|
||||
private final String accessToken;
|
||||
private final String idToken;
|
||||
private final String refreshToken; // may be null
|
||||
private final Instant accessTokenExpiresAt;
|
||||
private final Map<String, Object> claims; // decoded from id_token
|
||||
|
||||
public OidcSession(String id, String accessToken, String idToken,
|
||||
String refreshToken, Instant accessTokenExpiresAt,
|
||||
Map<String, Object> claims) {
|
||||
this.id = id;
|
||||
this.accessToken = accessToken;
|
||||
this.idToken = idToken;
|
||||
this.refreshToken = refreshToken;
|
||||
this.accessTokenExpiresAt = accessTokenExpiresAt;
|
||||
this.claims = Map.copyOf(claims);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns {@code true} if the access token has expired or will expire within
|
||||
* the next 30 seconds (eager refresh to avoid mid-request expiry).
|
||||
*/
|
||||
public boolean isAccessTokenExpired() {
|
||||
return Instant.now().isAfter(accessTokenExpiresAt.minusSeconds(30));
|
||||
}
|
||||
|
||||
public String id() { return id; }
|
||||
public String accessToken() { return accessToken; }
|
||||
public String idToken() { return idToken; }
|
||||
public String refreshToken() { return refreshToken; }
|
||||
public Instant accessTokenExpiresAt() { return accessTokenExpiresAt; }
|
||||
public Map<String, Object> claims() { return claims; }
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package dev.relism.ext.oidc;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Backing store for {@link OidcSession} objects. The default implementation is
|
||||
* {@link InMemoryOidcSessionStore}; supply a custom one via
|
||||
* {@link OidcConfig.Builder#sessionStore(OidcSessionStore)} for Redis, JDBC, etc.
|
||||
*/
|
||||
public interface OidcSessionStore {
|
||||
void save(OidcSession session);
|
||||
Optional<OidcSession> find(String sessionId);
|
||||
void delete(String sessionId);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package dev.relism.ext.oidc;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Short-lived store mapping state nonces → (original URL, PKCE verifier).
|
||||
*
|
||||
* <p>Entries expire after {@value #TTL_SECONDS} seconds. Cleanup runs on every
|
||||
* access to prevent unbounded growth without needing a background thread.
|
||||
*/
|
||||
final class OidcStateStore {
|
||||
|
||||
static final int TTL_SECONDS = 600; // 10 minutes
|
||||
|
||||
record Entry(String originalUrl, String codeVerifier, String nonce, Instant expiresAt) {}
|
||||
|
||||
private final ConcurrentHashMap<String, Entry> store = new ConcurrentHashMap<>();
|
||||
|
||||
void put(String state, String originalUrl, String codeVerifier, String nonce) {
|
||||
cleanup();
|
||||
store.put(state, new Entry(originalUrl, codeVerifier, nonce,
|
||||
Instant.now().plusSeconds(TTL_SECONDS)));
|
||||
}
|
||||
|
||||
/** Atomically retrieves and removes the entry; returns empty if absent or expired. */
|
||||
Optional<Entry> consumeAndRemove(String nonce) {
|
||||
cleanup();
|
||||
Entry e = store.remove(nonce);
|
||||
if (e == null || Instant.now().isAfter(e.expiresAt())) return Optional.empty();
|
||||
return Optional.of(e);
|
||||
}
|
||||
|
||||
private void cleanup() {
|
||||
Instant now = Instant.now();
|
||||
store.entrySet().removeIf(kv -> now.isAfter(kv.getValue().expiresAt()));
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package dev.relism.ext.oidc;
|
||||
|
||||
/** Parsed response from an OAuth2 token endpoint. Package-private — internal use only. */
|
||||
record OidcTokenResponse(
|
||||
String accessToken,
|
||||
String idToken, // may be null on refresh if provider omits it
|
||||
String refreshToken, // may be null
|
||||
int expiresIn,
|
||||
int refreshExpiresIn
|
||||
) {}
|
||||
@@ -0,0 +1,106 @@
|
||||
package dev.relism.ext.oidc;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Type-safe view over the JWT claims stored in {@link ClaimsHolder}.
|
||||
*
|
||||
* <p>Obtainable from any protected context via {@link ClaimsHolder#user()}.
|
||||
* Class-based handlers that extend the {@code SessionHandler} hierarchy already
|
||||
* have a provisioned DB user in {@code currentUser}; {@code OidcUser} complements
|
||||
* that by giving access to the raw OIDC claims when needed, and is the primary
|
||||
* API for lambda routes.
|
||||
*
|
||||
* <pre>{@code
|
||||
* // Lambda route (OidcMiddleware injected):
|
||||
* app.get("/api/whoami", (req, res) -> {
|
||||
* OidcUser u = ClaimsHolder.user();
|
||||
* return Map.of("sub", u.sub(), "email", u.email(), "roles", u.roles());
|
||||
* }, oidcMw.protect());
|
||||
*
|
||||
* // Class-based handler (currentUser is the DB entity; oidcUser() for raw claims):
|
||||
* protected Object handleAuthenticated(Request req, Response res) throws Exception {
|
||||
* OidcUser u = oidcUser(); // same as ClaimsHolder.user()
|
||||
* return json(res, currentUser); // DB entity — provisioned from OIDC sub
|
||||
* }
|
||||
* }</pre>
|
||||
*/
|
||||
public final class OidcUser {
|
||||
|
||||
private final Map<String, Object> claims;
|
||||
|
||||
OidcUser(Map<String, Object> claims) {
|
||||
this.claims = claims;
|
||||
}
|
||||
|
||||
// ── Common OIDC standard claims ───────────────────────────────────────────
|
||||
|
||||
/** Subject identifier — unique, stable user ID issued by the provider. */
|
||||
public String sub() { return str("sub"); }
|
||||
|
||||
/** User's email address ({@code email} claim). */
|
||||
public String email() { return str("email"); }
|
||||
|
||||
/** Human-readable username ({@code preferred_username} claim). */
|
||||
public String username() { return str("preferred_username"); }
|
||||
|
||||
/** Full display name ({@code name} claim). */
|
||||
public String name() { return str("name"); }
|
||||
|
||||
// ── Roles ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extracts the roles list by traversing a dot-separated claim path.
|
||||
*
|
||||
* <p>Example paths:
|
||||
* <ul>
|
||||
* <li>{@code "realm_access.roles"} — Keycloak realm roles</li>
|
||||
* <li>{@code "resource_access.my-client.roles"} — Keycloak client roles</li>
|
||||
* <li>{@code "groups"} — Authelia / generic IdPs</li>
|
||||
* </ul>
|
||||
*
|
||||
* @return list of role strings, or an empty list if the path doesn't exist
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<String> roles(String claimPath) {
|
||||
String[] parts = claimPath.split("\\.");
|
||||
Object current = claims;
|
||||
for (String part : parts) {
|
||||
if (!(current instanceof Map<?, ?> m)) return List.of();
|
||||
current = m.get(part);
|
||||
}
|
||||
if (current instanceof List<?> list)
|
||||
return list.stream().map(Object::toString).toList();
|
||||
return List.of();
|
||||
}
|
||||
|
||||
/** Returns {@code true} if the user holds {@code role} at the given claim path. */
|
||||
public boolean hasRole(String claimPath, String role) {
|
||||
return roles(claimPath).contains(role);
|
||||
}
|
||||
|
||||
// ── Arbitrary claim access ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the value of any claim, cast to {@code T}.
|
||||
*
|
||||
* @throws ClassCastException if the stored value is not assignable to {@code type}
|
||||
*/
|
||||
public <T> T claim(String key, Class<T> type) {
|
||||
return type.cast(claims.get(key));
|
||||
}
|
||||
|
||||
/** Returns the raw claim value, or {@code null} if absent. */
|
||||
public Object claim(String key) { return claims.get(key); }
|
||||
|
||||
/** Escape hatch — returns the full unmodified claims map. */
|
||||
public Map<String, Object> claims() { return claims; }
|
||||
|
||||
// ── Internals ─────────────────────────────────────────────────────────
|
||||
|
||||
private String str(String key) {
|
||||
Object v = claims.get(key);
|
||||
return v != null ? v.toString() : null;
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package dev.relism.ext.oidc;
|
||||
|
||||
/**
|
||||
* Thrown when OIDC token validation fails (signature, claims, nonce, expiry, etc.).
|
||||
* Distinct from {@link dev.relism.exceptions.HttpException}: this signals a protocol-level
|
||||
* failure, not an HTTP response — callers decide the appropriate status code.
|
||||
*/
|
||||
public final class OidcValidationException extends RuntimeException {
|
||||
public OidcValidationException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package dev.relism.ext.oidc;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* PKCE (RFC 7636) utilities: code verifier generation and S256 challenge computation.
|
||||
* Package-private — used exclusively by {@link OidcExtension}.
|
||||
*/
|
||||
final class PkceUtils {
|
||||
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
private PkceUtils() {}
|
||||
|
||||
/**
|
||||
* Generates a cryptographically random code verifier (43 URL-safe characters,
|
||||
* per RFC 7636 §4.1 — 32 bytes encoded as unpadded Base64URL).
|
||||
*/
|
||||
static String generateVerifier() {
|
||||
byte[] bytes = new byte[32];
|
||||
RANDOM.nextBytes(bytes);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the S256 code challenge: {@code BASE64URL(SHA-256(ASCII(verifier)))}.
|
||||
*/
|
||||
static String computeChallenge(String verifier) throws Exception {
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(verifier.getBytes(StandardCharsets.US_ASCII));
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package dev.relism.ext.oidc;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* Restricts a handler to callers whose JWT contains at least one of the
|
||||
* specified roles. Authentication is implicitly required — no need to combine
|
||||
* with {@link Authenticated}.
|
||||
*
|
||||
* <p>Roles are read from the claim configured in {@link OidcConfig#rolesClaimPath()}
|
||||
* (default: {@code "roles"}). Nested paths like {@code "realm_access.roles"} are
|
||||
* supported with dot notation.
|
||||
*
|
||||
* <pre>{@code
|
||||
* @Route(method = HttpMethod.DELETE, path = "/api/admin/blogs/{id}")
|
||||
* @RolesAllowed("admin")
|
||||
* public class DeleteBlog extends JacksonHandler { ... }
|
||||
*
|
||||
* // Multiple accepted roles (OR semantics — any one role is sufficient):
|
||||
* @RolesAllowed({"admin", "editor"})
|
||||
* public class UpdateBlog extends JacksonHandler { ... }
|
||||
* }</pre>
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public @interface RolesAllowed {
|
||||
/** One or more role names. Access is granted if the caller has any of them. */
|
||||
String[] value();
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package dev.relism.ext.oidc;
|
||||
|
||||
import net.minidev.json.JSONValue;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* HTTP client for OAuth2 token endpoint operations (pure HTTP, no SDK).
|
||||
*
|
||||
* <p>Supports two client authentication methods (RFC 6749 §2.3):
|
||||
* <ul>
|
||||
* <li>{@link ClientAuthMethod#POST} — credentials in form body ({@code client_secret_post})</li>
|
||||
* <li>{@link ClientAuthMethod#BASIC} — credentials in {@code Authorization: Basic} header
|
||||
* ({@code client_secret_basic})</li>
|
||||
* </ul>
|
||||
*/
|
||||
final class TokenClient {
|
||||
|
||||
private final HttpClient http;
|
||||
private final String clientId;
|
||||
private final String clientSecret;
|
||||
private final ClientAuthMethod authMethod;
|
||||
|
||||
TokenClient(HttpClient http, OidcConfig config) {
|
||||
this.http = http;
|
||||
this.clientId = config.clientId();
|
||||
this.clientSecret = config.clientSecret();
|
||||
this.authMethod = config.clientAuthMethod();
|
||||
}
|
||||
|
||||
/** Authorization Code + PKCE exchange. */
|
||||
OidcTokenResponse exchangeCode(String tokenEndpoint,
|
||||
String code, String redirectUri,
|
||||
String codeVerifier) throws Exception {
|
||||
Map<String, String> params = new LinkedHashMap<>();
|
||||
params.put("grant_type", "authorization_code");
|
||||
params.put("code", code);
|
||||
params.put("redirect_uri", redirectUri);
|
||||
params.put("code_verifier", codeVerifier);
|
||||
return post(tokenEndpoint, params);
|
||||
}
|
||||
|
||||
/** Refresh token grant. */
|
||||
OidcTokenResponse refresh(String tokenEndpoint, String refreshToken) throws Exception {
|
||||
Map<String, String> params = new LinkedHashMap<>();
|
||||
params.put("grant_type", "refresh_token");
|
||||
params.put("refresh_token", refreshToken);
|
||||
return post(tokenEndpoint, params);
|
||||
}
|
||||
|
||||
// -- Internals ------------------------------------------------------------
|
||||
|
||||
private OidcTokenResponse post(String url, Map<String, String> params) throws Exception {
|
||||
HttpRequest.Builder req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("Content-Type", "application/x-www-form-urlencoded");
|
||||
|
||||
if (authMethod == ClientAuthMethod.BASIC) {
|
||||
String creds = Base64.getEncoder().encodeToString(
|
||||
(clientId + ":" + clientSecret).getBytes(StandardCharsets.UTF_8));
|
||||
req.header("Authorization", "Basic " + creds);
|
||||
} else {
|
||||
params.put("client_id", clientId);
|
||||
params.put("client_secret", clientSecret);
|
||||
}
|
||||
|
||||
HttpResponse<String> resp = http.send(
|
||||
req.POST(HttpRequest.BodyPublishers.ofString(form(params))).build(),
|
||||
HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (resp.statusCode() < 200 || resp.statusCode() >= 300)
|
||||
throw new IllegalStateException(
|
||||
"Token endpoint [" + resp.statusCode() + "]: " + resp.body());
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> json = (Map<String, Object>) JSONValue.parse(resp.body());
|
||||
|
||||
return new OidcTokenResponse(
|
||||
(String) json.get("access_token"),
|
||||
(String) json.get("id_token"),
|
||||
(String) json.get("refresh_token"),
|
||||
numInt(json, "expires_in", 300),
|
||||
numInt(json, "refresh_expires_in", 1800)
|
||||
);
|
||||
}
|
||||
|
||||
private static String form(Map<String, String> params) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
params.forEach((k, v) -> {
|
||||
if (!sb.isEmpty()) sb.append('&');
|
||||
sb.append(enc(k)).append('=').append(enc(v));
|
||||
});
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String enc(String v) {
|
||||
return URLEncoder.encode(v, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static int numInt(Map<String, Object> m, String key, int def) {
|
||||
Object v = m.get(key);
|
||||
return v instanceof Number n ? n.intValue() : def;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
# flash-ext-openapi
|
||||
|
||||
OpenAPI 3.0 spec generation and Swagger UI for the Flash HTTP server.
|
||||
|
||||
## What it provides
|
||||
|
||||
| Route | Description |
|
||||
|---|---|
|
||||
| `GET /openapi.json` | OpenAPI 3.0.3 spec as JSON |
|
||||
| `GET /openapi.yaml` | OpenAPI 3.0.3 spec as YAML |
|
||||
| `GET /openapi/swagger` | Swagger UI (loaded from unpkg CDN) |
|
||||
|
||||
The base path is configurable. Operations are collected automatically at handler-registration time
|
||||
from class-based handlers annotated with `@ApiOperation`.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Requires `flash-ext-jackson` installed **before** this extension (shares its `ObjectMapper` from context).
|
||||
If `flash-ext-oidc` is installed **after** this extension, OIDC security schemes are injected automatically.
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-openapi</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```java
|
||||
FlashApp.of(new HttpServer(config))
|
||||
.install(new JacksonExtension())
|
||||
.install(new OpenApiExtension("/openapi", "My API", "2.0.0", "Optional description"))
|
||||
.register(new MyHandler());
|
||||
```
|
||||
|
||||
### Constructors
|
||||
|
||||
```java
|
||||
new OpenApiExtension() // base path: /openapi, title: API, version: 1.0.0
|
||||
new OpenApiExtension("/docs") // custom base path
|
||||
new OpenApiExtension("/docs", "My API", "2.0.0") // title + version
|
||||
new OpenApiExtension("/docs", "My API", "2.0.0", "desc") // full
|
||||
```
|
||||
|
||||
## Annotating handlers
|
||||
|
||||
All annotations target the **handler class** (`@Target(ElementType.TYPE)`).
|
||||
|
||||
### @ApiOperation
|
||||
|
||||
```java
|
||||
@Route(method = HttpMethod.GET, path = "/api/blogs")
|
||||
@ApiOperation(
|
||||
summary = "List all blogs",
|
||||
description = "Returns a paginated list of published blog posts.",
|
||||
tags = {"blogs"},
|
||||
operationId = "listBlogs",
|
||||
deprecated = false
|
||||
)
|
||||
public class ListBlogs extends JacksonHandler { ... }
|
||||
```
|
||||
|
||||
| Field | Default | Description |
|
||||
|---|---|---|
|
||||
| `summary` | `""` | Short one-liner shown in the operation title |
|
||||
| `description` | `""` | Longer Markdown description |
|
||||
| `tags` | `{}` | Groups operations in the Swagger UI sidebar |
|
||||
| `operationId` | `""` | Unique machine-readable ID |
|
||||
| `deprecated` | `false` | Marks the operation with a strikethrough |
|
||||
|
||||
### @ApiResponse
|
||||
|
||||
Repeatable — annotate as many status codes as the handler can return.
|
||||
|
||||
```java
|
||||
@ApiResponse(status = 200, description = "Blog created", schema = Blog.class)
|
||||
@ApiResponse(status = 400, description = "Invalid input")
|
||||
@ApiResponse(status = 409, description = "Slug already exists")
|
||||
public class CreateBlog extends JacksonHandler { ... }
|
||||
```
|
||||
|
||||
`schema` references `#/components/schemas/<ClassName>` — you are responsible for populating
|
||||
`components.schemas` if you need full model documentation (not yet auto-generated).
|
||||
|
||||
`@ApiResponse` is repeatable. The container `@ApiResponses({ @ApiResponse(...), ... })` is also available.
|
||||
|
||||
### @ApiParam
|
||||
|
||||
Repeatable — declare query, path, header, or cookie parameters explicitly.
|
||||
|
||||
```java
|
||||
@ApiParam(name = "limit", in = "query", type = "integer", description = "Max results (default 20)")
|
||||
@ApiParam(name = "offset", in = "query", type = "integer", description = "Pagination offset")
|
||||
@ApiParam(name = "slug", in = "path", type = "string", required = true)
|
||||
@ApiParam(name = "X-Trace-Id", in = "header", type = "string")
|
||||
public class GetBlog extends JacksonHandler { ... }
|
||||
```
|
||||
|
||||
> Path parameters declared in `@Route(path = "/blogs/{id}")` are extracted and added automatically
|
||||
> as required path parameters — you only need `@ApiParam` for query / header / cookie params.
|
||||
|
||||
`@ApiParam` is repeatable. If you prefer grouping them, `@ApiParams({ @ApiParam(...), @ApiParam(...) })` is
|
||||
the container annotation.
|
||||
|
||||
| Field | Default | Description |
|
||||
|---|---|---|
|
||||
| `name` | — | Parameter name |
|
||||
| `in` | `"query"` | Location: `"query"`, `"path"`, `"header"`, `"cookie"` |
|
||||
| `type` | `"string"` | OpenAPI primitive: `"string"`, `"integer"`, `"number"`, `"boolean"` |
|
||||
| `description` | `""` | Human-readable description |
|
||||
| `required` | `false` | Whether the parameter is mandatory |
|
||||
| `example` | `""` | Inline example value shown in Swagger UI |
|
||||
|
||||
## Security integration
|
||||
|
||||
`flash-ext-openapi` defines the `OpenApiSecurityContributor` / `OpenApiSecurityRegistry` contracts.
|
||||
Security extensions (e.g. `flash-ext-oidc`) register a contributor at install time; the spec
|
||||
builder picks it up automatically — no coupling between extensions.
|
||||
|
||||
### How it works
|
||||
|
||||
1. `OpenApiExtension` creates an `OpenApiSecurityRegistry` and exposes it in the `ExtensionContext`.
|
||||
2. `flash-ext-oidc` calls `ctx.find(OpenApiSecurityRegistry.class)` and registers its contributor.
|
||||
3. At spec build time, `OpenApiBuilder` iterates contributors and injects `security` entries on each
|
||||
operation whose handler class carries `@Authenticated` or `@RolesAllowed`.
|
||||
|
||||
### Implementing a custom contributor
|
||||
|
||||
```java
|
||||
public class MyAuthContributor implements OpenApiSecurityContributor {
|
||||
|
||||
@Override
|
||||
public String schemeName() { return "myScheme"; }
|
||||
|
||||
@Override
|
||||
public Map<String, Object> schemeDefinition() {
|
||||
return Map.of("type", "apiKey", "in", "header", "name", "X-API-Key");
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> requiredFor(Class<?> handlerClass) {
|
||||
if (handlerClass.isAnnotationPresent(MyAuth.class)) return List.of();
|
||||
return null; // not secured by this contributor
|
||||
}
|
||||
}
|
||||
|
||||
// Register during extension install:
|
||||
ctx.find(OpenApiSecurityRegistry.class)
|
||||
.ifPresent(r -> r.add(new MyAuthContributor()));
|
||||
```
|
||||
|
||||
Return values from `requiredFor`:
|
||||
|
||||
| Return | Meaning |
|
||||
|---|---|
|
||||
| `null` | Handler is not secured by this contributor — skip |
|
||||
| `List.of()` | Requires authentication, no specific scopes |
|
||||
| `List.of("admin", "user")` | Requires one of these scopes (OpenAPI OR semantics) |
|
||||
@@ -0,0 +1,34 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-openapi</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-jackson</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.dataformat</groupId>
|
||||
<artifactId>jackson-dataformat-yaml</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package dev.relism.ext.openapi;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* Declares OpenAPI operation metadata for a class-based handler.
|
||||
* Picked up by {@link OpenApiExtension} via {@link FlashApp#register}.
|
||||
*
|
||||
* <pre>{@code
|
||||
* @Route(method = HttpMethod.GET, path = "/api/blogs")
|
||||
* @ApiOperation(summary = "List all blogs", tags = {"blogs"})
|
||||
* public class ListBlogs extends JacksonHandler { ... }
|
||||
* }</pre>
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public @interface ApiOperation {
|
||||
String summary() default "";
|
||||
String description() default "";
|
||||
String[] tags() default {};
|
||||
boolean deprecated() default false;
|
||||
String operationId() default "";
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package dev.relism.ext.openapi;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
/**
|
||||
* Declares a single parameter (query, path, header, or cookie) for an operation.
|
||||
* Repeatable — place multiple annotations on the same handler class.
|
||||
*
|
||||
* <pre>{@code
|
||||
* @ApiParam(name = "limit", in = "query", type = "integer", description = "Max results (default 20)")
|
||||
* @ApiParam(name = "offset", in = "query", type = "integer", description = "Pagination offset")
|
||||
* public class ListBlogs extends JacksonHandler { ... }
|
||||
* }</pre>
|
||||
*/
|
||||
@Repeatable(ApiParams.class)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public @interface ApiParam {
|
||||
String name();
|
||||
/** "query", "path", "header", or "cookie". */
|
||||
String in() default "query";
|
||||
/** OpenAPI primitive type: "string", "integer", "number", "boolean". */
|
||||
String type() default "string";
|
||||
String description() default "";
|
||||
boolean required() default false;
|
||||
String example() default "";
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package dev.relism.ext.openapi;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/** Container for repeated {@link ApiParam} annotations. */
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public @interface ApiParams {
|
||||
ApiParam[] value();
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package dev.relism.ext.openapi;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
/**
|
||||
* Declares a single response for an operation. Repeatable — use multiple
|
||||
* {@code @ApiResponse} annotations on the same handler to document several status codes.
|
||||
*
|
||||
* <pre>{@code
|
||||
* @ApiResponse(status = 200, description = "Blog created", schema = Blog.class)
|
||||
* @ApiResponse(status = 400, description = "Invalid input")
|
||||
* @ApiResponse(status = 409, description = "Slug already exists")
|
||||
* public class CreateBlog extends JacksonHandler { ... }
|
||||
* }</pre>
|
||||
*/
|
||||
@Repeatable(ApiResponses.class)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public @interface ApiResponse {
|
||||
int status();
|
||||
String description() default "";
|
||||
Class<?> schema() default Void.class;
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package dev.relism.ext.openapi;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/** Container for repeated {@link ApiResponse} annotations. */
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public @interface ApiResponses {
|
||||
ApiResponse[] value();
|
||||
}
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
package dev.relism.ext.openapi;
|
||||
|
||||
import dev.relism.routing.Route;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Accumulates OpenAPI 3.0 operations and builds the spec document as a plain
|
||||
* {@code Map} for Jackson to serialize. Operations are added at registration time
|
||||
* via {@link OpenApiExtension}'s {@link dev.relism.AnnotationProcessor}.
|
||||
*/
|
||||
public class OpenApiBuilder {
|
||||
|
||||
private String title = "API";
|
||||
private String version = "1.0.0";
|
||||
private String description = "";
|
||||
|
||||
private final Map<String, Map<String, Object>> paths = new LinkedHashMap<>();
|
||||
private final Map<String, Map<String, Class<?>>> operationHandlers = new LinkedHashMap<>();
|
||||
private OpenApiSecurityRegistry securityRegistry;
|
||||
|
||||
// ── Configuration ─────────────────────────────────────────────────────────
|
||||
|
||||
public OpenApiBuilder title(String title) { this.title = title; return this; }
|
||||
public OpenApiBuilder version(String version) { this.version = version; return this; }
|
||||
public OpenApiBuilder description(String description) { this.description = description; return this; }
|
||||
|
||||
void setSecurityRegistry(OpenApiSecurityRegistry registry) {
|
||||
this.securityRegistry = registry;
|
||||
}
|
||||
|
||||
// ── Operation registration ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Adds an operation derived from the handler's {@link Route}, {@link ApiOperation},
|
||||
* {@link ApiResponse}, and {@link ApiParam} annotations.
|
||||
*/
|
||||
public void addOperation(Route route, ApiOperation op, Class<?> handlerClass) {
|
||||
String path = normalizePath(route.path());
|
||||
String method = route.method().name().toLowerCase();
|
||||
|
||||
Map<String, Object> pathItem = paths.computeIfAbsent(path, k -> new LinkedHashMap<>());
|
||||
Map<String, Object> operation = new LinkedHashMap<>();
|
||||
|
||||
if (!op.operationId().isEmpty()) operation.put("operationId", op.operationId());
|
||||
if (!op.summary().isEmpty()) operation.put("summary", op.summary());
|
||||
if (!op.description().isEmpty()) operation.put("description", op.description());
|
||||
if (op.tags().length > 0) operation.put("tags", Arrays.asList(op.tags()));
|
||||
if (op.deprecated()) operation.put("deprecated", true);
|
||||
|
||||
buildParameters(operation, handlerClass, route);
|
||||
buildResponses(operation, handlerClass);
|
||||
|
||||
pathItem.put(method, operation);
|
||||
operationHandlers.computeIfAbsent(path, k -> new LinkedHashMap<>()).put(method, handlerClass);
|
||||
}
|
||||
|
||||
// ── Spec build ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the complete OpenAPI 3.0.3 spec as a plain map ready for JSON
|
||||
* serialization. Called on each request to {@code /openapi.json} so that
|
||||
* handlers registered after the extension is installed are included.
|
||||
*/
|
||||
public Map<String, Object> build() {
|
||||
Map<String, Object> info = new LinkedHashMap<>();
|
||||
info.put("title", title);
|
||||
info.put("version", version);
|
||||
if (!description.isEmpty()) info.put("description", description);
|
||||
|
||||
List<OpenApiSecurityContributor> contributors = securityRegistry != null
|
||||
? securityRegistry.contributors() : List.of();
|
||||
|
||||
// Build paths with security injected per-operation (fresh copy each time so
|
||||
// repeated calls don't accumulate duplicate security entries)
|
||||
Map<String, Object> renderedPaths = new LinkedHashMap<>();
|
||||
for (var pathEntry : paths.entrySet()) {
|
||||
Map<String, Object> renderedPathItem = new LinkedHashMap<>();
|
||||
Map<String, Class<?>> handlers = operationHandlers.getOrDefault(pathEntry.getKey(), Map.of());
|
||||
for (var methodEntry : pathEntry.getValue().entrySet()) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> original = (Map<String, Object>) methodEntry.getValue();
|
||||
Map<String, Object> op = new LinkedHashMap<>(original); // shallow copy
|
||||
Class<?> handler = handlers.get(methodEntry.getKey());
|
||||
if (handler != null && !contributors.isEmpty()) {
|
||||
List<Map<String, List<String>>> security = buildOperationSecurity(contributors, handler);
|
||||
if (!security.isEmpty()) op.put("security", security);
|
||||
}
|
||||
renderedPathItem.put(methodEntry.getKey(), op);
|
||||
}
|
||||
renderedPaths.put(pathEntry.getKey(), renderedPathItem);
|
||||
}
|
||||
|
||||
Map<String, Object> spec = new LinkedHashMap<>();
|
||||
spec.put("openapi", "3.0.3");
|
||||
spec.put("info", info);
|
||||
spec.put("paths", renderedPaths);
|
||||
|
||||
if (!contributors.isEmpty()) {
|
||||
Map<String, Object> schemes = new LinkedHashMap<>();
|
||||
for (OpenApiSecurityContributor c : contributors) {
|
||||
schemes.put(c.schemeName(), c.schemeDefinition());
|
||||
}
|
||||
spec.put("components", Map.of("securitySchemes", schemes));
|
||||
}
|
||||
|
||||
return spec;
|
||||
}
|
||||
|
||||
// ── Internals ─────────────────────────────────────────────────────────────
|
||||
|
||||
private void buildParameters(Map<String, Object> op, Class<?> cls, Route route) {
|
||||
List<Map<String, Object>> params = new ArrayList<>();
|
||||
|
||||
// Path params from @Route path — add them automatically as required
|
||||
String path = route.path();
|
||||
int i = 0;
|
||||
while (i < path.length()) {
|
||||
int open = path.indexOf('{', i);
|
||||
if (open < 0) break;
|
||||
int close = path.indexOf('}', open);
|
||||
if (close < 0) break;
|
||||
String name = path.substring(open + 1, close);
|
||||
Map<String, Object> p = new LinkedHashMap<>();
|
||||
p.put("name", name);
|
||||
p.put("in", "path");
|
||||
p.put("required", true);
|
||||
p.put("schema", Map.of("type", "string"));
|
||||
params.add(p);
|
||||
i = close + 1;
|
||||
}
|
||||
|
||||
// Explicit @ApiParam annotations
|
||||
ApiParam[] apiParams = cls.getAnnotationsByType(ApiParam.class);
|
||||
for (ApiParam ann : apiParams) {
|
||||
Map<String, Object> p = new LinkedHashMap<>();
|
||||
p.put("name", ann.name());
|
||||
p.put("in", ann.in());
|
||||
p.put("required", ann.required());
|
||||
if (!ann.description().isEmpty()) p.put("description", ann.description());
|
||||
Map<String, Object> schema = new LinkedHashMap<>();
|
||||
schema.put("type", ann.type());
|
||||
if (!ann.example().isEmpty()) schema.put("example", ann.example());
|
||||
p.put("schema", schema);
|
||||
params.add(p);
|
||||
}
|
||||
|
||||
if (!params.isEmpty()) op.put("parameters", params);
|
||||
}
|
||||
|
||||
private void buildResponses(Map<String, Object> op, Class<?> cls) {
|
||||
ApiResponse[] annotations = cls.getAnnotationsByType(ApiResponse.class);
|
||||
Map<String, Object> responses = new LinkedHashMap<>();
|
||||
|
||||
if (annotations.length == 0) {
|
||||
responses.put("200", Map.of("description", "OK"));
|
||||
} else {
|
||||
for (ApiResponse ann : annotations) {
|
||||
Map<String, Object> r = new LinkedHashMap<>();
|
||||
r.put("description", ann.description().isEmpty() ? httpPhrase(ann.status()) : ann.description());
|
||||
if (ann.schema() != Void.class) {
|
||||
r.put("content", Map.of(
|
||||
"application/json", Map.of(
|
||||
"schema", Map.of("$ref", "#/components/schemas/" + ann.schema().getSimpleName()))));
|
||||
}
|
||||
responses.put(String.valueOf(ann.status()), r);
|
||||
}
|
||||
}
|
||||
op.put("responses", responses);
|
||||
}
|
||||
|
||||
/** Converts Flash path params ({id}) to OpenAPI path params ({id}) — already compatible. */
|
||||
private static String normalizePath(String path) {
|
||||
return path.startsWith("/") ? path : "/" + path;
|
||||
}
|
||||
|
||||
private static String httpPhrase(int status) {
|
||||
return switch (status) {
|
||||
case 200 -> "OK";
|
||||
case 201 -> "Created";
|
||||
case 204 -> "No Content";
|
||||
case 400 -> "Bad Request";
|
||||
case 401 -> "Unauthorized";
|
||||
case 403 -> "Forbidden";
|
||||
case 404 -> "Not Found";
|
||||
case 409 -> "Conflict";
|
||||
case 422 -> "Unprocessable Entity";
|
||||
case 500 -> "Internal Server Error";
|
||||
default -> "";
|
||||
};
|
||||
}
|
||||
|
||||
private static List<Map<String, List<String>>> buildOperationSecurity(
|
||||
List<OpenApiSecurityContributor> contributors, Class<?> handlerClass) {
|
||||
List<Map<String, List<String>>> security = new ArrayList<>();
|
||||
for (OpenApiSecurityContributor c : contributors) {
|
||||
List<String> scopes = c.requiredFor(handlerClass);
|
||||
if (scopes != null) {
|
||||
security.add(Map.of(c.schemeName(), scopes));
|
||||
}
|
||||
}
|
||||
return security;
|
||||
}
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package dev.relism.ext.openapi;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
|
||||
import dev.relism.extension.ExtensionContext;
|
||||
import dev.relism.extension.FlashApp;
|
||||
import dev.relism.extension.FlashExtension;
|
||||
import dev.relism.http.ContentType;
|
||||
import dev.relism.routing.Route;
|
||||
|
||||
/**
|
||||
* Generates and serves an OpenAPI 3.0 spec and Swagger UI under a configurable base path.
|
||||
*
|
||||
* <p>Given {@code basePath = "/openapi"} (the default), three routes are registered:
|
||||
* <ul>
|
||||
* <li>{@code GET /openapi.json} — OpenAPI 3.0 spec as JSON</li>
|
||||
* <li>{@code GET /openapi.yaml} — OpenAPI 3.0 spec as YAML</li>
|
||||
* <li>{@code GET /openapi/swagger} — Swagger UI pointing at {@code /openapi.json}</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>Requires</b> {@code flash-ext-jackson} to be installed first (shares its
|
||||
* {@link ObjectMapper}). The YAML endpoint uses its own {@link YAMLMapper} instance.
|
||||
*
|
||||
* <p>Operations are collected automatically from handlers annotated with
|
||||
* {@link ApiOperation} as they are registered via {@link FlashApp#register}.
|
||||
*
|
||||
* <pre>{@code
|
||||
* FlashApp.of(new HttpServer(config))
|
||||
* .install(new JacksonExtension())
|
||||
* .install(new OpenApiExtension("/openapi", "My API", "2.0.0"))
|
||||
* .register(new BlogHandlers.Index())
|
||||
* .start();
|
||||
* }</pre>
|
||||
*/
|
||||
public class OpenApiExtension implements FlashExtension {
|
||||
|
||||
private static final String YAML_CONTENT_TYPE = "application/yaml";
|
||||
|
||||
private final String basePath;
|
||||
private final String title;
|
||||
private final String version;
|
||||
private final String description;
|
||||
|
||||
public OpenApiExtension() {
|
||||
this("/openapi", "API", "1.0.0", "");
|
||||
}
|
||||
|
||||
public OpenApiExtension(String basePath) {
|
||||
this(basePath, "API", "1.0.0", "");
|
||||
}
|
||||
|
||||
public OpenApiExtension(String basePath, String title, String version) {
|
||||
this(basePath, title, version, "");
|
||||
}
|
||||
|
||||
public OpenApiExtension(String basePath, String title, String version, String description) {
|
||||
this.basePath = basePath.endsWith("/") ? basePath.substring(0, basePath.length() - 1) : basePath;
|
||||
this.title = title;
|
||||
this.version = version;
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void install(FlashApp app, ExtensionContext ctx) {
|
||||
ObjectMapper jsonMapper = ctx.require(ObjectMapper.class);
|
||||
YAMLMapper yamlMapper = new YAMLMapper();
|
||||
|
||||
OpenApiBuilder builder = new OpenApiBuilder()
|
||||
.title(title)
|
||||
.version(version)
|
||||
.description(description);
|
||||
|
||||
OpenApiSecurityRegistry secRegistry = new OpenApiSecurityRegistry();
|
||||
ctx.provide(OpenApiSecurityRegistry.class, secRegistry);
|
||||
builder.setSecurityRegistry(secRegistry);
|
||||
|
||||
ctx.provide(OpenApiBuilder.class, builder);
|
||||
|
||||
// Collect operation metadata at handler-registration time (no middleware injected)
|
||||
ctx.addAnnotationProcessor(handlerClass -> {
|
||||
ApiOperation op = handlerClass.getAnnotation(ApiOperation.class);
|
||||
Route route = handlerClass.getAnnotation(Route.class);
|
||||
if (op != null && route != null) {
|
||||
builder.addOperation(route, op, handlerClass);
|
||||
}
|
||||
return java.util.List.of();
|
||||
});
|
||||
|
||||
String jsonPath = basePath + ".json";
|
||||
String yamlPath = basePath + ".yaml";
|
||||
String swaggerPath = basePath + "/swagger";
|
||||
|
||||
// JSON spec
|
||||
app.get(jsonPath, (req, res) -> {
|
||||
res.setContentType(ContentType.JSON);
|
||||
return jsonMapper.writeValueAsString(builder.build());
|
||||
}).with();
|
||||
|
||||
// YAML spec
|
||||
app.get(yamlPath, (req, res) -> {
|
||||
res.type(YAML_CONTENT_TYPE);
|
||||
return yamlMapper.writeValueAsString(builder.build());
|
||||
}).with();
|
||||
|
||||
// Swagger UI — loads from CDN, points at the JSON spec
|
||||
String swaggerHtml = buildSwaggerHtml(jsonPath);
|
||||
app.get(swaggerPath, (req, res) -> {
|
||||
res.setContentType(ContentType.TEXT_HTML);
|
||||
return swaggerHtml;
|
||||
}).with();
|
||||
}
|
||||
|
||||
// ── Swagger UI HTML ───────────────────────────────────────────────────────
|
||||
|
||||
private static String buildSwaggerHtml(String specJsonPath) {
|
||||
return "<!DOCTYPE html>\n" +
|
||||
"<html lang=\"en\">\n" +
|
||||
"<head>\n" +
|
||||
" <meta charset=\"UTF-8\">\n" +
|
||||
" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n" +
|
||||
" <title>Swagger UI</title>\n" +
|
||||
" <link rel=\"stylesheet\" href=\"https://unpkg.com/swagger-ui-dist@5/swagger-ui.css\">\n" +
|
||||
"</head>\n" +
|
||||
"<body>\n" +
|
||||
"<div id=\"swagger-ui\"></div>\n" +
|
||||
"<script src=\"https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js\"></script>\n" +
|
||||
"<script>\n" +
|
||||
"SwaggerUIBundle({\n" +
|
||||
" url: \"" + specJsonPath + "\",\n" +
|
||||
" dom_id: '#swagger-ui',\n" +
|
||||
" deepLinking: true,\n" +
|
||||
" presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],\n" +
|
||||
" layout: \"BaseLayout\"\n" +
|
||||
"});\n" +
|
||||
"</script>\n" +
|
||||
"</body>\n" +
|
||||
"</html>";
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package dev.relism.ext.openapi;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Pluggable security scheme contributor for the OpenAPI spec.
|
||||
*
|
||||
* <p>Extensions that enforce authentication (e.g. {@code flash-ext-oidc}) implement
|
||||
* this interface and register an instance into {@link OpenApiSecurityRegistry} via the
|
||||
* {@link dev.relism.extension.ExtensionContext}. {@link OpenApiExtension} picks it up
|
||||
* at spec-generation time — no coupling between the two extensions at install time.
|
||||
*
|
||||
* <p>Multi-tenant: multiple contributors may coexist. For handlers secured by
|
||||
* {@code @Authenticated}/{@code @RolesAllowed}, each matching contributor adds its
|
||||
* own entry to the operation's {@code security} array (OpenAPI OR semantics).
|
||||
*/
|
||||
public interface OpenApiSecurityContributor {
|
||||
|
||||
/**
|
||||
* Unique scheme name used as a key in {@code components.securitySchemes}
|
||||
* and referenced from each operation's {@code security} array.
|
||||
*/
|
||||
String schemeName();
|
||||
|
||||
/**
|
||||
* The OpenAPI security scheme definition object placed under
|
||||
* {@code components.securitySchemes.<schemeName>}.
|
||||
*
|
||||
* <p>Example for OIDC:
|
||||
* <pre>{@code
|
||||
* Map.of("type", "openIdConnect",
|
||||
* "openIdConnectUrl", "https://idp.example.com/.well-known/openid-configuration")
|
||||
* }</pre>
|
||||
*/
|
||||
Map<String, Object> schemeDefinition();
|
||||
|
||||
/**
|
||||
* Returns the scopes/roles required for the given handler class under this scheme,
|
||||
* or {@code null} if this contributor does not secure the handler.
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code null} — handler is not secured by this contributor (skip)</li>
|
||||
* <li>empty list — handler requires authentication, no specific scopes</li>
|
||||
* <li>non-empty list — handler requires these scopes/roles</li>
|
||||
* </ul>
|
||||
*/
|
||||
List<String> requiredFor(Class<?> handlerClass);
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package dev.relism.ext.openapi;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
/**
|
||||
* Mutable registry of {@link OpenApiSecurityContributor}s.
|
||||
*
|
||||
* <p>Created and provided to the {@link dev.relism.extension.ExtensionContext} by
|
||||
* {@link OpenApiExtension} at install time. Other extensions (e.g. {@code flash-ext-oidc})
|
||||
* retrieve it via {@code ctx.find(OpenApiSecurityRegistry.class)} and register their
|
||||
* contributor — the OpenAPI extension then picks it up lazily at spec-generation time.
|
||||
*
|
||||
* <p>Thread-safe: {@link CopyOnWriteArrayList} allows concurrent reads during spec
|
||||
* generation without blocking registration.
|
||||
*/
|
||||
public final class OpenApiSecurityRegistry {
|
||||
|
||||
private final List<OpenApiSecurityContributor> contributors = new CopyOnWriteArrayList<>();
|
||||
|
||||
/** Registers a contributor. Safe to call concurrently. */
|
||||
public void add(OpenApiSecurityContributor contributor) {
|
||||
contributors.add(contributor);
|
||||
}
|
||||
|
||||
/** Returns an unmodifiable snapshot of all registered contributors. */
|
||||
public List<OpenApiSecurityContributor> contributors() {
|
||||
return Collections.unmodifiableList(contributors);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-parent</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
<packaging>pom</packaging>
|
||||
|
||||
<modules>
|
||||
<module>flash-ext-jackson</module>
|
||||
<module>flash-ext-openapi</module>
|
||||
<module>flash-ext-oidc</module>
|
||||
</modules>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-jackson</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
<version>2.17.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.dataformat</groupId>
|
||||
<artifactId>jackson-dataformat-yaml</artifactId>
|
||||
<version>2.17.2</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.nimbusds</groupId>
|
||||
<artifactId>nimbus-jose-jwt</artifactId>
|
||||
<version>9.37.3</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>net.minidev</groupId>
|
||||
<artifactId>json-smart</artifactId>
|
||||
<version>2.5.1</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
</project>
|
||||
Reference in New Issue
Block a user