Compare commits
36
Commits
d3fe740e05
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ece97ca7b | ||
|
|
5fe6fb46c8 | ||
|
|
c0c9480fa5 | ||
|
|
9090ba59f5 | ||
|
|
6a6431c528 | ||
|
|
491553e9f5 | ||
|
|
dd0455c6d1 | ||
|
|
60dd4eab6a | ||
|
|
2f06ca7c1d | ||
|
|
2fbe65fcc5 | ||
|
|
adcd6376b6 | ||
|
|
ef4740f26d | ||
|
|
8353033cb1 | ||
|
|
fd96d67fca | ||
|
|
003fd6d1f0 | ||
|
|
680bbca8c7 | ||
|
|
580417e952 | ||
|
|
d6c018242f | ||
|
|
6d44f9e7b1 | ||
|
|
f28fc43150 | ||
|
|
7f225e0faf | ||
|
|
86fafdb3d8 | ||
|
|
e0795299fc | ||
|
|
017c2443f4 | ||
|
|
b30a4af1d6 | ||
|
|
096098b33c | ||
|
|
829b9bf348 | ||
|
|
9d39e24ccb | ||
|
|
c5be6ac7b8 | ||
|
|
ea00182c7c | ||
|
|
9892ad44b7 | ||
|
|
4866624e73 | ||
|
|
4feabc45d5 | ||
|
|
9de2800a83 | ||
|
|
dbf057f493 | ||
|
|
5c163b7f8d |
Generated
+4
-4
@@ -17,8 +17,8 @@
|
||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-jackson/src/main/resources" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/java" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-limiter/src/main/resources" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/java" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-oidc/src/main/resources" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-security-oidc/src/main/java" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-security-oidc/src/main/resources" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/java" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-openapi/src/main/resources" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java" charset="UTF-8" />
|
||||
@@ -31,8 +31,8 @@
|
||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view-thymeleaf/src/main/resources" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/java" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-view/src/main/resources" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/java" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-web-bundler/src/main/resources" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-vite/src/main/java" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-vite/src/main/resources" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/flash-extensions/src/main/java" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/flash-extensions/src/main/resources" charset="UTF-8" />
|
||||
<file url="file://$PROJECT_DIR$/flash/src/main/java" charset="UTF-8" />
|
||||
|
||||
@@ -37,8 +37,8 @@ Format: `<type>(<scope>): <short description>`
|
||||
| `ci` | Changes to GitHub Actions workflows |
|
||||
|
||||
Allowed scopes: `core`, `testing`, `ext-jackson`, `ext-openapi`, `ext-oidc`, `ext-routeviewer`,
|
||||
`ext-view-core`, `ext-view-jte`, `ext-view-thymeleaf`, `ext-limiter`, `ext-web-bundler`,
|
||||
`ext-mcp`, `ext-validation`, `ext-data-core`, `ext-data-jdbc`, `ext-data-hibernate`,
|
||||
`ext-view-core`, `ext-view-jte`, `ext-view-thymeleaf`, `ext-limiter`, `ext-vite`,
|
||||
`ext-mcp`, `ext-validation`, `ext-scheduler`, `ext-data-core`, `ext-data-jdbc`, `ext-data-hibernate`,
|
||||
`ext-cache-core`, `ext-cache-caffeine`, `release`, `deps`, `ci`.
|
||||
|
||||
Examples:
|
||||
|
||||
@@ -9,14 +9,22 @@ a zero-allocation FSM router, bounded protocol state, and one shared request/res
|
||||
|---|---|
|
||||
| `flash` | Core server library — HTTP/1.1 and HTTP/2 transport, router, request/response model |
|
||||
| `flash-testing` | JUnit 5 harness — boot an app on an ephemeral port, fake its services, assert on responses |
|
||||
| `flash-extensions/flash-ext-jackson` | Jackson JSON integration |
|
||||
| `flash-extensions/flash-ext-jackson-core` | What every Jackson format shares: the codec, the body handler, the constraints a body is checked against |
|
||||
| `flash-extensions/flash-ext-jackson-json` | JSON bodies and responses |
|
||||
| `flash-extensions/flash-ext-jackson-xml` | XML bodies and responses |
|
||||
| `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI |
|
||||
| `flash-extensions/flash-ext-oidc` | OIDC Authorization Code + PKCE flow |
|
||||
| `flash-extensions/flash-ext-mcp` | MCP (Model Context Protocol) server — Streamable HTTP, optional OAuth2 via flash-ext-oidc |
|
||||
| `flash-extensions/flash-ext-security-core` | Security: authentication chain, annotations, sessions, OpenAPI |
|
||||
| `flash-extensions/flash-ext-security-oidc` | OpenID Connect: bearer tokens, code flow + PKCE |
|
||||
| `flash-extensions/flash-ext-security-apikey` | API keys |
|
||||
| `flash-extensions/flash-ext-security-form` | Password sign-in |
|
||||
| `flash-extensions/flash-ext-security-oauth-server` | OAuth 2.1 authorization server for the application's own users and resources |
|
||||
| `flash-extensions/flash-ext-security-test` | Test identities, fake OpenID Provider |
|
||||
| `flash-extensions/flash-ext-mcp` | MCP (Model Context Protocol) server — Streamable HTTP, secured by flash-ext-security-core |
|
||||
| `flash-extensions/flash-ext-view-core` | Minimal shared SSR runtime primitives |
|
||||
| `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension |
|
||||
| `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension |
|
||||
| `flash-extensions/flash-ext-validation` | Request validation — jakarta constraints, compiled once per type |
|
||||
| `flash-extensions/flash-ext-vite` | Vite frontend: dev server in DEV, the built SPA from the jar otherwise |
|
||||
| `flash-extensions/flash-ext-vite-maven-plugin` | Builds the Vite frontend into the jar during `mvn package` |
|
||||
| `flash-extensions/flash-ext-scheduler` | Interval and cron background jobs on virtual threads |
|
||||
| `flash-extensions/flash-ext-cache-core` | Caching contract — `Cache`, `CacheManager`, `CacheSpec` |
|
||||
| `flash-extensions/flash-ext-cache-caffeine` | In-process cache backed by Caffeine |
|
||||
@@ -144,13 +152,18 @@ FlashApp.create(8080)
|
||||
```
|
||||
|
||||
See extension-specific READMEs for full details:
|
||||
- [`flash-ext-jackson`](flash-extensions/flash-ext-jackson/README.md)
|
||||
- [`flash-ext-jackson-core`](flash-extensions/flash-ext-jackson-core/README.md)
|
||||
- [`flash-ext-jackson-json`](flash-extensions/flash-ext-jackson-json/README.md)
|
||||
- [`flash-ext-jackson-xml`](flash-extensions/flash-ext-jackson-xml/README.md)
|
||||
- [`flash-ext-openapi`](flash-extensions/flash-ext-openapi/README.md)
|
||||
- [`flash-ext-oidc`](flash-extensions/flash-ext-oidc/README.md)
|
||||
- [`flash-ext-security-core`](flash-extensions/flash-ext-security-core/docs/README.md)
|
||||
- [`flash-ext-security-oidc`](flash-extensions/flash-ext-security-oidc/docs/README.md)
|
||||
- [`flash-ext-security-apikey`](flash-extensions/flash-ext-security-apikey/docs/README.md)
|
||||
- [`flash-ext-security-form`](flash-extensions/flash-ext-security-form/docs/README.md)
|
||||
- [`flash-ext-security-test`](flash-extensions/flash-ext-security-test/docs/README.md)
|
||||
- [`flash-ext-mcp`](flash-extensions/flash-ext-mcp/docs/README.md)
|
||||
- [`flash-ext-view-jte`](flash-extensions/flash-ext-view-jte/README.md)
|
||||
- [`flash-ext-view-thymeleaf`](flash-extensions/flash-ext-view-thymeleaf/README.md)
|
||||
- [`flash-ext-validation`](flash-extensions/flash-ext-validation/docs/README.md)
|
||||
- [`flash-ext-scheduler`](flash-extensions/flash-ext-scheduler/docs/README.md)
|
||||
- [`flash-ext-cache-caffeine`](flash-extensions/flash-ext-cache-caffeine/docs/README.md)
|
||||
- [`flash-testing`](flash-testing/docs/README.md)
|
||||
|
||||
@@ -112,6 +112,8 @@ public abstract class Repository<T, ID> {
|
||||
- `Tx` in the `FlashContext`
|
||||
- `TxManager` in the `FlashContext`
|
||||
- an annotation processor for `@Transactional`
|
||||
- `TxManager.close()` as an `onClose` callback, so stopping the app releases the manager's
|
||||
session factory and connection pool; give the manager a pool you want closed with the app
|
||||
|
||||
This makes the data layer composable with Flash's extension system without global state.
|
||||
|
||||
|
||||
+1
@@ -34,6 +34,7 @@ public final class DataExtension implements FlashExtension {
|
||||
|
||||
@Override
|
||||
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
ctx.onClose(txManager::close);
|
||||
ctx.provide(Tx.class, tx);
|
||||
ctx.provide(TxManager.class, txManager);
|
||||
if (data != null) ctx.provide(Data.class, data);
|
||||
|
||||
+5
-1
@@ -1,7 +1,11 @@
|
||||
package dev.relism.flash.ext.data.core;
|
||||
|
||||
public interface TxManager {
|
||||
public interface TxManager extends AutoCloseable {
|
||||
TxStatus begin(TxDefinition definition);
|
||||
void commit(TxStatus status);
|
||||
void rollback(TxStatus status);
|
||||
|
||||
/** Releases what this manager was built on, its connection pool included. {@link dev.relism.flash.ext.data.DataExtension} calls it when the app stops. */
|
||||
@Override
|
||||
void close();
|
||||
}
|
||||
|
||||
+22
@@ -3,6 +3,10 @@ package dev.relism.flash.ext.data.hibernate;
|
||||
import dev.relism.flash.ext.data.core.*;
|
||||
import org.hibernate.Session;
|
||||
import org.hibernate.SessionFactory;
|
||||
import org.hibernate.engine.jdbc.connections.spi.ConnectionProvider;
|
||||
import org.hibernate.engine.spi.SessionFactoryImplementor;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
@@ -16,6 +20,24 @@ public class HibernateTxManager implements TxManager {
|
||||
this.sf = Objects.requireNonNull(sessionFactory);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the session factory, then the data source it was given ({@code jakarta.persistence.nonJtaDataSource}
|
||||
* or {@code hibernate.connection.datasource}): Hibernate stops a pool it built itself, never one handed to it.
|
||||
*/
|
||||
@Override
|
||||
public void close() {
|
||||
ConnectionProvider connections = sf.unwrap(SessionFactoryImplementor.class).getServiceRegistry().getService(ConnectionProvider.class);
|
||||
DataSource ds = connections != null && connections.isUnwrappableAs(DataSource.class) ? connections.unwrap(DataSource.class) : null;
|
||||
sf.close();
|
||||
if (ds instanceof AutoCloseable closeable) {
|
||||
try {
|
||||
closeable.close();
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to close the data source", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public TxStatus begin(TxDefinition definition) {
|
||||
return switch (definition.propagation()) {
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package dev.relism.flash.ext.data.hibernate;
|
||||
|
||||
import org.hibernate.SessionFactory;
|
||||
import org.hibernate.boot.MetadataSources;
|
||||
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
|
||||
import org.hibernate.cfg.AvailableSettings;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.io.PrintWriter;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.SQLException;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class HibernateTxManagerCloseTest {
|
||||
|
||||
/** Stands in for a pool: closeable, and it records being closed. */
|
||||
static final class Pool implements DataSource, AutoCloseable {
|
||||
boolean closed;
|
||||
@Override public Connection getConnection() throws SQLException { return DriverManager.getConnection("jdbc:h2:mem:tx-close;DB_CLOSE_DELAY=-1"); }
|
||||
@Override public Connection getConnection(String user, String password) throws SQLException { return getConnection(); }
|
||||
@Override public void close() { closed = true; }
|
||||
@Override public <T> T unwrap(Class<T> type) { throw new UnsupportedOperationException(); }
|
||||
@Override public boolean isWrapperFor(Class<?> type) { return false; }
|
||||
@Override public PrintWriter getLogWriter() { return null; }
|
||||
@Override public void setLogWriter(PrintWriter out) {}
|
||||
@Override public void setLoginTimeout(int seconds) {}
|
||||
@Override public int getLoginTimeout() { return 0; }
|
||||
@Override public Logger getParentLogger() { throw new UnsupportedOperationException(); }
|
||||
}
|
||||
|
||||
@Test
|
||||
void closingTheManagerClosesTheDataSourceHibernateWasGiven() {
|
||||
Pool pool = new Pool();
|
||||
SessionFactory sf = new MetadataSources(new StandardServiceRegistryBuilder()
|
||||
.applySetting(AvailableSettings.JAKARTA_NON_JTA_DATASOURCE, pool)
|
||||
.applySetting(AvailableSettings.DIALECT, "org.hibernate.dialect.H2Dialect")
|
||||
.build()).buildMetadata().buildSessionFactory();
|
||||
|
||||
new HibernateTxManager(sf).close();
|
||||
|
||||
assertTrue(sf.isClosed());
|
||||
assertTrue(pool.closed);
|
||||
}
|
||||
}
|
||||
+12
@@ -17,6 +17,18 @@ public class JdbcTxManager implements TxManager {
|
||||
this.ds = Objects.requireNonNull(ds);
|
||||
}
|
||||
|
||||
/** Closes the data source when it is closeable, as a pool is; a plain {@code DataSource} holds nothing to release. */
|
||||
@Override
|
||||
public void close() {
|
||||
if (ds instanceof AutoCloseable closeable) {
|
||||
try {
|
||||
closeable.close();
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to close the data source", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public TxStatus begin(TxDefinition definition) {
|
||||
return switch (definition.propagation()) {
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package dev.relism.flash.ext.data.jdbc;
|
||||
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import dev.relism.flash.ext.data.DataExtension;
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class JdbcTxManagerCloseTest {
|
||||
|
||||
/** A pool outlives its app unless someone closes it; the data extension does, once requests have drained. */
|
||||
@Test
|
||||
void stoppingTheAppClosesThePool() {
|
||||
HikariDataSource pool = new HikariDataSource();
|
||||
pool.setJdbcUrl(TestDataSource.URL);
|
||||
FlashApp.create(0).install(new DataExtension(new JdbcTxManager(pool))).start().stop().join();
|
||||
assertTrue(pool.isClosed());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
# flash-ext-jackson-core
|
||||
|
||||
What every Jackson data format shares. Applications do not install this module directly: they
|
||||
install a format — [`flash-ext-jackson-json`](../flash-ext-jackson-json),
|
||||
[`flash-ext-jackson-xml`](../flash-ext-jackson-xml) — and get all of this with it.
|
||||
|
||||
## Why there is a core at all
|
||||
|
||||
Every Jackson data format is the same databind model behind a different factory: `XmlMapper`,
|
||||
`YAMLMapper` and `CBORMapper` are all `ObjectMapper`s. So the annotations on a type, the
|
||||
constraints its fields declare and the schema it publishes are the same whatever writes it. Only
|
||||
the mapper and the content type differ, and that is all a format module has to say.
|
||||
|
||||
## Codec
|
||||
|
||||
One mapper, in the shape a handler needs it.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `body(Request, Class<T>)` | Parses the body and verifies its constraints |
|
||||
| `write(Response, Object)` | Serializes and sets the format's content type |
|
||||
| `writeView(Response, Object, Class<?>)` | The same, through a Jackson `@JsonView` |
|
||||
| `mapper()` | The `ObjectMapper` itself, for everything else |
|
||||
|
||||
`body` reads straight off the request's stream, which Flash reuses per connection: the body is
|
||||
never buffered into an array to be handed over. A malformed body is a 400, a body that breaks a
|
||||
constraint is a 422, and neither reaches the handler.
|
||||
|
||||
## Constraints
|
||||
|
||||
A body is checked against the `jakarta.validation` annotations its own type declares — nothing to
|
||||
install, nothing to call:
|
||||
|
||||
```java
|
||||
public record NewUser(@NotBlank @Size(max = 80) String name, @Email String email, @Min(18) int age) {}
|
||||
```
|
||||
|
||||
Supported: `@NotNull`, `@NotBlank`, `@NotEmpty`, `@Size`, `@Min`, `@Max`, `@Email`, `@Pattern`.
|
||||
Jakarta semantics: only `@NotNull` rejects null, every other constraint passes it.
|
||||
|
||||
A failure reads `<field> <message>`, and the message is the constraint's own when it sets one —
|
||||
which is how the person who sent the request is told something better than a regex:
|
||||
|
||||
```java
|
||||
@Pattern(regexp = "[A-Za-z0-9_][A-Za-z0-9_.-]{0,254}", message = "uses up to 255 letters, digits, _, . and -")
|
||||
String key
|
||||
```
|
||||
|
||||
```json
|
||||
{"error": "key uses up to 255 letters, digits, _, . and -", "status": 422}
|
||||
```
|
||||
|
||||
The constraints of a type are compiled the first time it is seen and kept in a `ClassValue`,
|
||||
beside the class itself — no map, no lock. A check reads the field through an exact-signature
|
||||
`MethodHandle`: no boxing, no argument array, no iterator, and nothing allocated at all unless
|
||||
something fails. A type that declares no constraints compiles to a validator that does nothing.
|
||||
|
||||
Verify a value built by hand with `Validator.check(value)`.
|
||||
|
||||
`flash-ext-openapi` reads the same annotations to publish `minLength`, `maximum`, `pattern` and
|
||||
the required fields, so a rule is written once and both enforced and documented.
|
||||
|
||||
## Writing a format module
|
||||
|
||||
```java
|
||||
public final class Yaml extends Codec {
|
||||
public Yaml(YAMLMapper mapper) { super(mapper, ContentType.TEXT_YAML); }
|
||||
}
|
||||
|
||||
@Consumes(ContentType.TEXT_YAML)
|
||||
public abstract class YamlHandler<B> extends JacksonHandler<B> {
|
||||
@Inject private Yaml yaml;
|
||||
@Override protected Codec codec() { return yaml; }
|
||||
}
|
||||
```
|
||||
|
||||
Plus an extension that provides the codec and, for outbound bodies,
|
||||
`Marshalling.of(mapper, contentType)`. That is the whole of it.
|
||||
@@ -0,0 +1,47 @@
|
||||
<?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>2.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-jackson-core</artifactId>
|
||||
<name>flash-ext-jackson-core</name>
|
||||
<description>What every Jackson data format shares: the codec, the body handler and the constraints a body is checked against.</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-jsr310</artifactId>
|
||||
</dependency>
|
||||
<!-- The constraint annotations a body is checked against; no implementation, no transitives. -->
|
||||
<dependency>
|
||||
<groupId>jakarta.validation</groupId>
|
||||
<artifactId>jakarta.validation-api</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package dev.relism.flash.ext.validation;
|
||||
package dev.relism.flash.ext.jackson;
|
||||
|
||||
import java.lang.invoke.MethodHandle;
|
||||
import java.util.regex.Pattern;
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package dev.relism.flash.ext.jackson;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.relism.flash.exceptions.HttpException;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.Response;
|
||||
|
||||
/**
|
||||
* One Jackson mapper, in the shape a handler needs it.
|
||||
*
|
||||
* <p>Every Jackson data format is the same databind model behind a different factory, so this is
|
||||
* the whole of what a format module has to say: which mapper, and which content type it writes.
|
||||
* What the annotations mean, what a body is checked against and how a type is described are the
|
||||
* same for all of them.
|
||||
*
|
||||
* <p>Retrieve it once at boot — {@code @Inject private Json json;} — and call it on the hot path.
|
||||
* The underlying {@link ObjectMapper} is thread-safe once configured.
|
||||
*/
|
||||
public abstract class Codec {
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
private final ContentType contentType;
|
||||
|
||||
protected Codec(ObjectMapper mapper, ContentType contentType) {
|
||||
this.mapper = mapper;
|
||||
this.contentType = contentType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the request body as {@code type} and verifies its constraints.
|
||||
*
|
||||
* <p>Read straight off the request's stream, which Flash reuses per connection: nothing
|
||||
* buffers the body to hand it over. A type that declares no constraints is not checked at all.
|
||||
*
|
||||
* @throws HttpException 400 if the body cannot be parsed as {@code type}
|
||||
* @throws ValidationException 422 if it parses but violates a constraint
|
||||
*/
|
||||
public <T> T body(Request request, Class<T> type) throws Exception {
|
||||
T value;
|
||||
try {
|
||||
value = mapper.readValue(request.body().stream(), type);
|
||||
} catch (JsonProcessingException malformed) {
|
||||
throw HttpException.badRequest("Invalid request body: " + malformed.getOriginalMessage());
|
||||
}
|
||||
Validator.of(type).verify(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Serializes {@code value} and sets this codec's content type on the response. */
|
||||
public String write(Response response, Object value) throws Exception {
|
||||
response.type(contentType);
|
||||
return mapper.writeValueAsString(value);
|
||||
}
|
||||
|
||||
/** Like {@link #write}, restricted to the fields visible under a Jackson {@code @JsonView}. */
|
||||
public String writeView(Response response, Object value, Class<?> view) throws Exception {
|
||||
response.type(contentType);
|
||||
return mapper.writerWithView(view).writeValueAsString(value);
|
||||
}
|
||||
|
||||
/** What this codec writes, for a handler that sets the response type itself. */
|
||||
public ContentType contentType() {
|
||||
return contentType;
|
||||
}
|
||||
|
||||
/** The mapper itself, for everything these methods do not cover. */
|
||||
public ObjectMapper mapper() {
|
||||
return mapper;
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package dev.relism.flash.ext.jackson;
|
||||
|
||||
import dev.relism.flash.models.BodyHandler;
|
||||
import dev.relism.flash.models.Request;
|
||||
|
||||
/**
|
||||
* A handler whose body one Jackson format parses and whose constraints are checked before it
|
||||
* arrives.
|
||||
*
|
||||
* <p>Format modules extend this and name their codec — {@code JsonHandler}, {@code XmlHandler}.
|
||||
* An application extends those, never this one.
|
||||
*
|
||||
* @param <B> the body type, which is also what the published OpenAPI document describes
|
||||
*/
|
||||
public abstract class JacksonHandler<B> extends BodyHandler<B> {
|
||||
|
||||
private final Class<B> type = bodyType();
|
||||
|
||||
protected JacksonHandler() {
|
||||
if (type == null) {
|
||||
throw new IllegalStateException(getClass().getSimpleName()
|
||||
+ " extends a body handler without naming its body type — write it as Handler<YourBody>");
|
||||
}
|
||||
}
|
||||
|
||||
/** The format this handler speaks. Injected by the subclass, resolved once at boot. */
|
||||
protected abstract Codec codec();
|
||||
|
||||
@Override
|
||||
protected final B body(Request request) throws Exception {
|
||||
return codec().body(request, type);
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package dev.relism.flash.ext.jackson;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.models.Response;
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
|
||||
/**
|
||||
* Turns whatever a handler returns into a serialized body.
|
||||
*
|
||||
* <p>Pass-through for what is already a response: {@code null}, a {@link Response}, a
|
||||
* {@code byte[]} or a {@link CharSequence}. Everything else is serialized straight to bytes.
|
||||
*/
|
||||
public final class Marshalling {
|
||||
|
||||
private Marshalling() {}
|
||||
|
||||
public static Middleware of(ObjectMapper mapper, ContentType contentType) {
|
||||
return next -> (req, res) -> {
|
||||
Object out = next.handle(req, res);
|
||||
if (out == null || out instanceof Response || out instanceof byte[] || out instanceof CharSequence) return out;
|
||||
|
||||
res.type(contentType);
|
||||
try {
|
||||
return mapper.writeValueAsBytes(out);
|
||||
} catch (JsonProcessingException failure) {
|
||||
throw new IllegalStateException("Could not serialize " + out.getClass().getName(), failure);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package dev.relism.flash.ext.validation;
|
||||
package dev.relism.flash.ext.jackson;
|
||||
|
||||
import dev.relism.flash.exceptions.HttpException;
|
||||
|
||||
@@ -14,7 +14,7 @@ public final class ValidationException extends HttpException {
|
||||
|
||||
private final transient List<Violation> violations;
|
||||
|
||||
ValidationException(List<Violation> violations) {
|
||||
public ValidationException(List<Violation> violations) {
|
||||
super(422, describe(violations));
|
||||
this.violations = List.copyOf(violations);
|
||||
}
|
||||
+51
-12
@@ -1,4 +1,4 @@
|
||||
package dev.relism.flash.ext.validation;
|
||||
package dev.relism.flash.ext.jackson;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.Max;
|
||||
@@ -31,12 +31,38 @@ public final class Validator {
|
||||
|
||||
private static final Check[] NONE = new Check[0];
|
||||
|
||||
/**
|
||||
* One compiled validator per type, kept beside the class itself: no map lookup, no lock, and
|
||||
* the entry is collected with the class rather than pinning it.
|
||||
*/
|
||||
private static final ClassValue<Validator> COMPILED = new ClassValue<>() {
|
||||
@Override protected Validator computeValue(Class<?> type) {
|
||||
return compile(type);
|
||||
}
|
||||
};
|
||||
|
||||
private final Check[] checks;
|
||||
|
||||
private Validator(Check[] checks) {
|
||||
this.checks = checks;
|
||||
}
|
||||
|
||||
/** The constraints of {@code type}, compiled the first time it is seen and reused after. */
|
||||
public static Validator of(Class<?> type) {
|
||||
return COMPILED.get(type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a value against its own type's constraints.
|
||||
*
|
||||
* @return {@code value}, so it can be used inline
|
||||
* @throws ValidationException 422, listing every failure
|
||||
*/
|
||||
public static <T> T check(T value) {
|
||||
of(value.getClass()).verify(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** True when the type declares no constraints at all — {@link #verify} is then a no-op. */
|
||||
public boolean isEmpty() {
|
||||
return checks.length == 0;
|
||||
@@ -152,25 +178,28 @@ public final class Validator {
|
||||
Class<?> type = field.getType();
|
||||
MethodHandle ref = type.isPrimitive() ? null : asReference(getter);
|
||||
|
||||
if (field.isAnnotationPresent(NotNull.class) && ref != null)
|
||||
checks.add(Check.reference(Check.NOT_NULL, name, "must not be null", ref));
|
||||
NotNull notNull = field.getAnnotation(NotNull.class);
|
||||
if (notNull != null && ref != null)
|
||||
checks.add(Check.reference(Check.NOT_NULL, name, said(notNull.message(), "must not be null"), ref));
|
||||
|
||||
if (field.isAnnotationPresent(NotBlank.class) && ref != null)
|
||||
checks.add(Check.reference(Check.NOT_BLANK, name, "must not be blank", ref));
|
||||
NotBlank notBlank = field.getAnnotation(NotBlank.class);
|
||||
if (notBlank != null && ref != null)
|
||||
checks.add(Check.reference(Check.NOT_BLANK, name, said(notBlank.message(), "must not be blank"), ref));
|
||||
|
||||
if (field.isAnnotationPresent(NotEmpty.class) && ref != null)
|
||||
checks.add(Check.reference(Check.NOT_EMPTY, name, "must not be empty", ref));
|
||||
NotEmpty notEmpty = field.getAnnotation(NotEmpty.class);
|
||||
if (notEmpty != null && ref != null)
|
||||
checks.add(Check.reference(Check.NOT_EMPTY, name, said(notEmpty.message(), "must not be empty"), ref));
|
||||
|
||||
Size size = field.getAnnotation(Size.class);
|
||||
if (size != null && ref != null)
|
||||
checks.add(Check.size(name, sizeMessage(size), ref, size.min(), size.max()));
|
||||
checks.add(Check.size(name, said(size.message(), sizeMessage(size)), ref, size.min(), size.max()));
|
||||
|
||||
Min min = field.getAnnotation(Min.class);
|
||||
Max max = field.getAnnotation(Max.class);
|
||||
if (min != null || max != null) {
|
||||
long lo = min != null ? min.value() : Long.MIN_VALUE;
|
||||
long hi = max != null ? max.value() : Long.MAX_VALUE;
|
||||
String message = rangeMessage(min, max);
|
||||
String message = said(min != null ? min.message() : max.message(), rangeMessage(min, max));
|
||||
if (isIntegralPrimitive(type)) {
|
||||
checks.add(Check.rangePrimitive(name, message, asLong(getter), lo, hi));
|
||||
} else if (Number.class.isAssignableFrom(type) && ref != null) {
|
||||
@@ -178,14 +207,15 @@ public final class Validator {
|
||||
}
|
||||
}
|
||||
|
||||
if (field.isAnnotationPresent(Email.class) && ref != null)
|
||||
checks.add(Check.reference(Check.EMAIL, name, "must be a well-formed email address", ref));
|
||||
Email email = field.getAnnotation(Email.class);
|
||||
if (email != null && ref != null)
|
||||
checks.add(Check.reference(Check.EMAIL, name, said(email.message(), "must be a well-formed email address"), ref));
|
||||
|
||||
Pattern pattern = field.getAnnotation(Pattern.class);
|
||||
if (pattern != null && ref != null) {
|
||||
// ponytail: the one allocating check — Pattern.matcher() per call. The regex itself is
|
||||
// compiled once here; swap for a structural check if a hot route ever needs it.
|
||||
checks.add(Check.pattern(name, "must match " + pattern.regexp(), ref,
|
||||
checks.add(Check.pattern(name, said(pattern.message(), "must match " + pattern.regexp()), ref,
|
||||
java.util.regex.Pattern.compile(pattern.regexp())));
|
||||
}
|
||||
}
|
||||
@@ -202,6 +232,15 @@ public final class Validator {
|
||||
return getter.asType(MethodType.methodType(long.class, Object.class));
|
||||
}
|
||||
|
||||
/**
|
||||
* What a failure says: the constraint's own {@code message} when it sets one, and otherwise a
|
||||
* plain description of the rule. Jakarta's defaults are bundle keys in braces, and a key is
|
||||
* not something to put in front of whoever sent the request.
|
||||
*/
|
||||
private static String said(String message, String otherwise) {
|
||||
return message == null || message.isBlank() || message.startsWith("{") ? otherwise : message;
|
||||
}
|
||||
|
||||
private static String sizeMessage(Size size) {
|
||||
if (size.min() == 0) return "size must be at most " + size.max();
|
||||
if (size.max() == Integer.MAX_VALUE) return "size must be at least " + size.min();
|
||||
+12
-1
@@ -1,4 +1,4 @@
|
||||
package dev.relism.flash.ext.validation;
|
||||
package dev.relism.flash.ext.jackson;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.Max;
|
||||
@@ -114,4 +114,15 @@ class ValidatorTest {
|
||||
assertTrue(validator.isEmpty());
|
||||
assertDoesNotThrow(() -> validator.verify(new Plain(null)));
|
||||
}
|
||||
|
||||
record Keyed(@jakarta.validation.constraints.Pattern(regexp = "[a-z.]+",
|
||||
message = "uses lowercase letters and dots") String key) {}
|
||||
|
||||
@org.junit.jupiter.api.Test
|
||||
void a_constraint_says_what_it_wants_in_its_own_words() {
|
||||
ValidationException refused = org.junit.jupiter.api.Assertions.assertThrows(
|
||||
ValidationException.class, () -> Validator.check(new Keyed("Not A Key")));
|
||||
|
||||
org.junit.jupiter.api.Assertions.assertEquals("key uses lowercase letters and dots", refused.getMessage());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
# flash-ext-jackson-json
|
||||
|
||||
JSON bodies and JSON responses.
|
||||
|
||||
## Install
|
||||
|
||||
```java
|
||||
JsonExtension json = new JsonExtension();
|
||||
|
||||
FlashApp.create(8080)
|
||||
.install(json)
|
||||
.use(json.auto())
|
||||
.scan("com.acme.handlers")
|
||||
.startAndBlock();
|
||||
```
|
||||
|
||||
The default mapper discovers the modules on the classpath (Java Time among them) and writes dates
|
||||
as ISO strings; `new JsonExtension(mapper)` takes one of your own. `auto()` serializes whatever a
|
||||
handler returns, leaving alone what is already a response: `null`, a `Response`, a `byte[]` or a
|
||||
`CharSequence`.
|
||||
|
||||
## A handler with a body
|
||||
|
||||
The body type is the handler's type argument, and that is the whole declaration — it is also what
|
||||
the OpenAPI document describes and what the constraints are read from:
|
||||
|
||||
```java
|
||||
@POST("/users")
|
||||
public final class CreateUser extends JsonHandler<NewUser> {
|
||||
@Inject private UserService users;
|
||||
|
||||
@Override protected Object handle(Request req, Response res, NewUser body) {
|
||||
return users.create(body);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A malformed body never reaches it (400), nor does one that breaks a constraint (422).
|
||||
|
||||
## A handler that reads it itself
|
||||
|
||||
```java
|
||||
public final class Import extends RequestHandler {
|
||||
@Inject private Json json;
|
||||
|
||||
@Override public Object handle(Request req, Response res) throws Exception {
|
||||
return archive.store(json.body(req, Manifest.class));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`Json` is the [`Codec`](../flash-ext-jackson-core) for `application/json`: `body`, `write`,
|
||||
`writeView`, `mapper`.
|
||||
|
||||
## Notes
|
||||
|
||||
- One mapper per application (or per scope), shared by every handler; `ObjectMapper` is
|
||||
thread-safe once configured.
|
||||
- Install `flash-ext-jackson-xml` beside this one when an application speaks both: a route picks
|
||||
its format by the handler it extends, not by negotiation.
|
||||
@@ -0,0 +1,40 @@
|
||||
<?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>2.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-jackson-json</artifactId>
|
||||
<name>flash-ext-jackson-json</name>
|
||||
<description>JSON bodies and responses: the Json codec, JsonHandler and the marshalling middleware.</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-jackson-core</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-testing</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- The constraints a body declares are described by the published document too. -->
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-openapi</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package dev.relism.flash.ext.jackson.json;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.relism.flash.ext.jackson.Codec;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
|
||||
/**
|
||||
* JSON in and out, checked against the body type's own constraints.
|
||||
*
|
||||
* <pre>{@code
|
||||
* public final class CreateItem extends RequestHandler {
|
||||
* @Inject private Json json;
|
||||
*
|
||||
* @Override public Object handle(Request req, Response res) throws Exception {
|
||||
* return items.create(json.body(req, NewItem.class));
|
||||
* }
|
||||
* }
|
||||
* }</pre>
|
||||
*
|
||||
* <p>A handler whose whole body is one type has nothing to write at all: see {@link JsonHandler}.
|
||||
*/
|
||||
public final class Json extends Codec {
|
||||
|
||||
public Json(ObjectMapper mapper) {
|
||||
super(mapper, ContentType.JSON);
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package dev.relism.flash.ext.jackson.json;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||
import dev.relism.flash.ext.jackson.Marshalling;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import dev.relism.flash.extension.FlashExtension;
|
||||
import dev.relism.flash.extension.FlashRegistrar;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
|
||||
/**
|
||||
* JSON for an application: the {@link Json} codec, the mapper behind it, and the middleware that
|
||||
* serializes whatever a handler returns.
|
||||
*
|
||||
* <pre>{@code
|
||||
* JsonExtension json = new JsonExtension();
|
||||
* app.install(json).use(json.auto());
|
||||
* }</pre>
|
||||
*
|
||||
* <p>The default mapper discovers the modules on the classpath (Java Time among them) and writes
|
||||
* dates as ISO strings. Hand it a mapper of your own to decide otherwise.
|
||||
*/
|
||||
public class JsonExtension implements FlashExtension {
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
public JsonExtension() {
|
||||
this(JsonMapper.builder()
|
||||
.findAndAddModules()
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
.build());
|
||||
}
|
||||
|
||||
public JsonExtension(ObjectMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
/** Serializes what a handler returns, unless it already returned a response, bytes or text. */
|
||||
public Middleware auto() {
|
||||
return Marshalling.of(mapper, ContentType.JSON);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
ctx.provide(Json.class, new Json(mapper));
|
||||
ctx.provide(ObjectMapper.class, mapper);
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package dev.relism.flash.ext.jackson.json;
|
||||
|
||||
import dev.relism.flash.ext.jackson.Codec;
|
||||
import dev.relism.flash.ext.jackson.JacksonHandler;
|
||||
import dev.relism.flash.extension.Inject;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.routing.Consumes;
|
||||
|
||||
/**
|
||||
* A handler that takes a JSON body of one type.
|
||||
*
|
||||
* <pre>{@code
|
||||
* @POST("/users")
|
||||
* public final class CreateUser extends JsonHandler<NewUser> {
|
||||
* @Inject private UserService users;
|
||||
*
|
||||
* @Override protected Object handle(Request req, Response res, NewUser body) {
|
||||
* return users.create(body);
|
||||
* }
|
||||
* }
|
||||
* }</pre>
|
||||
*
|
||||
* <p>The body is read off the request stream, verified against the constraints its type declares,
|
||||
* and handed over. A malformed body is a 400, a body that breaks a constraint is a 422, and
|
||||
* neither ever reaches the handler. The published OpenAPI document describes the same type.
|
||||
*/
|
||||
@Consumes(ContentType.JSON)
|
||||
public abstract class JsonHandler<B> extends JacksonHandler<B> {
|
||||
|
||||
@Inject private Json json;
|
||||
|
||||
@Override protected final Codec codec() {
|
||||
return json;
|
||||
}
|
||||
}
|
||||
+7
-8
@@ -1,6 +1,6 @@
|
||||
package dev.relism.flash.ext.validation;
|
||||
package dev.relism.flash.ext.jackson.json;
|
||||
|
||||
import dev.relism.flash.ext.jackson.JacksonExtension;
|
||||
import dev.relism.flash.ext.jackson.json.JsonExtension;
|
||||
import dev.relism.flash.ext.openapi.APIResponse;
|
||||
import dev.relism.flash.ext.openapi.ApiOperation;
|
||||
import dev.relism.flash.ext.openapi.Content;
|
||||
@@ -24,7 +24,7 @@ import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
* describes them. Nothing registers this bridge — flash-ext-openapi picks the annotations up on
|
||||
* its own when they are on the classpath.
|
||||
*/
|
||||
class ValidationOpenApiInteropTest {
|
||||
class ConstraintsInTheDocumentTest {
|
||||
|
||||
record Account(
|
||||
@NotBlank @Size(max = 40) String name,
|
||||
@@ -33,7 +33,7 @@ class ValidationOpenApiInteropTest {
|
||||
|
||||
@GET("/accounts")
|
||||
@ApiOperation(summary = "List accounts")
|
||||
@APIResponse(responseCode = "200", content = @Content(contentType = ContentType.JSON, schema = Account.class))
|
||||
@APIResponse(responseCode = "200", content = @Content(schema = Account.class))
|
||||
public static class ListAccounts extends RequestHandler {
|
||||
@Override public Object handle(Request request, Response response) {
|
||||
return new Account("alice", "a@b.com", 30);
|
||||
@@ -42,10 +42,9 @@ class ValidationOpenApiInteropTest {
|
||||
|
||||
@RegisterExtension
|
||||
static FlashTest app = FlashTest.of(configured -> {
|
||||
configured.install(new JacksonExtension());
|
||||
configured.install(new ValidationExtension());
|
||||
configured.install(new OpenApiExtension("/openapi", "Accounts", "1.0.0"));
|
||||
configured.scan("dev.relism.flash.ext.validation");
|
||||
configured.install(new JsonExtension());
|
||||
configured.install(new OpenApiExtension("/openapi", "Accounts", "1.0.0"));
|
||||
configured.scan("dev.relism.flash.ext.jackson.json");
|
||||
});
|
||||
|
||||
@Test
|
||||
+6
-7
@@ -1,4 +1,4 @@
|
||||
package dev.relism.flash.ext.jackson;
|
||||
package dev.relism.flash.ext.jackson.json;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
@@ -16,26 +16,25 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class JacksonExtensionTest {
|
||||
class JsonExtensionTest {
|
||||
|
||||
@Test
|
||||
void configure_registers_json_mapper_and_middleware() {
|
||||
FlashContext ctx = new FlashContext();
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
JacksonExtension ext = new JacksonExtension(mapper);
|
||||
JsonExtension ext = new JsonExtension(mapper);
|
||||
|
||||
ext.configure(null, ctx);
|
||||
ctx.complete();
|
||||
|
||||
assertNotNull(ctx.require(Json.class));
|
||||
assertNotNull(ctx.require(JacksonMiddleware.class));
|
||||
assertSame(mapper, ctx.require(ObjectMapper.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoJson_factory_delegates_to_middleware_policy() throws Exception {
|
||||
void auto_marshals_what_a_handler_returns() throws Exception {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
JacksonExtension ext = new JacksonExtension(mapper);
|
||||
JsonExtension ext = new JsonExtension(mapper);
|
||||
RequestHandler next = new RequestHandler() {
|
||||
@Override
|
||||
public Object handle(Request request, Response response) {
|
||||
@@ -43,7 +42,7 @@ class JacksonExtensionTest {
|
||||
}
|
||||
};
|
||||
RequestHandler wrapped = new RequestHandler() {
|
||||
private final SimpleHandler.FunctionalHandler delegate = ext.autoJson().wrap(next);
|
||||
private final SimpleHandler.FunctionalHandler delegate = ext.auto().wrap(next);
|
||||
|
||||
@Override
|
||||
public Object handle(Request request, Response response) throws Exception {
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package dev.relism.flash.ext.jackson.json;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.models.Http1HeaderMap;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.RequestLine;
|
||||
import dev.relism.flash.models.Response;
|
||||
import dev.relism.flash.routing.routers.fastpathrouter.FastPathViews;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class JsonHandlerTest {
|
||||
|
||||
public record NewUser(String name) {}
|
||||
|
||||
static final class Create extends JsonHandler<NewUser> {
|
||||
@Override protected Object handle(Request request, Response response, NewUser body) {
|
||||
return body.name();
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("rawtypes")
|
||||
static final class Untyped extends JsonHandler {
|
||||
@Override protected Object handle(Request request, Response response, Object body) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void theBodyArrivesParsedAsTheTypeTheHandlerDeclares() throws Exception {
|
||||
Create handler = new Create();
|
||||
handler.bind(context());
|
||||
|
||||
Object answer = handler.handle(request("{\"name\":\"alice\"}"), new Response(200, ContentType.JSON));
|
||||
|
||||
assertEquals("alice", answer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMalformedBodyIsTheUsualBadRequest() throws Exception {
|
||||
Create handler = new Create();
|
||||
handler.bind(context());
|
||||
|
||||
dev.relism.flash.exceptions.HttpException refused = assertThrows(dev.relism.flash.exceptions.HttpException.class,
|
||||
() -> handler.handle(request("not json"), new Response(200, ContentType.JSON)));
|
||||
|
||||
assertEquals(400, refused.status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aHandlerThatNeverNamedItsBodyTypeIsRefusedAtBoot() {
|
||||
IllegalStateException refused = assertThrows(IllegalStateException.class, Untyped::new);
|
||||
|
||||
assertTrue(refused.getMessage().contains("Handler<YourBody>"), refused.getMessage());
|
||||
}
|
||||
|
||||
private static FlashContext context() {
|
||||
FlashContext ctx = new FlashContext();
|
||||
ctx.provide(Json.class, new Json(new ObjectMapper()));
|
||||
ctx.complete();
|
||||
return ctx;
|
||||
}
|
||||
|
||||
private static Request request(String body) {
|
||||
return new Request(new RequestLine(HttpMethod.POST,
|
||||
new FastPathViews.StringByteView("/users"), null,
|
||||
new FastPathViews.StringByteView("HTTP/1.1"), new Http1HeaderMap()),
|
||||
body.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
+3
-8
@@ -1,4 +1,4 @@
|
||||
package dev.relism.flash.ext.jackson;
|
||||
package dev.relism.flash.ext.jackson.json;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonView;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -37,16 +37,11 @@ class JsonTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void bodyFrom_parses_stream_and_maps_bad_payload_to_http_400() throws Exception {
|
||||
void a_truncated_body_is_a_bad_request_too() throws Exception {
|
||||
Json json = new Json(new ObjectMapper());
|
||||
|
||||
Request ok = request("{\"id\":\"u2\",\"name\":\"bob\"}");
|
||||
UserDto dto = json.bodyFrom(ok, UserDto.class);
|
||||
assertEquals("u2", dto.id);
|
||||
assertEquals("bob", dto.name);
|
||||
HttpException ex = assertThrows(HttpException.class, () -> json.body(request("["), UserDto.class));
|
||||
|
||||
Request bad = request("[");
|
||||
HttpException ex = assertThrows(HttpException.class, () -> json.bodyFrom(bad, UserDto.class));
|
||||
assertEquals(400, ex.status());
|
||||
}
|
||||
|
||||
+13
-11
@@ -1,6 +1,8 @@
|
||||
package dev.relism.flash.ext.jackson;
|
||||
package dev.relism.flash.ext.jackson.json;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.relism.flash.ext.jackson.Marshalling;
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
import dev.relism.flash.models.SimpleHandler;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.models.Request;
|
||||
@@ -16,13 +18,13 @@ import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class JacksonMiddlewareTest {
|
||||
class MarshallingTest {
|
||||
|
||||
private static final Request REQ = null;
|
||||
|
||||
@Test
|
||||
void autoJson_marshalsPojo_toJsonBytes_and_setsJsonContentType() throws Exception {
|
||||
JacksonMiddleware mw = new JacksonMiddleware(new ObjectMapper());
|
||||
void marshalling_marshalsPojo_toJsonBytes_and_setsJsonContentType() throws Exception {
|
||||
Middleware mw = Marshalling.of(new ObjectMapper(), ContentType.JSON);
|
||||
RequestHandler wrapped = wrap(mw, new UserDto("u1", "alice"));
|
||||
Response res = new Response(200, ContentType.TEXT_PLAIN);
|
||||
|
||||
@@ -34,8 +36,8 @@ class JacksonMiddlewareTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoJson_passThrough_for_response_string_charSequence_bytes_and_null() throws Exception {
|
||||
JacksonMiddleware mw = new JacksonMiddleware(new ObjectMapper());
|
||||
void marshalling_passThrough_for_response_string_charSequence_bytes_and_null() throws Exception {
|
||||
Middleware mw = Marshalling.of(new ObjectMapper(), ContentType.JSON);
|
||||
|
||||
Response payloadResponse = new Response(201, ContentType.TEXT_PLAIN).body("ok");
|
||||
RequestHandler wrappedResponse = wrap(mw, payloadResponse);
|
||||
@@ -55,17 +57,17 @@ class JacksonMiddlewareTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoJson_wraps_serialization_errors_as_illegal_state() {
|
||||
JacksonMiddleware mw = new JacksonMiddleware(new ObjectMapper());
|
||||
void marshalling_wraps_serialization_errors_as_illegal_state() {
|
||||
Middleware mw = Marshalling.of(new ObjectMapper(), ContentType.JSON);
|
||||
RequestHandler wrapped = wrap(mw, new CyclicDto());
|
||||
Response res = new Response(200, ContentType.TEXT_PLAIN);
|
||||
|
||||
IllegalStateException ex = assertThrows(IllegalStateException.class, () -> wrapped.handle(REQ, res));
|
||||
assertEquals("application/json", new String(res.getContentType(), StandardCharsets.UTF_8));
|
||||
assertTrue(ex.getMessage().startsWith("Failed to serialize handler result as JSON:"));
|
||||
assertTrue(ex.getMessage().startsWith("Could not serialize"));
|
||||
}
|
||||
|
||||
private static RequestHandler wrap(JacksonMiddleware mw, Object fixedReturn) {
|
||||
private static RequestHandler wrap(Middleware mw, Object fixedReturn) {
|
||||
RequestHandler next = new RequestHandler() {
|
||||
@Override
|
||||
public Object handle(Request request, Response response) {
|
||||
@@ -73,7 +75,7 @@ class JacksonMiddlewareTest {
|
||||
}
|
||||
};
|
||||
return new RequestHandler() {
|
||||
private final SimpleHandler.FunctionalHandler delegate = mw.autoJson().wrap(next);
|
||||
private final SimpleHandler.FunctionalHandler delegate = mw.wrap(next);
|
||||
|
||||
@Override
|
||||
public Object handle(Request request, Response response) throws Exception {
|
||||
+5
-7
@@ -1,6 +1,5 @@
|
||||
package dev.relism.flash.ext.validation;
|
||||
package dev.relism.flash.ext.jackson.json;
|
||||
|
||||
import dev.relism.flash.ext.jackson.JacksonExtension;
|
||||
import dev.relism.flash.testing.FlashTest;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.Min;
|
||||
@@ -12,19 +11,18 @@ import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/** The whole path: JSON in, constraints checked, status out — with no error handling wired up. */
|
||||
class ValidationRoutesTest {
|
||||
class ValidatedBodyTest {
|
||||
|
||||
record CreateUser(@NotBlank @Size(max = 8) String name, @Email String email, @Min(18) int age) {}
|
||||
|
||||
@RegisterExtension
|
||||
static FlashTest app = FlashTest.of(configured -> {
|
||||
configured.install(new JacksonExtension());
|
||||
configured.install(new ValidationExtension());
|
||||
configured.install(new JsonExtension());
|
||||
|
||||
configured.ctx().onReady(() -> {
|
||||
Validation validation = configured.ctx().require(Validation.class);
|
||||
Json json = configured.ctx().require(Json.class);
|
||||
configured.post("/users", (req, res) ->
|
||||
res.status(201).body("created:" + validation.body(req, CreateUser.class).name()));
|
||||
res.status(201).body("created:" + json.body(req, CreateUser.class).name()));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# flash-ext-jackson-xml
|
||||
|
||||
XML bodies and XML responses, over the same types as every other Jackson format.
|
||||
|
||||
```java
|
||||
XmlExtension xml = new XmlExtension();
|
||||
app.install(xml).use(xml.auto());
|
||||
```
|
||||
|
||||
```java
|
||||
@POST("/orders")
|
||||
public final class PlaceOrder extends XmlHandler<Order> {
|
||||
@Inject private OrderService orders;
|
||||
|
||||
@Override protected Object handle(Request req, Response res, Order body) {
|
||||
return orders.place(body);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Everything [`flash-ext-jackson-json`](../flash-ext-jackson-json) does, in XML: the body is read off
|
||||
the request stream, verified against the constraints its type declares, and handed over. A type
|
||||
annotated for JSON works here as is — `Order` can be a JSON body on one route and an XML body on
|
||||
another.
|
||||
|
||||
What is specific to XML is Jackson's own: `@JacksonXmlRootElement` for the root name,
|
||||
`@JacksonXmlProperty(isAttribute = true)` for an attribute rather than an element, and
|
||||
`@JacksonXmlElementWrapper` for how a list is wrapped. This module adds no annotations of its own.
|
||||
|
||||
Brings `jackson-dataformat-xml`, and with it Woodstox.
|
||||
+13
-21
@@ -10,42 +10,34 @@
|
||||
<version>2.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-validation</artifactId>
|
||||
<artifactId>flash-ext-jackson-xml</artifactId>
|
||||
<name>flash-ext-jackson-xml</name>
|
||||
<description>XML bodies and responses, over the same types and the same constraints as every other Jackson format.</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash</artifactId>
|
||||
<artifactId>flash-ext-jackson-core</artifactId>
|
||||
</dependency>
|
||||
<!--
|
||||
Annotations only (~90 KB). Hibernate Validator's engine is deliberately absent: it
|
||||
resolves constraints reflectively per call and pulls ~2 MB plus EL. This module
|
||||
compiles the same annotations into a flat check table once per class instead.
|
||||
-->
|
||||
<dependency>
|
||||
<groupId>jakarta.validation</groupId>
|
||||
<artifactId>jakarta.validation-api</artifactId>
|
||||
</dependency>
|
||||
<!-- Only needed for Validation.body(...); check(...) works without it. -->
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-jackson</artifactId>
|
||||
<optional>true</optional>
|
||||
<groupId>com.fasterxml.jackson.dataformat</groupId>
|
||||
<artifactId>jackson-dataformat-xml</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>jakarta.validation</groupId>
|
||||
<artifactId>jakarta.validation-api</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-testing</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- Interop only: proves constraints reach the published schema. -->
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-openapi</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package dev.relism.flash.ext.jackson.xml;
|
||||
|
||||
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
|
||||
import dev.relism.flash.ext.jackson.Codec;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
|
||||
/**
|
||||
* XML in and out, checked against the body type's own constraints.
|
||||
*
|
||||
* <p>The same databind model as every other Jackson format: a type is annotated once and can be
|
||||
* read as XML here and as JSON elsewhere in the same application. XML's own concerns — a root
|
||||
* element name, an attribute rather than an element, how a list is wrapped — are Jackson's
|
||||
* {@code @JacksonXml*} annotations on the type.
|
||||
*/
|
||||
public final class Xml extends Codec {
|
||||
|
||||
public Xml(XmlMapper mapper) {
|
||||
super(mapper, ContentType.XML);
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package dev.relism.flash.ext.jackson.xml;
|
||||
|
||||
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
|
||||
import dev.relism.flash.ext.jackson.Marshalling;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import dev.relism.flash.extension.FlashExtension;
|
||||
import dev.relism.flash.extension.FlashRegistrar;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
|
||||
/**
|
||||
* XML for an application: the {@link Xml} codec and the middleware that serializes what a handler
|
||||
* returns.
|
||||
*
|
||||
* <pre>{@code
|
||||
* XmlExtension xml = new XmlExtension();
|
||||
* app.install(xml).use(xml.auto());
|
||||
* }</pre>
|
||||
*
|
||||
* <p>Install it beside {@code JsonExtension} when an application speaks both: each provides its
|
||||
* own codec, and a route picks one by the handler it extends.
|
||||
*/
|
||||
public class XmlExtension implements FlashExtension {
|
||||
|
||||
private final XmlMapper mapper;
|
||||
|
||||
public XmlExtension() {
|
||||
this(XmlMapper.builder().findAndAddModules().build());
|
||||
}
|
||||
|
||||
public XmlExtension(XmlMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
/** Serializes what a handler returns, unless it already returned a response, bytes or text. */
|
||||
public Middleware auto() {
|
||||
return Marshalling.of(mapper, ContentType.XML);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
ctx.provide(Xml.class, new Xml(mapper));
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package dev.relism.flash.ext.jackson.xml;
|
||||
|
||||
import dev.relism.flash.ext.jackson.Codec;
|
||||
import dev.relism.flash.ext.jackson.JacksonHandler;
|
||||
import dev.relism.flash.extension.Inject;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.routing.Consumes;
|
||||
|
||||
/**
|
||||
* A handler that takes an XML body of one type.
|
||||
*
|
||||
* <p>Everything {@code JsonHandler} does, in XML: the body is read off the request stream,
|
||||
* verified against its type's constraints, and handed over. Both can live in the same
|
||||
* application, on different routes, over the same types.
|
||||
*/
|
||||
@Consumes(ContentType.XML)
|
||||
public abstract class XmlHandler<B> extends JacksonHandler<B> {
|
||||
|
||||
@Inject private Xml xml;
|
||||
|
||||
@Override protected final Codec codec() {
|
||||
return xml;
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package dev.relism.flash.ext.jackson.xml;
|
||||
|
||||
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
|
||||
import dev.relism.flash.exceptions.HttpException;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.models.Http1HeaderMap;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.RequestLine;
|
||||
import dev.relism.flash.models.Response;
|
||||
import dev.relism.flash.routing.routers.fastpathrouter.FastPathViews;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
/** The same handler shape as JSON, over the same types and the same constraints. */
|
||||
class XmlHandlerTest {
|
||||
|
||||
public record Order(@NotBlank String reference) {}
|
||||
|
||||
static final class Place extends XmlHandler<Order> {
|
||||
@Override protected Object handle(Request request, Response response, Order body) {
|
||||
return body.reference();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void theBodyArrivesParsedAsTheTypeTheHandlerDeclares() throws Exception {
|
||||
Place handler = new Place();
|
||||
handler.bind(context());
|
||||
|
||||
Object answer = handler.handle(request("<Order><reference>A-1</reference></Order>"), response());
|
||||
|
||||
assertEquals("A-1", answer);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aBodyThatBreaksAConstraintNeverReachesTheHandler() throws Exception {
|
||||
Place handler = new Place();
|
||||
handler.bind(context());
|
||||
|
||||
HttpException refused = assertThrows(HttpException.class,
|
||||
() -> handler.handle(request("<Order><reference></reference></Order>"), response()));
|
||||
|
||||
assertEquals(422, refused.status());
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMalformedBodyIsABadRequest() throws Exception {
|
||||
Place handler = new Place();
|
||||
handler.bind(context());
|
||||
|
||||
HttpException refused = assertThrows(HttpException.class,
|
||||
() -> handler.handle(request("<Order><reference>"), response()));
|
||||
|
||||
assertEquals(400, refused.status());
|
||||
}
|
||||
|
||||
private static FlashContext context() {
|
||||
FlashContext ctx = new FlashContext();
|
||||
ctx.provide(Xml.class, new Xml(XmlMapper.builder().build()));
|
||||
ctx.complete();
|
||||
return ctx;
|
||||
}
|
||||
|
||||
private static Response response() {
|
||||
return new Response(200, ContentType.XML);
|
||||
}
|
||||
|
||||
private static Request request(String body) {
|
||||
return new Request(new RequestLine(HttpMethod.POST,
|
||||
new FastPathViews.StringByteView("/orders"), null,
|
||||
new FastPathViews.StringByteView("HTTP/1.1"), new Http1HeaderMap()),
|
||||
body.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
# flash-ext-jackson
|
||||
|
||||
Jackson JSON integration for Flash with an opinionated auto-marshal middleware.
|
||||
|
||||
## What it provides
|
||||
|
||||
| Component | Description |
|
||||
|---|---|
|
||||
| `JacksonExtension` | Registers JSON services into `FlashContext` |
|
||||
| `Json` | JSON read/write helper (`body`, `bodyFrom`, `write`, `writeView`) |
|
||||
| `ObjectMapper` | Raw mapper escape hatch for advanced usage |
|
||||
| `JacksonMiddleware` | `autoJson()` middleware for automatic outbound JSON marshalling |
|
||||
|
||||
Default mapper behavior (`new JacksonExtension()`):
|
||||
|
||||
- auto-discovers Jackson modules on classpath (`findAndAddModules()`)
|
||||
- includes Java Time support (`jackson-datatype-jsr310`)
|
||||
- writes date/time values as ISO-8601 strings (not numeric timestamps)
|
||||
|
||||
## Recommended default
|
||||
|
||||
Install the extension, then apply `autoJson()` once at app or scope level.
|
||||
|
||||
```java
|
||||
JacksonExtension jackson = new JacksonExtension();
|
||||
|
||||
FlashApp app = FlashApp.create(8080)
|
||||
.install(jackson)
|
||||
.use(jackson.autoJson());
|
||||
|
||||
app.startAndBlock();
|
||||
```
|
||||
|
||||
Behavior of `autoJson()`:
|
||||
|
||||
- pass-through: `null`, `Response`, `byte[]`, `String`, `CharSequence`
|
||||
- any other return value: serialize to JSON `byte[]`
|
||||
- sets `Content-Type: application/json` for marshalled responses
|
||||
- serialization failures throw `IllegalStateException`
|
||||
|
||||
This keeps handlers concise while preserving Flash's direct byte write path.
|
||||
|
||||
## Installation
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-jackson</artifactId>
|
||||
<version>1.1-indev2</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
## Json helper API
|
||||
|
||||
Use `Json` when you want explicit, local control in a handler.
|
||||
|
||||
```java
|
||||
@POST("/users")
|
||||
public final class CreateUser extends RequestHandler {
|
||||
private Json json;
|
||||
|
||||
@Override
|
||||
protected void onInit() {
|
||||
json = require(Json.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object handle(Request req, Response res) throws Exception {
|
||||
CreateUserBody body = json.body(req, CreateUserBody.class);
|
||||
UserDto created = service.create(body);
|
||||
res.status(201);
|
||||
return json.write(res, created);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Methods:
|
||||
|
||||
- `body(req, Type.class)` -> parse from `req.body().bytes()`
|
||||
- `bodyFrom(req, Type.class)` -> parse from `req.body().stream()`
|
||||
- `write(res, obj)` -> writes JSON string and sets JSON content type
|
||||
- `writeView(res, obj, View.class)` -> JSON with Jackson `@JsonView`
|
||||
- `mapper()` -> raw `ObjectMapper`
|
||||
|
||||
## Custom mapper
|
||||
|
||||
```java
|
||||
ObjectMapper mapper = JsonMapper.builder()
|
||||
.addModule(new JavaTimeModule())
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
.build();
|
||||
|
||||
FlashApp.create(8080)
|
||||
.install(new JacksonExtension(mapper));
|
||||
```
|
||||
|
||||
## Scope usage
|
||||
|
||||
`autoJson()` works the same at scope level:
|
||||
|
||||
```java
|
||||
JacksonExtension jackson = new JacksonExtension();
|
||||
|
||||
app.mount("/api", api -> {
|
||||
api.use(jackson.autoJson());
|
||||
api.get("/health", (req, res) -> Map.of("ok", true));
|
||||
});
|
||||
```
|
||||
|
||||
If you need to pull it from context, `JacksonMiddleware` is also provided as a service
|
||||
after the app boots (same lifecycle model as other extension-provided services).
|
||||
|
||||
## Notes
|
||||
|
||||
- Install order is irrelevant (Flash two-phase extension lifecycle).
|
||||
- `autoJson()` and OpenAPI are intentionally decoupled.
|
||||
@@ -1,82 +0,0 @@
|
||||
<?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>2.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-jackson</artifactId>
|
||||
|
||||
<properties>
|
||||
<jacoco.version>0.8.12</jacoco.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.datatype</groupId>
|
||||
<artifactId>jackson-datatype-jsr310</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<version>${jacoco.version}</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>jacoco-prepare-agent</id>
|
||||
<goals>
|
||||
<goal>prepare-agent</goal>
|
||||
</goals>
|
||||
</execution>
|
||||
<execution>
|
||||
<id>jacoco-report-and-check</id>
|
||||
<phase>verify</phase>
|
||||
<goals>
|
||||
<goal>report</goal>
|
||||
<goal>check</goal>
|
||||
</goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<rule>
|
||||
<element>BUNDLE</element>
|
||||
<limits>
|
||||
<limit>
|
||||
<counter>LINE</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.80</minimum>
|
||||
</limit>
|
||||
</limits>
|
||||
</rule>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
-94
@@ -1,94 +0,0 @@
|
||||
package dev.relism.flash.ext.jackson;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import dev.relism.flash.extension.FlashRegistrar;
|
||||
import dev.relism.flash.extension.FlashExtension;
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
|
||||
/**
|
||||
* Registers JSON support into the Flash extension layer.
|
||||
*
|
||||
* <p>Exposes a {@link Json} utility instance in the {@link FlashContext} under
|
||||
* {@code Json.class}. Any handler or extension can retrieve it via {@code ctx.require(Json.class)}
|
||||
* inside {@code onInit()} (class-based) or from a {@link FlashContext#onReady(Runnable)}
|
||||
* callback (extensions).
|
||||
*
|
||||
* <p>The raw {@link ObjectMapper} is also registered under {@code ObjectMapper.class}
|
||||
* for extensions that need direct mapper access (e.g. OpenAPI schema generation).
|
||||
*
|
||||
* <p>{@link JacksonMiddleware} is provided under {@code JacksonMiddleware.class} and
|
||||
* exposes opinionated JSON auto-marshalling middleware via {@link JacksonMiddleware#autoJson()}.
|
||||
*
|
||||
* <h3>Usage — composition (preferred)</h3>
|
||||
* <pre>{@code
|
||||
* public class MyHandler extends RequestHandler {
|
||||
* private Json json;
|
||||
*
|
||||
* @Override protected void onInit() {
|
||||
* json = require(Json.class);
|
||||
* }
|
||||
*
|
||||
* public Object handle(Request req, Response res) throws Exception {
|
||||
* MyDto dto = json.body(req, MyDto.class);
|
||||
* return json.write(res, 201, dto);
|
||||
* }
|
||||
* }
|
||||
* }</pre>
|
||||
*
|
||||
* <h3>Custom mapper</h3>
|
||||
* <pre>{@code
|
||||
* ObjectMapper mapper = JsonMapper.builder()
|
||||
* .addModule(new JavaTimeModule())
|
||||
* .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
* .build();
|
||||
*
|
||||
* FlashApp.create(8080)
|
||||
* .install(new JacksonExtension(mapper));
|
||||
* }</pre>
|
||||
*/
|
||||
public class JacksonExtension implements FlashExtension {
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
private final JacksonMiddleware middleware;
|
||||
|
||||
/**
|
||||
* Installs with an opinionated default {@link JsonMapper}:
|
||||
* auto-discovers modules on classpath (e.g. Java Time) and writes dates as ISO strings.
|
||||
*/
|
||||
public JacksonExtension() {
|
||||
this(JsonMapper.builder()
|
||||
.findAndAddModules()
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
.build());
|
||||
}
|
||||
|
||||
/** Installs with a fully configured custom {@link ObjectMapper}. */
|
||||
public JacksonExtension(ObjectMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
this.middleware = new JacksonMiddleware(mapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opinionated outbound JSON middleware factory.
|
||||
*
|
||||
* <p>Use for app/scope-level registration:
|
||||
* <pre>{@code
|
||||
* JacksonExtension jackson = new JacksonExtension();
|
||||
* app.install(jackson).use(jackson.autoJson());
|
||||
* }</pre>
|
||||
*/
|
||||
public Middleware autoJson() {
|
||||
return middleware.autoJson();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
Json json = new Json(mapper);
|
||||
ctx.provide(Json.class, json);
|
||||
ctx.provide(ObjectMapper.class, mapper);
|
||||
ctx.provide(JacksonMiddleware.class, middleware);
|
||||
}
|
||||
}
|
||||
-62
@@ -1,62 +0,0 @@
|
||||
package dev.relism.flash.ext.jackson;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.models.Response;
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
|
||||
/**
|
||||
* Outbound JSON marshalling middleware for class-based and lambda routes.
|
||||
*
|
||||
* <p>{@link #autoJson()} marshals any non-body-native return value to JSON bytes,
|
||||
* writes {@code Content-Type: application/json}, and returns {@code byte[]} so
|
||||
* the Flash write path stays direct.
|
||||
*
|
||||
* <p>Pass-through return types:
|
||||
* <ul>
|
||||
* <li>{@code null}</li>
|
||||
* <li>{@link Response}</li>
|
||||
* <li>{@code byte[]}</li>
|
||||
* <li>{@link String}</li>
|
||||
* <li>{@link CharSequence}</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class JacksonMiddleware {
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
JacksonMiddleware(ObjectMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatic JSON marshalling policy.
|
||||
*
|
||||
* <p>For non-pass-through return values, serializes with Jackson directly to
|
||||
* {@code byte[]} and sets response content type to JSON.
|
||||
*
|
||||
* @throws IllegalStateException when serialization fails
|
||||
*/
|
||||
public Middleware autoJson() {
|
||||
return next -> (req, res) -> {
|
||||
Object out = next.handle(req, res);
|
||||
if (isPassThrough(out)) return out;
|
||||
|
||||
res.type(ContentType.JSON);
|
||||
try {
|
||||
return mapper.writeValueAsBytes(out);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new IllegalStateException(
|
||||
"Failed to serialize handler result as JSON: " + out.getClass().getName(), e);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean isPassThrough(Object out) {
|
||||
return out == null
|
||||
|| out instanceof Response
|
||||
|| out instanceof byte[]
|
||||
|| out instanceof CharSequence;
|
||||
}
|
||||
}
|
||||
-117
@@ -1,117 +0,0 @@
|
||||
package dev.relism.flash.ext.jackson;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.relism.flash.exceptions.HttpException;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.Response;
|
||||
|
||||
/**
|
||||
* Thread-safe JSON toolbox. Single point of access for all JSON I/O operations
|
||||
* within a Flash application.
|
||||
*
|
||||
* <p>Retrieve once at boot time via {@code require(Json.class)} inside
|
||||
* {@code onInit()}, cache in a private field, and call on the hot path
|
||||
* with zero lookup or allocation overhead:
|
||||
*
|
||||
* <pre>{@code
|
||||
* @Route(method = HttpMethod.POST, path = "/api/items")
|
||||
* public class CreateItemHandler extends RequestHandler {
|
||||
*
|
||||
* private Json json;
|
||||
*
|
||||
* @Override
|
||||
* protected void onInit() {
|
||||
* json = require(Json.class);
|
||||
* }
|
||||
*
|
||||
* public Object handle(Request req, Response res) throws Exception {
|
||||
* CreateItemRequest body = json.body(req, CreateItemRequest.class);
|
||||
* return json.write(res, itemService.create(body));
|
||||
* }
|
||||
* }
|
||||
* }</pre>
|
||||
*
|
||||
* <p>The underlying {@link ObjectMapper} is shared across all handlers in the same
|
||||
* scope (one instance per app / per child scope). Jackson's {@code ObjectMapper}
|
||||
* is fully thread-safe after configuration — no synchronization is needed.
|
||||
*
|
||||
* <p>Install via {@link JacksonExtension} before calling {@code scan()} or
|
||||
* {@code register()}.
|
||||
*/
|
||||
public final class Json {
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
/** Package-private — constructed exclusively by {@link JacksonExtension}. */
|
||||
Json(ObjectMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
// ── Input ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Deserializes the full request body into an instance of {@code type}.
|
||||
*
|
||||
* <p>Reads {@code req.body().bytes()} in one shot. For streaming bodies
|
||||
* use {@link #bodyFrom(Request, Class)} instead.
|
||||
*
|
||||
* @throws HttpException 400 if the body cannot be parsed as {@code type}
|
||||
*/
|
||||
public <T> T body(Request req, Class<T> type) throws Exception {
|
||||
try {
|
||||
return mapper.readValue(req.body().bytes(), type);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw HttpException.badRequest("Invalid request body: " + e.getOriginalMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserializes the request body via the raw {@link java.io.InputStream},
|
||||
* avoiding the intermediate {@code byte[]} allocation. Prefer this for
|
||||
* large bodies or when allocation budget is tight.
|
||||
*
|
||||
* @throws HttpException 400 on parse failure
|
||||
*/
|
||||
public <T> T bodyFrom(Request req, Class<T> type) throws Exception {
|
||||
try {
|
||||
return mapper.readValue(req.body().stream(), type);
|
||||
} catch (JsonProcessingException e) {
|
||||
throw HttpException.badRequest("Invalid request body: " + e.getOriginalMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ── Output ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Serializes {@code obj} to a JSON string and sets
|
||||
* {@code Content-Type: application/json} on the response.
|
||||
*
|
||||
* <p>The returned string is used as the response body by the Flash runtime.
|
||||
*/
|
||||
public String write(Response res, Object obj) throws Exception {
|
||||
res.type(ContentType.JSON);
|
||||
return mapper.writeValueAsString(obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link #write} but applies a Jackson {@code @JsonView} filter,
|
||||
* restricting serialization to fields visible under {@code view}.
|
||||
*/
|
||||
public String writeView(Response res, Object obj, Class<?> view) throws Exception {
|
||||
res.type(ContentType.JSON);
|
||||
return mapper.writerWithView(view).writeValueAsString(obj);
|
||||
}
|
||||
|
||||
// ── Escape hatch ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the underlying {@link ObjectMapper} for advanced operations
|
||||
* (custom serialization, schema generation, etc.) not covered by the
|
||||
* methods above.
|
||||
*/
|
||||
public ObjectMapper mapper() {
|
||||
return mapper;
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ FlashApp.create(8080)
|
||||
// With custom resolvers
|
||||
LimiterConfig conf = new LimiterConfig()
|
||||
.registerResolver("auth_user", req ->
|
||||
ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous");
|
||||
SecurityIdentity.current() != null ? SecurityIdentity.current().principal().name() : "anonymous");
|
||||
|
||||
FlashApp.create(8080)
|
||||
.install(new LimiterExtension(conf))
|
||||
|
||||
@@ -35,11 +35,11 @@ conf.registerResolver("ip", req -> {
|
||||
LimiterConfig conf = new LimiterConfig();
|
||||
```
|
||||
|
||||
### By authenticated user (OIDC / ClaimsHolder)
|
||||
### By authenticated user
|
||||
|
||||
```java
|
||||
conf.registerResolver("auth_user", req ->
|
||||
ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous");
|
||||
SecurityIdentity.current() != null ? SecurityIdentity.current().principal().name() : "anonymous");
|
||||
```
|
||||
|
||||
Requests from unauthenticated users share the `"anonymous"` bucket. If you want
|
||||
@@ -88,7 +88,7 @@ returns the same key for the same user regardless of endpoint; the limit is set
|
||||
|
||||
```java
|
||||
conf.registerResolver("auth_user", req ->
|
||||
ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anon");
|
||||
SecurityIdentity.current() != null ? SecurityIdentity.current().principal().name() : "anon");
|
||||
```
|
||||
|
||||
```java
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ import dev.relism.flash.models.Request;
|
||||
*
|
||||
* <pre>{@code
|
||||
* conf.registerResolver("ip", req -> req.header("X-Forwarded-For"));
|
||||
* conf.registerResolver("auth_user", req -> ClaimsHolder.user().sub());
|
||||
* conf.registerResolver("auth_user", req -> SecurityIdentity.current().principal().name());
|
||||
* }</pre>
|
||||
*/
|
||||
@FunctionalInterface
|
||||
|
||||
+2
-2
@@ -21,8 +21,8 @@ import java.util.Map;
|
||||
* <pre>{@code
|
||||
* LimiterConfig conf = new LimiterConfig()
|
||||
* .registerResolver("auth_user", req -> {
|
||||
* // custom logic — e.g. extract sub from ClaimsHolder
|
||||
* return ClaimsHolder.exists() ? ClaimsHolder.user().sub() : "anonymous";
|
||||
* // custom logic — e.g. key by the authenticated caller
|
||||
* return SecurityIdentity.current() != null ? SecurityIdentity.current().principal().name() : "anonymous";
|
||||
* });
|
||||
*
|
||||
* app.install(new LimiterExtension(conf));
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ import java.util.Map;
|
||||
* <h3>Lambda routes (via Guard)</h3>
|
||||
* <pre>{@code
|
||||
* app.install(new LimiterExtension(
|
||||
* new LimiterConfig().registerResolver("auth_user", req -> ClaimsHolder.user().sub())));
|
||||
* new LimiterConfig().registerResolver("auth_user", req -> SecurityIdentity.current().principal().name())));
|
||||
*
|
||||
* // inside a FlashContext.onReady(...) callback:
|
||||
* Guard guard = ctx.require(Guard.class);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
`flash-ext-mcp` turns a Flash5 app into an [MCP](https://modelcontextprotocol.io) (Model Context
|
||||
Protocol) server: JSON-RPC 2.0 over the Streamable HTTP transport, tools/resources/prompts
|
||||
declared as plain classes and discovered at boot, optional OAuth2 protection built on
|
||||
`flash-ext-oidc`.
|
||||
`flash-ext-security-core`.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -44,7 +44,7 @@ public class GetWeatherTool extends McpTool {
|
||||
`tools-resources-prompts.md`.
|
||||
- **Transport**: Streamable HTTP, `POST`-only, stateless in this revision — see `transport.md`
|
||||
for exactly what that means and why.
|
||||
- **Security**: optional, policy-driven OAuth2 via `flash-ext-oidc` — see `security.md`.
|
||||
- **Security**: authenticated by `flash-ext-security-core`, an OAuth2 protected resource when OIDC is installed — see `security.md`.
|
||||
- **JSON**: this extension owns its JSON handling independently of `flash-ext-jackson` — see
|
||||
`jackson-interop.md` for why, and how a future opt-in reuse could work.
|
||||
|
||||
@@ -53,6 +53,4 @@ public class GetWeatherTool extends McpTool {
|
||||
- [`tools-resources-prompts.md`](tools-resources-prompts.md) — defining tools, resources, prompts
|
||||
- [`transport.md`](transport.md) — Streamable HTTP scope, session/SSE limitations, Origin validation
|
||||
- [`security.md`](security.md) — `McpSecurity` policy, OAuth2 resolution, RFC 9728 / RFC 8707
|
||||
- [`keycloak.md`](keycloak.md) — Keycloak-specific setup cookbook: Dynamic Client Registration,
|
||||
the RFC 8707 audience mapper gotcha, and how to verify/debug it
|
||||
- [`jackson-interop.md`](jackson-interop.md) — why this extension does not depend on `flash-ext-jackson`
|
||||
|
||||
@@ -24,7 +24,7 @@ see `tools-resources-prompts.md` — and the fixed `TextContent`/`TextResourceCo
|
||||
as a `JsonNode` tree, not as a databound class, for the same reason — a JSON-RPC tool call's
|
||||
arguments aren't a DTO with getters/setters, they're a dynamic, per-tool-defined bag of values.
|
||||
|
||||
This mirrors how `flash-ext-oidc` already handles its own internal JSON needs (`json-smart` for
|
||||
This mirrors how `flash-ext-security-oidc` handles its own JSON needs (Nimbus's parser for
|
||||
token-endpoint responses) independently of `flash-ext-jackson` — extensions with protocol-level
|
||||
JSON needs that are shaped by a spec, not by user code, own that JSON handling themselves rather
|
||||
than routing it through the app's general-purpose JSON extension.
|
||||
@@ -44,8 +44,7 @@ Nothing here rules out a later, additive convenience layer: `McpExtension.routes
|
||||
that shared mapper as the backing for an escape hatch such as `ToolArguments.as(Class<T>)` or
|
||||
for a tool that wants to `ToolResponse.success(someRecord)` and have it serialized with the
|
||||
app's own conventions — falling back to a locally-constructed default `ObjectMapper` when
|
||||
`flash-ext-jackson` isn't installed, the same "prefer shared, degrade to sane default" shape
|
||||
already used for `McpSecurity.AUTO`. That would be purely additive on top of the
|
||||
`flash-ext-jackson` isn't installed. That would be purely additive on top of the
|
||||
`JsonGenerator`-based envelope/content writing described above, not a replacement for it — the
|
||||
fixed-shape protocol plumbing has no reason to ever go through databinding, regardless of what
|
||||
convenience layer gets added around it.
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
# Keycloak cookbook
|
||||
|
||||
`security.md` covers the OAuth2 mechanics `McpOidcIntegration` implements against any
|
||||
`flash-ext-oidc`-compatible provider. This is the Keycloak-specific setup: the exact Admin
|
||||
Console configuration for a working MCP OAuth2 flow with open Dynamic Client Registration
|
||||
(DCR) — no pre-registered clients, any MCP client self-registers on first connect.
|
||||
|
||||
## 1. Allow Dynamic Client Registration
|
||||
|
||||
MCP clients (Claude Desktop, Claude.ai, MCP Inspector, others) don't share one static OAuth
|
||||
client — each has its own `redirect_uri` and none know your realm in advance. They self-register
|
||||
on first connect via `POST {issuer}/clients-registrations/openid-connect` (the
|
||||
`registration_endpoint` from the AS metadata document, reached via the RFC 9728 Protected
|
||||
Resource Metadata document `McpExtension` publishes).
|
||||
|
||||
**Clients → Client registration**: remove the **Trusted Hosts** policy — it rejects anonymous
|
||||
registration from hosts not on an explicit allowlist (`403` / `"Host not trusted"`), which
|
||||
doesn't scale to arbitrary future agents. This does not weaken end-user authentication — DCR
|
||||
only grants an app a `client_id`; every user still authenticates against Keycloak's real login
|
||||
screen regardless of which client asked. Lighter hygiene policies (**Max Clients Limit**,
|
||||
**Consent Required**) can stay, they don't interfere.
|
||||
|
||||
## 2. RFC 8707 audience: mapper on `basic`, not a custom scope
|
||||
|
||||
`McpOidcIntegration` rejects (403) any token whose `aud` doesn't include the MCP endpoint's
|
||||
canonical URL. Keycloak doesn't add this by default. The obvious fix — a custom client scope
|
||||
with an Audience mapper, marked Default, added to Allowed Client Scopes — **does not work**:
|
||||
clients created via the `openid-connect` DCR endpoint only ever get scopes they explicitly
|
||||
request, and most MCP clients (including MCP Inspector) don't request anything beyond what a
|
||||
server tells them to via `scopes_supported` (step 3). Default-scope auto-attachment, which is
|
||||
how a normal manually-created client would pick up a custom Default scope, doesn't apply to
|
||||
DCR-created clients at all.
|
||||
|
||||
`basic` is the one built-in scope Keycloak attaches to every client unconditionally, regardless
|
||||
of what it registered with. Put the audience mapper there:
|
||||
|
||||
1. **Client scopes → `basic`** → **Mappers** → **Add mapper** → **By configuration** →
|
||||
**Audience**.
|
||||
2. **Included Custom Audience** = the exact value your server expects — check
|
||||
`GET {parent-of-rootPath}/.well-known/oauth-protected-resource{rootPath}` on the running
|
||||
server for the `resource` field it publishes (auto-derived from the request's
|
||||
forwarded/`Host` headers — see `security.md`). Leave **Included Client Audience** empty (that
|
||||
targets another Keycloak client, not a resource URL).
|
||||
3. **Add to access token** = ON.
|
||||
4. **Save.**
|
||||
|
||||
This is unconditional and works regardless of client cooperation — keep it even after step 3
|
||||
below gets other claims flowing normally, since audience binding is a hard spec requirement
|
||||
that shouldn't depend on a client bothering to request the right scope.
|
||||
|
||||
## 3. Other claims (username, email...): `scopes_supported` + Allowed Client Scopes
|
||||
|
||||
`OidcUser.username()`/`.email()`/`.name()` read `preferred_username`/`email`/`name` — normally
|
||||
from the `profile`/`email` client scopes, which DCR clients don't get either, same root cause.
|
||||
Unlike audience, this **is** fixable the "normal" way, because it doesn't need to survive a
|
||||
completely uncooperative client:
|
||||
|
||||
`McpConfig.scopesSupported("openid", "profile", "email")` publishes those scopes in the PRM
|
||||
document. MCP clients that read it (confirmed for MCP Inspector) echo them back in their DCR
|
||||
registration request — `"scope": "openid profile email offline_access"` (`offline_access` is
|
||||
Inspector's own addition, for refresh tokens). For that request to actually succeed, **Allowed
|
||||
Client Scopes** needs, exactly:
|
||||
|
||||
- **`openid` listed explicitly.** The one genuinely non-obvious step: `openid` is not covered by
|
||||
**Allow Default Scopes** (On by default) the way other realm-Default scopes are, even though
|
||||
every OIDC request includes it. Until it's listed here, registration fails with a generic
|
||||
`403 insufficient_scope` / `"Not permitted to use specified clientScope"` regardless of
|
||||
whether everything else is configured correctly.
|
||||
- **`offline_access` listed explicitly** — it's Optional, not Default, so `ALLOW_DEFAULT_SCOPES`
|
||||
doesn't cover it either.
|
||||
- **`profile`/`email` — do not list them here.** Mark them **Default** on the **Client scopes**
|
||||
page (Assigned Type column) instead, and leave **Allow Default Scopes** = On. Adding an
|
||||
already-Default scope to this list explicitly gets rejected on save
|
||||
(`"Client scopes not allowed: [...]"`) — the list is for *additional* Optional scopes only.
|
||||
|
||||
With that, a real client's token comes back with `preferred_username`/`email` populated
|
||||
normally.
|
||||
|
||||
### Fallback for anything else
|
||||
|
||||
For a claim not covered by `openid profile email` (a custom attribute, a role) — or for a client
|
||||
that ignores `scopes_supported` entirely — add a **User Property** mapper to `basic` too
|
||||
(Property `username` → Token Claim Name `preferred_username`, or whatever's needed), same as the
|
||||
audience mapper in step 2. Unconditional, works regardless of client cooperation, costs one
|
||||
mapper per claim, once, at the realm level — not per tool.
|
||||
|
||||
## Verifying without a full OAuth round-trip
|
||||
|
||||
**Clients → (any client) → Client scopes → Evaluate**: pick a user, run it — Default scopes
|
||||
(including `basic`) apply automatically and won't appear in the "Select scope parameters"
|
||||
picker, which only lists Optional ones — and check the **Generated Access Token** preview.
|
||||
Confirms mappers work without a browser + real MCP client round-trip each time.
|
||||
|
||||
## If a real client still gets rejected
|
||||
|
||||
`McpOidcIntegration.audienceGuard` logs the actual mismatch at `WARN`:
|
||||
|
||||
```
|
||||
[flash-ext-mcp] Rejecting token (RFC 8707): aud=<token's actual aud> does not include expected
|
||||
resource identifier "<what this server expects>" — ...
|
||||
```
|
||||
|
||||
`aud=null` → the `basic` mapper produced nothing (most common cause: **Included Custom
|
||||
Audience** left blank — the mapper saves fine and silently does nothing without it). A non-null
|
||||
`aud` that still doesn't match → compare byte-for-byte — the expected side is derived from the
|
||||
request's own forwarded/`Host` headers, so scheme/host/trailing-slash mismatches show up here
|
||||
directly, as does a proxy hop that drops `X-Forwarded-Host`.
|
||||
@@ -1,170 +1,58 @@
|
||||
# Security
|
||||
|
||||
Provider-specific setup steps (not generic OAuth2 mechanics) live in separate cookbooks —
|
||||
[`keycloak.md`](keycloak.md) for Keycloak: enabling Dynamic Client Registration, why the RFC 8707
|
||||
audience mapper needs to go on the built-in `basic` scope instead of a custom one, and the exact
|
||||
Allowed Client Scopes configuration `scopes_supported` needs to actually work.
|
||||
The MCP endpoint is secured by [`flash-ext-security-core`](../../flash-ext-security-core/docs/README.md):
|
||||
whatever mechanisms the application registers — OAuth2 bearer tokens, API keys, custom ones —
|
||||
authenticate `/mcp` exactly as they authenticate every other route.
|
||||
|
||||
## `McpSecurity`
|
||||
| `McpConfig.security(...)` | |
|
||||
|---|---|
|
||||
| `REQUIRED` (default) | every call must be authenticated; boot fails without a `SecurityExtension` |
|
||||
| `NONE` | a public endpoint; a tool carrying security annotations fails the boot |
|
||||
|
||||
`McpConfig.security(...)` controls how the MCP endpoint reacts to `flash-ext-oidc` being
|
||||
installed (`ctx.find(OidcMiddleware.class)`), resolved once at boot in `McpExtension.routes()`:
|
||||
## OAuth2 protected resource
|
||||
|
||||
| Policy | `flash-ext-oidc` installed | `flash-ext-oidc` absent |
|
||||
|---|---|---|
|
||||
| `REQUIRED` | protected | **boot fails** (`IllegalStateException`) |
|
||||
| `AUTO` (default) | protected | runs unprotected, logs a warning |
|
||||
| `NONE` | never protected, even if oidc is installed elsewhere in the app | runs unprotected |
|
||||
When a registered mechanism publishes an OAuth2 issuer — `flash-ext-security-oidc` does — the endpoint
|
||||
behaves as the MCP authorization spec requires, with nothing to configure:
|
||||
|
||||
Use `REQUIRED` for anything you intend to run in production reachable over the network — it
|
||||
turns "someone forgot to wire up OAuth2" into a startup crash instead of a silently open
|
||||
endpoint. `AUTO` is meant for local development, where spinning up a real identity provider is
|
||||
friction you don't want yet.
|
||||
- `GET /.well-known/oauth-protected-resource/mcp` serves RFC 9728 metadata: the `resource` (the
|
||||
application's `SecurityExtension.origin(...)` plus the path), every issuer as `authorization_servers`,
|
||||
and `scopes_supported` when `McpConfig.scopesSupported(...)` is set;
|
||||
- an anonymous call gets `401` with `WWW-Authenticate: Bearer resource_metadata="…"`;
|
||||
- a token whose `aud` does not include the resource is `403` (RFC 8707) and logged at `WARN`. Credentials
|
||||
that are not audience-bound, such as API keys, are unaffected.
|
||||
|
||||
## Why `flash-ext-oidc` is an *optional* Maven dependency, concretely
|
||||
For Keycloak, the audience comes from an *Audience* protocol mapper whose included custom audience is
|
||||
the resource URL, attached to a client scope every MCP client receives (the built-in `basic` scope is the
|
||||
one that needs no client cooperation). Clients that register dynamically need Keycloak's anonymous
|
||||
client registration policies relaxed for the trusted hosts.
|
||||
|
||||
Maven's `<optional>true</optional>` only affects **transitive** propagation: consumers of
|
||||
`flash-ext-mcp` don't get `flash-ext-oidc` pulled in automatically unless they add it themselves.
|
||||
Within `flash-ext-mcp` itself, `flash-ext-oidc`'s classes are on the compile/test classpath as
|
||||
normal — this extension can (and does) reference `OidcMiddleware`/`ClaimsHolder` directly in
|
||||
source.
|
||||
`McpConfig.requireTokenAudience(false)` drops that last check for an authorization server that cannot
|
||||
mint a resource audience at all — Keycloak ignores RFC 8707's `resource` parameter, so a deployment that
|
||||
cannot add the mapper has no other way in. Every token a registered issuer signs is then accepted on the
|
||||
endpoint, and the boot logs say so.
|
||||
|
||||
That reference is isolated in its own class, `McpOidcIntegration`, invoked only from inside a
|
||||
`catch (NoClassDefFoundError)` block. A bare class-literal like `OidcMiddleware.class` (which
|
||||
`ctx.find(OidcMiddleware.class)` needs) forces the JVM to resolve that type the moment it's
|
||||
evaluated — if `flash-ext-oidc` is not on the *runtime* classpath at all (a genuinely
|
||||
MCP-only install, no OAuth2 anywhere in the app), the first such reference throws
|
||||
`NoClassDefFoundError`. Keeping that reference inside a separate, lazily-loaded class means
|
||||
`McpExtension` itself loads and works fine standalone; only the attempt to actually use OIDC
|
||||
fails, and only when there's something to fail. This mirrors `OidcExtension`'s own lazy bridge to
|
||||
`flash-ext-openapi` — same technique, same reason.
|
||||
## Which credentials
|
||||
|
||||
## OAuth2 resolution details — zero-config by default
|
||||
|
||||
When oidc is available and `security() != NONE`, `McpOidcIntegration` (an isolated,
|
||||
lazily-loaded bridge — see its javadoc) derives everything an MCP OAuth2 resource server needs
|
||||
straight from the installed `OidcMiddleware`, with no additional `McpConfig` calls required:
|
||||
|
||||
1. The MCP route is wrapped with `flash-ext-oidc`'s own `OidcMiddleware.protect(resourceMetadataPath)`
|
||||
— the same Bearer-token/JWKS validation path used everywhere else in Flash5, plus a
|
||||
`resource_metadata` challenge parameter (see below). No JWT parsing or JWKS handling is
|
||||
reimplemented here.
|
||||
2. An audience guard always runs after `protect(...)`: it reads the validated claims from
|
||||
`ClaimsHolder` and rejects (`403`) any token whose `aud` claim does not include the resource
|
||||
identifier — **RFC 8707 Resource Indicators / audience binding**, enforced unconditionally,
|
||||
not opt-in. `OidcMiddleware` itself validates `aud` against its own `clientId` for ID
|
||||
tokens, but deliberately does not enforce audience on access tokens (it varies by provider)
|
||||
— the MCP extension adds that check on top, scoped to its own resource identifier.
|
||||
3. The resource identifier is the canonical URI of the MCP endpoint, resolved **per request** by
|
||||
`OidcMiddleware#selfOrigin` + `rootPath` — the same scheme/host resolution `OidcExtension`
|
||||
uses for its own redirect URIs: `X-Forwarded-Host`/`X-Forwarded-Proto` when the request came
|
||||
through a reverse proxy, otherwise `{selfScheme()}://{Host header}`. Behind a proxy the
|
||||
`Host` alone is the upstream address the proxy dialled, which would publish a resource
|
||||
identifier no client can reach. `McpConfig.resourceIdentifier(...)` still overrides it
|
||||
outright for a proxy that forwards neither header.
|
||||
4. The authorization server issuer is read from `OidcMiddleware#issuer()` unless
|
||||
`McpConfig.authorizationServerIssuer(...)` overrides it.
|
||||
|
||||
## RFC 9728 Protected Resource Metadata
|
||||
|
||||
Whenever the endpoint ends up protected, `flash-ext-mcp` publishes a Protected Resource Metadata
|
||||
document at `/.well-known/oauth-protected-resource{rootPath}` — no explicit `resourceIdentifier`/
|
||||
`authorizationServerIssuer` configuration required, both are auto-derived as described above:
|
||||
|
||||
```json
|
||||
{ "resource": "https://mcp.example.com/mcp", "authorization_servers": ["https://auth.example.com/realms/myrealm"] }
|
||||
```
|
||||
|
||||
`resource` is computed per request from the incoming request's forwarded/`Host` headers (see
|
||||
above), so the document is correct without hardcoding the server's own public URL.
|
||||
|
||||
### `scopes_supported`
|
||||
|
||||
Optional per RFC 9728, omitted from the document entirely unless set via
|
||||
`McpConfig.scopesSupported("openid", "profile", "email")`:
|
||||
|
||||
```json
|
||||
{ "resource": "...", "authorization_servers": ["..."], "scopes_supported": ["openid", "profile", "email"] }
|
||||
```
|
||||
|
||||
This is pure advertisement — token validation doesn't change based on it — but it matters in
|
||||
practice: a client that ignores it and requests no scope at all (many do — see `keycloak.md`)
|
||||
only gets back whatever the authorization server treats as always-included regardless of
|
||||
request, which for Keycloak is just its built-in `basic` scope. A client that *does* read
|
||||
`scopes_supported` and echoes it back in its authorization/token requests gets a token with the
|
||||
claims those scopes actually provide (`profile` → `preferred_username`/`name`, etc.), without
|
||||
needing every one of those claims hand-mapped onto `basic`. Set it to whatever scopes your
|
||||
`McpTool`s actually read off `ClaimsHolder`/`OidcUser` — there's no way to auto-derive this list,
|
||||
it depends entirely on what your tools do with the claims.
|
||||
|
||||
## `WWW-Authenticate: resource_metadata` (RFC 9728 §5.1)
|
||||
|
||||
The MCP Authorization spec **requires** a `401` to carry `resource_metadata` in
|
||||
`WWW-Authenticate`, pointing at the Protected Resource Metadata document above — this is how a
|
||||
spec-compliant client discovers the authorization server without out-of-band configuration.
|
||||
`OidcMiddleware.protect(String resourceMetadataPath)` (an overload added specifically for this)
|
||||
builds that challenge automatically:
|
||||
|
||||
```
|
||||
WWW-Authenticate: Bearer realm="...", resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"
|
||||
```
|
||||
|
||||
The plain `OidcMiddleware.protect()` (no argument), used by every other Flash5 app, is
|
||||
unaffected — this parameter is additive and MCP-specific.
|
||||
|
||||
## Per-tool `@RolesAllowed`/`@ScopesAllowed`
|
||||
|
||||
`McpTool` subclasses can carry `flash-ext-oidc`'s `@RolesAllowed`/`@ScopesAllowed`:
|
||||
By default every mechanism in the chain authenticates `/mcp`, and the session cookie too.
|
||||
`McpConfig.mechanisms(...)` narrows that to the ones named: nothing else is a credential on the endpoint,
|
||||
and only their issuers are published — so a client is sent to exactly the authorization server the
|
||||
endpoint trusts.
|
||||
|
||||
```java
|
||||
@Tool(name = "delete_route", description = "Delete a route")
|
||||
@RolesAllowed("admin")
|
||||
public class DeleteRouteTool extends McpTool {
|
||||
@Override public ToolResponse call(ToolArguments args) { ... }
|
||||
}
|
||||
McpConfig.builder("app").toolsPackage("com.example.tools").mechanisms(authorizationServer).build();
|
||||
```
|
||||
|
||||
This does **not** reuse `flash-ext-oidc`'s per-route middleware mechanism (`ctx.addAnnotationProcessor`,
|
||||
the thing that makes these annotations work on a `RequestHandler`) — it can't: every tool shares
|
||||
one HTTP route (`POST {rootPath}`), already wrapped by whatever `McpSecurity` resolved above, so
|
||||
there is no per-tool route to attach a different middleware chain to. Instead,
|
||||
`McpOidcIntegration.compileToolPolicy` reads the annotations once at boot (`McpRegistry.scan`)
|
||||
and compiles them into a closure (`McpAuthPolicy`) that `McpDispatcher` runs *after* the
|
||||
route-wide auth has already succeeded and *before* invoking the specific tool named in the
|
||||
`tools/call` request — narrowing what's already-authenticated, not replacing it. A denial is a
|
||||
normal `isError: true` tool result (see `ToolResponse.error`), not an HTTP-level rejection — the
|
||||
model sees why, the same as any other tool failure.
|
||||
## Tool policies
|
||||
|
||||
Roles are read via `OidcUser#hasRole` against `McpConfig.rolesClaimPath(...)` (default
|
||||
`"realm_access.roles"`, matching `OidcConfig`'s own default — set this explicitly if the two
|
||||
diverge; there's no way to read `OidcConfig`'s actual configured value from here). Scopes use
|
||||
`OidcUser#hasScope`'s built-in default claim paths (`scope`/`scp`), no extra config needed.
|
||||
`@ScopesAllowed(match = ScopesAllowed.Match.ANY)` and multi-role `@RolesAllowed({"admin",
|
||||
"editor"})` (OR semantics) both work exactly as they do on a `RequestHandler`.
|
||||
The core annotations work on tools as on handlers, checked per `tools/call` against the caller the route
|
||||
authenticated:
|
||||
|
||||
**`@Authenticated` alone has no effect and fails boot.** Once oidc is active for a server, every
|
||||
tool call is already authenticated — there's no per-tool public/authenticated split the way
|
||||
there is for HTTP routes, so a bare `@Authenticated` on a tool can't mean anything and would
|
||||
silently do nothing if allowed to compile. Boot fails instead, with a message pointing at
|
||||
`@RolesAllowed`/`@ScopesAllowed` as the actual narrowing mechanism.
|
||||
```java
|
||||
@Tool(name = "approve", description = "Approves a pending proposal")
|
||||
@RolesAllowed(value = "REVIEWER", on = {"project", "locale"}) // read from the tool's arguments
|
||||
public class ApproveTool extends McpTool { … }
|
||||
```
|
||||
|
||||
**Annotating a tool without active OAuth2 also fails boot**, not silently at request time: if
|
||||
`@RolesAllowed`/`@ScopesAllowed`/`@Authenticated` shows up on a tool while `McpSecurity` resolved
|
||||
to unprotected (`NONE`, or `AUTO` with no oidc installed), that's very likely a forgotten
|
||||
`OidcExtension` install or a `McpSecurity.NONE` left over from local dev — `IllegalStateException`
|
||||
at `app.start()`.
|
||||
A denial is a tool result with `isError: true` — the call reached the server, the tool did not run.
|
||||
|
||||
## The `HttpException` safety net
|
||||
|
||||
`flash-ext-oidc`'s middleware throws `HttpException.unauthorized()`/`forbidden()` on auth
|
||||
failure. Flash5's core does **not** special-case `HttpException` in the default exception
|
||||
handler — the out-of-the-box `AbstractRouter` default always returns a generic `500`, regardless
|
||||
of the thrown exception's embedded status code; only an app that explicitly calls
|
||||
`FlashApp#onException(...)` (or installs something that does) gets `HttpException.status()`
|
||||
honored.
|
||||
|
||||
To keep the MCP endpoint correct regardless of what the rest of the app configures,
|
||||
`McpTransportGuards.httpExceptionGuard()` wraps the whole route and translates `HttpException`
|
||||
into the right HTTP status itself, rather than letting it fall through to the app's (possibly
|
||||
unconfigured) global handler. This is scoped entirely to the MCP route — it does not touch or
|
||||
override the app's `onException` for any other route.
|
||||
`McpConfig.middleware(...)` runs after authentication, for rate limiting, auditing or tracing.
|
||||
|
||||
@@ -19,8 +19,7 @@
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-oidc</artifactId>
|
||||
<optional>true</optional>
|
||||
<artifactId>flash-ext-security-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
@@ -43,6 +42,22 @@
|
||||
<artifactId>flash-testing</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-security-test</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-security-oidc</artifactId>
|
||||
<version>${project.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-security-apikey</artifactId>
|
||||
<version>${project.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Compiled per-tool authorization requirement, built once at boot by {@link McpOidcIntegration}
|
||||
* from {@code @RolesAllowed}/{@code @ScopesAllowed} on an {@link McpTool} subclass — {@code null}
|
||||
* on {@link McpRegistry.RegisteredTool} means no restriction beyond whatever {@link McpSecurity}
|
||||
* already enforces route-wide.
|
||||
*
|
||||
* <p>{@code check} is a closure, not a raw role/scope list — this is what lets this record (and
|
||||
* its only caller, {@link McpDispatcher}) stay free of any compile-time reference to a {@code
|
||||
* flash-ext-oidc} type, preserving the same classload isolation {@link McpOidcIntegration}'s
|
||||
* javadoc describes for the rest of the OIDC bridge. Only the plain-JDK {@link Supplier}
|
||||
* signature crosses the boundary; the closure itself, built once inside {@code
|
||||
* McpOidcIntegration}, is the only place that ever touches {@code OidcUser}/{@code ClaimsHolder}.
|
||||
*
|
||||
* <p>Returns {@code null} from {@link #check()}{@code .get()} when authorized, or a
|
||||
* human-readable denial reason otherwise — invoked once per {@code tools/call} against an
|
||||
* annotated tool, never allocated on that path (the closure and its captured role/scope arrays
|
||||
* are built exactly once, at boot).
|
||||
*/
|
||||
record McpAuthPolicy(Supplier<String> check) {}
|
||||
+38
-38
@@ -1,5 +1,8 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import dev.relism.flash.ext.security.AuthenticationMechanism;
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@@ -24,10 +27,11 @@ public final class McpConfig {
|
||||
private final String rootPath;
|
||||
private final String toolsPackage;
|
||||
private final McpSecurity security;
|
||||
private final String resourceIdentifier;
|
||||
private final String authorizationServerIssuer;
|
||||
private final boolean requireTokenAudience;
|
||||
private final List<String> allowedOrigins;
|
||||
private final List<String> scopesSupported;
|
||||
private final List<Middleware> middleware;
|
||||
private final List<AuthenticationMechanism> mechanisms;
|
||||
|
||||
private McpConfig(Builder b) {
|
||||
this.name = b.name;
|
||||
@@ -36,10 +40,11 @@ public final class McpConfig {
|
||||
this.rootPath = b.rootPath;
|
||||
this.toolsPackage = b.toolsPackage;
|
||||
this.security = b.security;
|
||||
this.resourceIdentifier = b.resourceIdentifier;
|
||||
this.authorizationServerIssuer = b.authorizationServerIssuer;
|
||||
this.requireTokenAudience = b.requireTokenAudience;
|
||||
this.allowedOrigins = List.copyOf(b.allowedOrigins);
|
||||
this.scopesSupported = List.copyOf(b.scopesSupported);
|
||||
this.middleware = List.copyOf(b.middleware);
|
||||
this.mechanisms = List.copyOf(b.mechanisms);
|
||||
}
|
||||
|
||||
String name() { return name; }
|
||||
@@ -48,10 +53,11 @@ public final class McpConfig {
|
||||
String rootPath() { return rootPath; }
|
||||
String toolsPackage() { return toolsPackage; }
|
||||
McpSecurity security() { return security; }
|
||||
String resourceIdentifier() { return resourceIdentifier; }
|
||||
String authorizationServerIssuer() { return authorizationServerIssuer; }
|
||||
boolean requireTokenAudience() { return requireTokenAudience; }
|
||||
List<String> allowedOrigins() { return allowedOrigins; }
|
||||
List<String> scopesSupported() { return scopesSupported; }
|
||||
List<Middleware> middleware() { return middleware; }
|
||||
List<AuthenticationMechanism> mechanisms() { return mechanisms; }
|
||||
|
||||
public static Builder builder(String name) { return new Builder(name); }
|
||||
|
||||
@@ -61,11 +67,12 @@ public final class McpConfig {
|
||||
private String instructions;
|
||||
private String rootPath = "/mcp";
|
||||
private String toolsPackage;
|
||||
private McpSecurity security = McpSecurity.AUTO;
|
||||
private String resourceIdentifier;
|
||||
private String authorizationServerIssuer;
|
||||
private McpSecurity security = McpSecurity.REQUIRED;
|
||||
private boolean requireTokenAudience = true;
|
||||
private final List<String> allowedOrigins = new ArrayList<>();
|
||||
private final List<String> scopesSupported = new ArrayList<>();
|
||||
private final List<Middleware> middleware = new ArrayList<>();
|
||||
private final List<AuthenticationMechanism> mechanisms = new ArrayList<>();
|
||||
|
||||
private Builder(String name) {
|
||||
if (name == null || name.isBlank())
|
||||
@@ -85,28 +92,16 @@ public final class McpConfig {
|
||||
/** Package scanned for {@link Tool @Tool}/{@link Resource @Resource}/{@link Prompt @Prompt} classes. Required. */
|
||||
public Builder toolsPackage(String toolsPackage) { this.toolsPackage = toolsPackage; return this; }
|
||||
|
||||
/** OAuth2 requirement policy. Default {@link McpSecurity#AUTO}. */
|
||||
/** Default {@link McpSecurity#REQUIRED}. */
|
||||
public Builder security(McpSecurity security) { this.security = security; return this; }
|
||||
|
||||
/**
|
||||
* Canonical URI of this MCP endpoint, used for RFC 8707 audience binding: tokens whose
|
||||
* {@code aud} claim does not include this value are rejected. Optional — when
|
||||
* {@code flash-ext-oidc} is installed, this is auto-derived per request from the
|
||||
* forwarded/{@code Host} headers (same resolution {@code OidcExtension} uses for its own
|
||||
* redirect URIs) and audience binding is enforced unconditionally. Set this explicitly
|
||||
* only to override that guess — a reverse proxy that forwards neither
|
||||
* {@code X-Forwarded-Host} nor {@code X-Forwarded-Proto}.
|
||||
* Whether a bearer token must name this endpoint in its {@code aud} (RFC 8707), as the MCP
|
||||
* authorization spec requires. Default {@code true}. Turn it off for an authorization server
|
||||
* that cannot mint a resource audience — every token a registered issuer signs is then
|
||||
* accepted on the endpoint, and a warning is logged at boot.
|
||||
*/
|
||||
public Builder resourceIdentifier(String resourceIdentifier) { this.resourceIdentifier = resourceIdentifier; return this; }
|
||||
|
||||
/**
|
||||
* Authorization server issuer URL, published in the RFC 9728 Protected Resource
|
||||
* Metadata document at {@code /.well-known/oauth-protected-resource{rootPath}}. Optional
|
||||
* — when {@code flash-ext-oidc} is installed, this is auto-derived from its configured
|
||||
* issuer. Set this explicitly only to override that (e.g. publishing a different issuer
|
||||
* than the one actually validating tokens).
|
||||
*/
|
||||
public Builder authorizationServerIssuer(String issuer) { this.authorizationServerIssuer = issuer; return this; }
|
||||
public Builder requireTokenAudience(boolean require) { this.requireTokenAudience = require; return this; }
|
||||
|
||||
/**
|
||||
* Origins allowed to call the MCP endpoint (DNS-rebinding protection, per the Streamable
|
||||
@@ -115,19 +110,24 @@ public final class McpConfig {
|
||||
*/
|
||||
public Builder allowedOrigins(String... origins) { this.allowedOrigins.addAll(List.of(origins)); return this; }
|
||||
|
||||
/**
|
||||
* OAuth2 scopes this server expects clients to request, published as {@code
|
||||
* scopes_supported} in the RFC 9728 Protected Resource Metadata document. Optional per
|
||||
* the spec — omitted from the document entirely if never set. A spec-compliant client
|
||||
* reads this to know what to put in its authorization/token requests instead of
|
||||
* requesting nothing; see {@code docs/keycloak.md}'s "same story for any other claim"
|
||||
* section for why this matters in practice (a client that requests no scope only gets
|
||||
* whatever your authorization server treats as always-included, e.g. Keycloak's `basic`).
|
||||
* Purely advertisement — this server still validates whatever token it actually receives
|
||||
* the same way regardless of what a client requested.
|
||||
*/
|
||||
/** Published as {@code scopes_supported} in the RFC 9728 metadata, so OAuth clients request them. */
|
||||
public Builder scopesSupported(String... scopes) { this.scopesSupported.addAll(List.of(scopes)); return this; }
|
||||
|
||||
/**
|
||||
* The only mechanisms that authenticate the endpoint, and the only issuers its RFC 9728 metadata
|
||||
* names: any other credential, the session cookie included, is none here. Default: the whole chain.
|
||||
*/
|
||||
public Builder mechanisms(AuthenticationMechanism... mechanisms) {
|
||||
this.mechanisms.addAll(List.of(mechanisms));
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Runs on the MCP route after the transport guards and authentication — rate limiting, auditing, tracing. */
|
||||
public Builder middleware(Middleware... middleware) {
|
||||
this.middleware.addAll(List.of(middleware));
|
||||
return this;
|
||||
}
|
||||
|
||||
public McpConfig build() {
|
||||
if (toolsPackage == null || toolsPackage.isBlank())
|
||||
throw new IllegalStateException(
|
||||
|
||||
+11
-7
@@ -3,6 +3,8 @@ package dev.relism.flash.ext.mcp;
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.ext.security.SecurityIdentity;
|
||||
import dev.relism.flash.ext.security.SecurityPolicy;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.Response;
|
||||
|
||||
@@ -18,10 +20,10 @@ import java.io.IOException;
|
||||
* tool/resource/prompt name, resource/prompt handler exceptions) is a JSON-RPC error object,
|
||||
* always returned with HTTP 200: the HTTP request itself succeeded, only the RPC did not. Only
|
||||
* malformed HTTP-level input (unparsable JSON, not a JSON object) gets HTTP 400. A
|
||||
* {@code @RolesAllowed}/{@code @ScopesAllowed} denial (see {@link McpAuthPolicy}) is the same
|
||||
* {@code @RolesAllowed}/{@code @ScopesAllowed} denial (see {@link SecurityPolicy}) is the same
|
||||
* category — {@code isError: true}, tool never invoked — not a transport-level rejection; the
|
||||
* route-wide 401/403 for "not authenticated at all" already happened earlier, in the {@code
|
||||
* OidcMiddleware}/audience-guard middleware chain, before this dispatcher ever runs.
|
||||
* route-wide 401/403 already happened earlier, in the security middleware, before this
|
||||
* dispatcher ever runs.
|
||||
*/
|
||||
final class McpDispatcher {
|
||||
|
||||
@@ -136,12 +138,14 @@ final class McpDispatcher {
|
||||
if (tool == null)
|
||||
throw McpProtocolException.invalidParams("Unknown tool: " + name);
|
||||
|
||||
ToolArguments args = new ToolArguments(params.path("arguments"));
|
||||
SecurityPolicy policy = tool.policy();
|
||||
ToolResponse result;
|
||||
String denied = tool.policy() != null ? tool.policy().check().get() : null;
|
||||
if (denied != null) {
|
||||
result = ToolResponse.error("Tool \"" + name + "\" denied: " + denied);
|
||||
if (policy != null && !policy.permitsScopes(SecurityIdentity.current())) {
|
||||
result = ToolResponse.error("Tool \"" + name + "\" denied: missing scope");
|
||||
} else if (policy != null && !policy.permitsRoles(SecurityIdentity.current(), args::getString)) {
|
||||
result = ToolResponse.error("Tool \"" + name + "\" denied: missing role");
|
||||
} else {
|
||||
ToolArguments args = new ToolArguments(params.path("arguments"));
|
||||
try {
|
||||
result = tool.instance().call(args);
|
||||
} catch (Exception e) {
|
||||
|
||||
+80
-86
@@ -1,5 +1,12 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import dev.relism.flash.exceptions.HttpException;
|
||||
import dev.relism.flash.ext.security.AuthenticationEntryPoint;
|
||||
import dev.relism.flash.ext.security.AuthenticationMechanism;
|
||||
import dev.relism.flash.ext.security.SecurityExtension;
|
||||
import dev.relism.flash.ext.security.SecurityIdentity;
|
||||
import dev.relism.flash.ext.security.SecurityPolicy;
|
||||
import dev.relism.flash.ext.security.SecurityScheme;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import dev.relism.flash.extension.FlashExtension;
|
||||
import dev.relism.flash.extension.FlashRegistrar;
|
||||
@@ -9,43 +16,31 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* MCP (Model Context Protocol) server extension. Streamable HTTP transport — a single
|
||||
* {@code POST} JSON-RPC endpoint, stateless in this revision (no session, no SSE stream; see
|
||||
* {@code docs/transport.md}) — dispatch precompiled at boot from classes annotated with
|
||||
* {@link Tool @Tool}/{@link Resource @Resource}/{@link Prompt @Prompt} under
|
||||
* MCP (Model Context Protocol) server extension. Streamable HTTP transport — a single {@code POST}
|
||||
* JSON-RPC endpoint, stateless in this revision (see {@code docs/transport.md}) — dispatching to
|
||||
* {@link Tool @Tool}/{@link Resource @Resource}/{@link Prompt @Prompt} classes under
|
||||
* {@link McpConfig#toolsPackage(String)}.
|
||||
*
|
||||
* <pre>{@code
|
||||
* // Standalone, no OAuth2
|
||||
* FlashApp.create(8080)
|
||||
* .install(new McpExtension(McpConfig.builder("my-mcp-server")
|
||||
* .toolsPackage("com.example.tools")
|
||||
* .build()))
|
||||
* .start();
|
||||
*
|
||||
* // With flash-ext-oidc as the OAuth2 resource server — zero extra config: issuer, canonical
|
||||
* // resource identifier, RFC 8707 audience binding and RFC 9728 metadata are all derived from
|
||||
* // the installed OidcExtension.
|
||||
* FlashApp.create(8080)
|
||||
* .install(new OidcExtension(oidcConfig))
|
||||
* .install(new McpExtension(McpConfig.builder("my-mcp-server")
|
||||
* .toolsPackage("com.example.tools")
|
||||
* .security(McpSecurity.REQUIRED)
|
||||
* .build()))
|
||||
* .start();
|
||||
* app.install(new SecurityExtension())
|
||||
* .install(new OidcExtension(OidcProvider.of("sso", issuer, clientId, secret)))
|
||||
* .install(new McpExtension(McpConfig.builder("my-server").toolsPackage("com.example.tools").build()));
|
||||
* }</pre>
|
||||
*
|
||||
* <p>One server per {@code McpExtension} instance — install multiple instances (distinct
|
||||
* {@code rootPath}, distinct {@code toolsPackage}) for multiple MCP servers on one app,
|
||||
* mirroring the {@code OidcExtension} multi-tenant pattern. See {@code docs/security.md} for
|
||||
* the full OAuth2 resolution rules.
|
||||
* <p>Every call is authenticated by the application's security chain — OAuth2 bearer tokens, API
|
||||
* keys, anything registered. When an OAuth2 issuer is among its schemes, the endpoint is also an
|
||||
* OAuth2 protected resource: RFC 9728 metadata, a {@code resource_metadata} challenge, and RFC 8707
|
||||
* audience binding for audience-bound tokens. Tool annotations are enforced per call, with
|
||||
* {@code @RolesAllowed(on = ...)} reading tool arguments.
|
||||
*/
|
||||
@Slf4j
|
||||
public class McpExtension implements FlashExtension {
|
||||
|
||||
private final McpConfig config;
|
||||
private volatile List<SecurityScheme> schemes;
|
||||
|
||||
public McpExtension(McpConfig config) {
|
||||
this.config = config;
|
||||
@@ -53,68 +48,67 @@ public class McpExtension implements FlashExtension {
|
||||
|
||||
@Override
|
||||
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
ctx.onReady(() -> registerRoutes(app, ctx));
|
||||
}
|
||||
ctx.onReady(() -> {
|
||||
SecurityExtension security = config.security() == McpSecurity.NONE ? null : ctx.find(SecurityExtension.class)
|
||||
.orElseThrow(() -> new IllegalStateException("MCP server \"" + config.name()
|
||||
+ "\" requires flash-ext-security-core: install a SecurityExtension, or set McpSecurity.NONE for a public server"));
|
||||
McpDispatcher dispatcher = new McpDispatcher(McpRegistry.scan(config.toolsPackage(), ctx, security),
|
||||
config.name(), config.version(), config.instructions());
|
||||
|
||||
private void registerRoutes(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
// Resolved before scanning so McpRegistry knows, per tool, whether @RolesAllowed/
|
||||
// @ScopesAllowed are backed by real OAuth2 protection or a boot-time misconfiguration
|
||||
// (see McpOidcIntegration#compileToolPolicy) — must run first, not after.
|
||||
McpOidcIntegration.Resolved secured = resolveSecurity(ctx);
|
||||
McpRegistry registry = McpRegistry.scan(config.toolsPackage(), ctx, secured != null,
|
||||
secured == null ? null : secured.rolesClaimPath());
|
||||
McpDispatcher dispatcher = new McpDispatcher(registry, config.name(), config.version(), config.instructions());
|
||||
|
||||
List<Middleware> chain = new ArrayList<>(3);
|
||||
chain.add(McpTransportGuards.httpExceptionGuard());
|
||||
chain.add(McpTransportGuards.originGuard(config.allowedOrigins()));
|
||||
if (secured != null) chain.add(secured.security());
|
||||
|
||||
app.post(config.rootPath(), (req, res) -> { dispatcher.handle(req, res); return null; },
|
||||
chain.toArray(Middleware[]::new));
|
||||
|
||||
registerResourceMetadata(app, secured);
|
||||
}
|
||||
|
||||
private McpOidcIntegration.Resolved resolveSecurity(FlashContext ctx) {
|
||||
if (config.security() == McpSecurity.NONE) return null;
|
||||
|
||||
McpOidcIntegration.Resolved resolved;
|
||||
try {
|
||||
resolved = McpOidcIntegration.resolve(ctx, config);
|
||||
} catch (NoClassDefFoundError e) {
|
||||
resolved = null; // flash-ext-oidc not on the classpath at all
|
||||
}
|
||||
if (resolved != null) return resolved;
|
||||
|
||||
if (config.security() == McpSecurity.REQUIRED) {
|
||||
throw new IllegalStateException(
|
||||
"McpSecurity.REQUIRED but flash-ext-oidc is not installed for MCP server \"" + config.name() +
|
||||
"\" — install an OidcExtension before this McpExtension, or relax security to " +
|
||||
"McpSecurity.AUTO/NONE if this server is meant to be public.");
|
||||
}
|
||||
|
||||
log.warn("[flash-ext-mcp] MCP server \"{}\" is running WITHOUT OAuth2 protection — " +
|
||||
"flash-ext-oidc is not installed and McpSecurity.AUTO degrades to unprotected. " +
|
||||
"Install flash-ext-oidc or set McpSecurity.REQUIRED to make this a hard failure instead.",
|
||||
config.name());
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 9728 Protected Resource Metadata, built once security is resolved — no longer
|
||||
* conditioned on {@code resourceIdentifier}/{@code authorizationServerIssuer} being set
|
||||
* explicitly, since {@link McpOidcIntegration#resolve} now derives both by default. The
|
||||
* {@code resource} field is computed per request (it depends on that request's own
|
||||
* forwarded/{@code Host} headers) via {@link McpOidcIntegration.Resolved#resourceIdentifier()}.
|
||||
*/
|
||||
private void registerResourceMetadata(FlashRegistrar<?> app, McpOidcIntegration.Resolved secured) {
|
||||
if (secured == null) return;
|
||||
String path = "/.well-known/oauth-protected-resource" + config.rootPath();
|
||||
app.get(path, (req, res) -> {
|
||||
res.type(ContentType.JSON);
|
||||
return McpResourceMetadata.build(
|
||||
secured.resourceIdentifier().apply(req), secured.issuer(), config.scopesSupported());
|
||||
List<Middleware> chain = new ArrayList<>(List.of(
|
||||
McpTransportGuards.httpExceptionGuard(), McpTransportGuards.originGuard(config.allowedOrigins())));
|
||||
if (security != null) protect(app, security, chain);
|
||||
chain.addAll(config.middleware());
|
||||
app.post(config.rootPath(), (req, res) -> {
|
||||
dispatcher.handle(req, res);
|
||||
return null;
|
||||
}, chain.toArray(Middleware[]::new));
|
||||
});
|
||||
}
|
||||
|
||||
private void protect(FlashRegistrar<?> app, SecurityExtension security, List<Middleware> chain) {
|
||||
String metadataPath = "/.well-known/oauth-protected-resource" + config.rootPath();
|
||||
List<AuthenticationMechanism> only = config.mechanisms();
|
||||
AuthenticationEntryPoint anonymous = (req, res) -> {
|
||||
List<SecurityScheme> schemes = schemes(security);
|
||||
res.header("WWW-Authenticate", issuers(schemes, null).isEmpty()
|
||||
? String.join(", ", schemes.stream().map(SecurityScheme::challenge).toList())
|
||||
: "Bearer resource_metadata=\"" + security.origin(req) + metadataPath + "\"");
|
||||
throw HttpException.unauthorized();
|
||||
};
|
||||
chain.add(only.isEmpty() ? security.enforce(SecurityPolicy.AUTHENTICATED, anonymous)
|
||||
: security.enforce(SecurityPolicy.AUTHENTICATED, anonymous, only));
|
||||
if (config.requireTokenAudience()) {
|
||||
chain.add(next -> (req, res) -> {
|
||||
String resource = security.origin(req) + config.rootPath();
|
||||
if (!SecurityIdentity.current().principal().hasAudience(resource)) {
|
||||
log.warn("[flash-ext-mcp] Rejected a token not issued for {} (RFC 8707) — the authorization server must put it in aud", resource);
|
||||
throw HttpException.forbidden();
|
||||
}
|
||||
return next.handle(req, res);
|
||||
});
|
||||
} else {
|
||||
log.warn("[flash-ext-mcp] Token audience validation (RFC 8707) is DISABLED for {} — every token a registered issuer signs is accepted.", config.rootPath());
|
||||
}
|
||||
app.get(metadataPath, (req, res) -> {
|
||||
List<String> issuers = issuers(schemes(security), security.origin(req));
|
||||
if (issuers.isEmpty()) throw HttpException.notFound("Protected resource metadata");
|
||||
res.type(ContentType.JSON);
|
||||
return McpResourceMetadata.build(security.origin(req) + config.rootPath(), issuers, config.scopesSupported());
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolved at the first request, once every mechanism has registered — which may be after this extension was ready. */
|
||||
private List<SecurityScheme> schemes(SecurityExtension security) {
|
||||
if (schemes == null) {
|
||||
schemes = config.mechanisms().isEmpty() ? security.schemes()
|
||||
: config.mechanisms().stream().flatMap(mechanism -> mechanism.schemes().stream()).toList();
|
||||
}
|
||||
return schemes;
|
||||
}
|
||||
|
||||
/** {@code "/"} is the application's own authorization server, at {@code origin}. */
|
||||
private static List<String> issuers(List<SecurityScheme> schemes, String origin) {
|
||||
return schemes.stream().map(SecurityScheme::issuer).filter(Objects::nonNull).map(issuer -> issuer.equals("/") ? origin : issuer).toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import java.nio.charset.StandardCharsets;
|
||||
*
|
||||
* <p>Not wired to {@code flash-ext-jackson} on purpose: the MCP JSON-RPC envelope is internal
|
||||
* protocol plumbing, not a user-facing serialization concern, so this extension owns its
|
||||
* mapper independently — same reasoning {@code flash-ext-oidc} applies to its own JSON needs
|
||||
* mapper independently — the same reasoning any protocol-level extension applies to its own JSON needs
|
||||
* (see {@code json-smart} there). See {@code docs/jackson-interop.md} for the full rationale
|
||||
* and how a future opt-in reuse of a shared {@code ObjectMapper} could work.
|
||||
*/
|
||||
|
||||
-180
@@ -1,180 +0,0 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import dev.relism.flash.ext.oidc.Authenticated;
|
||||
import dev.relism.flash.ext.oidc.ClaimsHolder;
|
||||
import dev.relism.flash.ext.oidc.OidcMiddleware;
|
||||
import dev.relism.flash.ext.oidc.OidcUser;
|
||||
import dev.relism.flash.ext.oidc.RolesAllowed;
|
||||
import dev.relism.flash.ext.oidc.ScopesAllowed;
|
||||
import dev.relism.flash.exceptions.HttpException;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Lazy, isolated bridge to {@code flash-ext-oidc}.
|
||||
*
|
||||
* <p>References to OIDC types only ever resolve when {@link #resolve}/{@link #compileToolPolicy}
|
||||
* are actually invoked — never at {@link McpExtension} class-load time — because they live in
|
||||
* this separate nested class. The caller wraps the invocation in {@code catch
|
||||
* (NoClassDefFoundError)}, exactly like {@code OidcExtension}'s own lazy bridge to {@code
|
||||
* flash-ext-openapi}. This is what lets {@code flash-ext-mcp} run standalone (MCP-only, no
|
||||
* OAuth2) when {@code flash-ext-oidc} is not even on the classpath. {@link Resolved}/{@link
|
||||
* McpAuthPolicy} carry only oidc-free types back out ({@link Middleware}, {@link String}, a
|
||||
* {@link Function}, a {@link Supplier}) so no other class in this package ever has to reference
|
||||
* an OIDC type.
|
||||
*
|
||||
* <p>Zero-config by design: when {@code flash-ext-oidc} is installed, everything an MCP OAuth2
|
||||
* resource server needs — issuer, canonical resource identifier, RFC 8707 audience binding, and
|
||||
* a spec-compliant {@code WWW-Authenticate} challenge (RFC 9728 §5.1) — is derived straight from
|
||||
* the installed {@link OidcMiddleware}, with no additional {@link McpConfig} calls.
|
||||
* {@link McpConfig#resourceIdentifier(String)}/{@link McpConfig#authorizationServerIssuer(String)}
|
||||
* remain as explicit overrides for the rare case where that guess is wrong.
|
||||
*/
|
||||
@Slf4j
|
||||
final class McpOidcIntegration {
|
||||
|
||||
private static final String[] NO_VALUES = new String[0];
|
||||
|
||||
private McpOidcIntegration() {}
|
||||
|
||||
/** Everything {@link McpExtension} needs once oidc security is resolved. */
|
||||
record Resolved(Middleware security, String issuer, String rolesClaimPath,
|
||||
Function<Request, String> resourceIdentifier) {}
|
||||
|
||||
/** Returns the resolved security bundle, or {@code null} if oidc is not installed. */
|
||||
static Resolved resolve(FlashContext ctx, McpConfig config) {
|
||||
Optional<OidcMiddleware> oidc = ctx.find(OidcMiddleware.class);
|
||||
if (oidc.isEmpty()) return null;
|
||||
|
||||
OidcMiddleware oidcMw = oidc.get();
|
||||
String resourceMetadataPath = "/.well-known/oauth-protected-resource" + config.rootPath();
|
||||
String issuer = config.authorizationServerIssuer() != null
|
||||
? config.authorizationServerIssuer() : oidcMw.issuer();
|
||||
Function<Request, String> resourceId = req -> config.resourceIdentifier() != null
|
||||
? config.resourceIdentifier()
|
||||
: OidcMiddleware.selfOrigin(req, oidcMw.selfScheme()) + config.rootPath();
|
||||
|
||||
Middleware protect = oidcMw.protect(resourceMetadataPath);
|
||||
Middleware secured = Middleware.of(protect, audienceGuard(resourceId));
|
||||
return new Resolved(secured, issuer, oidcMw.rolesClaimPath(), resourceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 8707 audience binding, unconditionally enforced once oidc is protecting the MCP
|
||||
* route — no longer opt-in behind an explicit {@code resourceIdentifier(...)} call.
|
||||
*/
|
||||
private static Middleware audienceGuard(Function<Request, String> resourceIdentifier) {
|
||||
return next -> (req, res) -> {
|
||||
Map<String, Object> claims = ClaimsHolder.get();
|
||||
String expected = resourceIdentifier.apply(req);
|
||||
if (claims != null && !audienceMatches(claims.get("aud"), expected)) {
|
||||
log.warn("[flash-ext-mcp] Rejecting token (RFC 8707): aud={} does not include expected " +
|
||||
"resource identifier \"{}\" — the authorization server must include this exact " +
|
||||
"value in the access token's aud claim (e.g. an Audience protocol mapper in " +
|
||||
"Keycloak) for this MCP server to accept it.", claims.get("aud"), expected);
|
||||
throw HttpException.forbidden();
|
||||
}
|
||||
return next.handle(req, res);
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean audienceMatches(Object aud, String expected) {
|
||||
if (aud instanceof String s) return s.equals(expected);
|
||||
if (aud instanceof Iterable<?> it) {
|
||||
for (Object o : it) if (expected.equals(String.valueOf(o))) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiles {@code @RolesAllowed}/{@code @ScopesAllowed} on a tool class into a {@link
|
||||
* McpAuthPolicy}, or returns {@code null} if the tool carries none of the three OIDC
|
||||
* annotations. Called once per tool at boot ({@link McpRegistry#scan}), never on the
|
||||
* request hot path — the {@link Supplier} it returns is what runs per {@code tools/call},
|
||||
* closing over the already-normalized role/scope arrays so the hot path itself allocates
|
||||
* nothing beyond what {@link OidcUser#hasRole}/{@link OidcUser#hasScope} already do.
|
||||
*
|
||||
* <p>Fails fast at boot, not silently at request time, for the two ways this can be
|
||||
* misconfigured: the annotation present without OAuth2 actually protecting this MCP server
|
||||
* ({@code oidcActive == false}), and {@code @Authenticated} — which has no per-tool meaning
|
||||
* here (see below) — used at all.
|
||||
*/
|
||||
static McpAuthPolicy compileToolPolicy(Class<? extends McpTool> toolClass, boolean oidcActive,
|
||||
String rolesClaimPath) {
|
||||
Authenticated auth = toolClass.getAnnotation(Authenticated.class);
|
||||
RolesAllowed roles = toolClass.getAnnotation(RolesAllowed.class);
|
||||
ScopesAllowed scopes = toolClass.getAnnotation(ScopesAllowed.class);
|
||||
if (auth == null && roles == null && scopes == null) return null;
|
||||
|
||||
if (!oidcActive) {
|
||||
throw new IllegalStateException(
|
||||
"MCP tool \"" + toolClass.getSimpleName() + "\" declares @Authenticated/@RolesAllowed/" +
|
||||
"@ScopesAllowed, but this MCP server has no active OAuth2 protection — flash-ext-oidc " +
|
||||
"is not installed for it, or McpSecurity is NONE. These annotations require " +
|
||||
"McpSecurity.AUTO/REQUIRED with an OidcExtension installed; install one, or remove the " +
|
||||
"annotation from " + toolClass.getSimpleName() + ".");
|
||||
}
|
||||
if (auth != null) {
|
||||
throw new IllegalStateException(
|
||||
"MCP tool \"" + toolClass.getSimpleName() + "\" is annotated @Authenticated, which has " +
|
||||
"no effect on an McpTool: the whole MCP endpoint is already all-or-nothing " +
|
||||
"authenticated once oidc is active (McpSecurity.AUTO/REQUIRED) — unlike a RequestHandler " +
|
||||
"route, there is no per-tool public/authenticated split to opt into. Remove it, or use " +
|
||||
"@RolesAllowed/@ScopesAllowed to narrow further.");
|
||||
}
|
||||
|
||||
String[] requiredRoles = roles != null ? normalizeRequired("RolesAllowed", roles.value()) : NO_VALUES;
|
||||
String[] requiredScopes = scopes != null ? normalizeRequired("ScopesAllowed", scopes.value()) : NO_VALUES;
|
||||
ScopesAllowed.Match scopeMatch = scopes != null ? scopes.match() : ScopesAllowed.Match.ALL;
|
||||
|
||||
Supplier<String> check = () -> {
|
||||
OidcUser user = ClaimsHolder.user();
|
||||
if (user == null) return "not authenticated";
|
||||
if (requiredRoles.length > 0 && !hasAnyRole(user, rolesClaimPath, requiredRoles))
|
||||
return "missing required role (any of: " + String.join(", ", requiredRoles) + ")";
|
||||
if (requiredScopes.length > 0 && !hasScopes(user, requiredScopes, scopeMatch))
|
||||
return "missing required scope (" + scopeMatch + " of: " + String.join(", ", requiredScopes) + ")";
|
||||
return null;
|
||||
};
|
||||
return new McpAuthPolicy(check);
|
||||
}
|
||||
|
||||
private static boolean hasAnyRole(OidcUser user, String claimPath, String[] roles) {
|
||||
for (String role : roles) if (user.hasRole(claimPath, role)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean hasScopes(OidcUser user, String[] scopes, ScopesAllowed.Match match) {
|
||||
if (match == ScopesAllowed.Match.ALL) {
|
||||
for (String scope : scopes) if (!user.hasScope(scope)) return false;
|
||||
return true;
|
||||
}
|
||||
for (String scope : scopes) if (user.hasScope(scope)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Mirrors {@code OidcAuthPolicy}'s own normalization — trim, dedupe, require non-blank. */
|
||||
private static String[] normalizeRequired(String annotationName, String[] values) {
|
||||
if (values == null || values.length == 0)
|
||||
throw new IllegalStateException("@" + annotationName + " requires at least one value");
|
||||
|
||||
LinkedHashSet<String> normalized = new LinkedHashSet<>(values.length);
|
||||
for (String raw : values) {
|
||||
if (raw == null) continue;
|
||||
String trimmed = raw.trim();
|
||||
if (!trimmed.isEmpty()) normalized.add(trimmed);
|
||||
}
|
||||
if (normalized.isEmpty())
|
||||
throw new IllegalStateException("@" + annotationName + " requires at least one non-empty value");
|
||||
|
||||
return normalized.toArray(String[]::new);
|
||||
}
|
||||
}
|
||||
+8
-29
@@ -2,6 +2,8 @@ package dev.relism.flash.ext.mcp;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import dev.relism.flash.exceptions.InitializationException;
|
||||
import dev.relism.flash.ext.security.SecurityExtension;
|
||||
import dev.relism.flash.ext.security.SecurityPolicy;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -25,7 +27,7 @@ final class McpRegistry {
|
||||
private static final String EMPTY_ARRAY = "[]";
|
||||
|
||||
/** {@code policy} is {@code null} unless the tool carries @RolesAllowed/@ScopesAllowed. */
|
||||
record RegisteredTool(String name, McpTool instance, McpAuthPolicy policy) {}
|
||||
record RegisteredTool(String name, McpTool instance, SecurityPolicy policy) {}
|
||||
record RegisteredResource(String uri, McpResource instance) {}
|
||||
record RegisteredPrompt(String name, McpPrompt instance) {}
|
||||
|
||||
@@ -39,15 +41,8 @@ final class McpRegistry {
|
||||
|
||||
private McpRegistry() {}
|
||||
|
||||
/**
|
||||
* @param oidcActive whether this MCP server's route is actually OAuth2-protected right
|
||||
* now (see {@link McpOidcIntegration#resolve}) — gates whether
|
||||
* {@code @RolesAllowed}/{@code @ScopesAllowed} on a tool are honored or
|
||||
* rejected at boot as a misconfiguration; see
|
||||
* {@link McpOidcIntegration#compileToolPolicy}.
|
||||
* @param rolesClaimPath claim path resolved from the installed OIDC extension.
|
||||
*/
|
||||
static McpRegistry scan(String packageName, FlashContext ctx, boolean oidcActive, String rolesClaimPath) {
|
||||
/** @param security {@code null} for a server running with {@link McpSecurity#NONE} */
|
||||
static McpRegistry scan(String packageName, FlashContext ctx, SecurityExtension security) {
|
||||
McpPackageScanner.ScanResult found = McpPackageScanner.scan(packageName);
|
||||
McpRegistry registry = new McpRegistry();
|
||||
|
||||
@@ -55,7 +50,9 @@ final class McpRegistry {
|
||||
Tool ann = cls.getAnnotation(Tool.class);
|
||||
McpTool instance = instantiate(cls);
|
||||
instance.bind(ctx);
|
||||
McpAuthPolicy policy = compileToolPolicy(cls, oidcActive, rolesClaimPath);
|
||||
if (security == null && SecurityPolicy.of(cls) != null)
|
||||
throw new InitializationException("MCP tool \"" + ann.name() + "\" declares security annotations, but the server runs with McpSecurity.NONE");
|
||||
SecurityPolicy policy = security == null ? null : security.policy(cls);
|
||||
if (registry.tools.putIfAbsent(ann.name(), new RegisteredTool(ann.name(), instance, policy)) != null)
|
||||
throw new InitializationException("Duplicate MCP tool name: \"" + ann.name() + "\"");
|
||||
}
|
||||
@@ -173,24 +170,6 @@ final class McpRegistry {
|
||||
gen.writeEndArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Isolated the same way {@link McpOidcIntegration#resolve} is — {@code
|
||||
* NoClassDefFoundError} here means {@code flash-ext-oidc} genuinely isn't on the runtime
|
||||
* classpath, in which case a tool couldn't have been compiled against
|
||||
* {@code @RolesAllowed}/{@code @ScopesAllowed} in the first place, so there's nothing to
|
||||
* check (and nothing lost: {@code oidcActive} is only ever {@code true} once {@link
|
||||
* McpOidcIntegration#resolve} has already succeeded once this boot, which proves those
|
||||
* types resolve fine).
|
||||
*/
|
||||
private static McpAuthPolicy compileToolPolicy(Class<? extends McpTool> cls, boolean oidcActive,
|
||||
String rolesClaimPath) {
|
||||
try {
|
||||
return McpOidcIntegration.compileToolPolicy(cls, oidcActive, rolesClaimPath);
|
||||
} catch (NoClassDefFoundError e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static <T> T instantiate(Class<T> cls) {
|
||||
try {
|
||||
Constructor<T> ctor = cls.getDeclaredConstructor();
|
||||
|
||||
+5
-5
@@ -2,18 +2,18 @@ package dev.relism.flash.ext.mcp;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** RFC 9728 OAuth 2.0 Protected Resource Metadata document, built once at boot. */
|
||||
/** RFC 9728 OAuth 2.0 Protected Resource Metadata. */
|
||||
final class McpResourceMetadata {
|
||||
|
||||
private McpResourceMetadata() {}
|
||||
|
||||
/** {@code scopesSupported} is optional per RFC 9728 — omitted from the document if empty. */
|
||||
static String build(String resourceIdentifier, String authorizationServerIssuer, List<String> scopesSupported) {
|
||||
/** {@code scopesSupported} is optional — omitted when empty. */
|
||||
static String build(String resource, List<String> authorizationServers, List<String> scopesSupported) {
|
||||
return McpJson.buildString(gen -> {
|
||||
gen.writeStartObject();
|
||||
gen.writeStringField("resource", resourceIdentifier);
|
||||
gen.writeStringField("resource", resource);
|
||||
gen.writeArrayFieldStart("authorization_servers");
|
||||
gen.writeString(authorizationServerIssuer);
|
||||
for (String issuer : authorizationServers) gen.writeString(issuer);
|
||||
gen.writeEndArray();
|
||||
if (!scopesSupported.isEmpty()) {
|
||||
gen.writeArrayFieldStart("scopes_supported");
|
||||
|
||||
+3
-9
@@ -1,17 +1,11 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
/**
|
||||
* OAuth2 requirement policy for the MCP endpoint, resolved against whether
|
||||
* {@code flash-ext-oidc} is installed ({@code ctx.find(OidcMiddleware.class)}).
|
||||
*/
|
||||
/** Whether the MCP endpoint requires an authenticated caller. */
|
||||
public enum McpSecurity {
|
||||
|
||||
/** Fail fast at boot if {@code flash-ext-oidc} is not installed — never expose an unprotected MCP endpoint. */
|
||||
/** The default: every call is authenticated by {@code flash-ext-security-core}, which must be installed. */
|
||||
REQUIRED,
|
||||
|
||||
/** Protect the endpoint if {@code flash-ext-oidc} is installed; otherwise run unprotected and log a warning. */
|
||||
AUTO,
|
||||
|
||||
/** Never protect the endpoint, even if {@code flash-ext-oidc} is installed elsewhere in the app. */
|
||||
/** A public endpoint. Tools declaring security annotations fail the boot. */
|
||||
NONE
|
||||
}
|
||||
|
||||
+2
-2
@@ -19,7 +19,7 @@ final class McpTransportGuards {
|
||||
* allowed through — only a <em>present but disallowed</em> value is rejected.
|
||||
*
|
||||
* <p>If {@code allowedOrigins} is empty, validation is skipped and a boot-time warning is
|
||||
* logged — same graceful-degradation shape as {@link McpSecurity#AUTO}.
|
||||
* logged.
|
||||
*/
|
||||
static Middleware originGuard(List<String> allowedOrigins) {
|
||||
if (allowedOrigins.isEmpty()) {
|
||||
@@ -38,7 +38,7 @@ final class McpTransportGuards {
|
||||
|
||||
/**
|
||||
* Safety net around the whole MCP route: translates {@link HttpException} (thrown by
|
||||
* {@link #originGuard} or by {@code flash-ext-oidc}'s middleware) into a proper HTTP status
|
||||
* {@link #originGuard} or by {@code flash-ext-security-core}) into a proper HTTP status
|
||||
* directly, instead of relying on the app's global exception handler — which defaults to a
|
||||
* generic 500 for every exception type unless the app owner overrides it (see
|
||||
* {@code AbstractRouter}'s default {@code exceptionHandler}). Keeps the MCP endpoint
|
||||
|
||||
-109
@@ -1,109 +0,0 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import com.nimbusds.jose.JWSAlgorithm;
|
||||
import com.nimbusds.jose.JWSHeader;
|
||||
import com.nimbusds.jose.crypto.RSASSASigner;
|
||||
import com.nimbusds.jose.jwk.JWKSet;
|
||||
import com.nimbusds.jose.jwk.KeyUse;
|
||||
import com.nimbusds.jose.jwk.RSAKey;
|
||||
import com.nimbusds.jwt.JWTClaimsSet;
|
||||
import com.nimbusds.jwt.SignedJWT;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
|
||||
import java.io.OutputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.interfaces.RSAPrivateKey;
|
||||
import java.security.interfaces.RSAPublicKey;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Minimal, self-contained fake OIDC provider for tests: real discovery document, real JWKS
|
||||
* endpoint, real RS256-signed tokens — no network dependency beyond localhost, no mocking
|
||||
* framework. Exercises {@code flash-ext-oidc}'s actual discovery + JWKS + JWT validation path.
|
||||
*/
|
||||
final class FakeOidcProvider implements AutoCloseable {
|
||||
|
||||
private final HttpServer server;
|
||||
private final String issuer;
|
||||
private final RSAKey rsaKey;
|
||||
|
||||
FakeOidcProvider() throws Exception {
|
||||
KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
|
||||
gen.initialize(2048);
|
||||
KeyPair kp = gen.generateKeyPair();
|
||||
this.rsaKey = new RSAKey.Builder((RSAPublicKey) kp.getPublic())
|
||||
.privateKey((RSAPrivateKey) kp.getPrivate())
|
||||
.keyUse(KeyUse.SIGNATURE)
|
||||
.algorithm(JWSAlgorithm.RS256)
|
||||
.keyID(UUID.randomUUID().toString())
|
||||
.build();
|
||||
|
||||
this.server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
this.issuer = "http://127.0.0.1:" + server.getAddress().getPort();
|
||||
|
||||
server.createContext("/.well-known/openid-configuration", ex -> respond(ex, discoveryDocument()));
|
||||
server.createContext("/jwks", ex -> respond(ex, new JWKSet(rsaKey.toPublicJWK()).toJSONObject().toString()));
|
||||
server.setExecutor(null);
|
||||
server.start();
|
||||
}
|
||||
|
||||
String issuer() { return issuer; }
|
||||
|
||||
/** Mints a valid RS256 access token — bearer-validation only, no full authorization-code round-trip needed. */
|
||||
String signToken(String subject, String audience) {
|
||||
return signToken(subject, audience, null, NO_ROLES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as {@link #signToken(String, String)}, plus a {@code scope} claim (space-delimited,
|
||||
* matching {@link dev.relism.flash.ext.oidc.OidcUser#hasScope}'s default claim path) and a
|
||||
* Keycloak-shaped {@code realm_access.roles} claim (matching {@code McpConfig}'s default
|
||||
* {@code rolesClaimPath}) when {@code roles} is non-empty.
|
||||
*/
|
||||
String signToken(String subject, String audience, String scope, String... roles) {
|
||||
try {
|
||||
JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder()
|
||||
.issuer(issuer)
|
||||
.subject(subject)
|
||||
.audience(audience)
|
||||
.issueTime(Date.from(Instant.now()))
|
||||
.expirationTime(Date.from(Instant.now().plusSeconds(300)));
|
||||
if (scope != null) builder.claim("scope", scope);
|
||||
if (roles.length > 0) builder.claim("realm_access", Map.of("roles", List.of(roles)));
|
||||
SignedJWT jwt = new SignedJWT(
|
||||
new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaKey.getKeyID()).build(), builder.build());
|
||||
jwt.sign(new RSASSASigner(rsaKey));
|
||||
return jwt.serialize();
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static final String[] NO_ROLES = new String[0];
|
||||
|
||||
private String discoveryDocument() {
|
||||
return "{"
|
||||
+ "\"issuer\":\"" + issuer + "\","
|
||||
+ "\"authorization_endpoint\":\"" + issuer + "/auth\","
|
||||
+ "\"token_endpoint\":\"" + issuer + "/token\","
|
||||
+ "\"jwks_uri\":\"" + issuer + "/jwks\""
|
||||
+ "}";
|
||||
}
|
||||
|
||||
private static void respond(com.sun.net.httpserver.HttpExchange ex, String body) throws java.io.IOException {
|
||||
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
|
||||
ex.getResponseHeaders().add("Content-Type", "application/json");
|
||||
ex.sendResponseHeaders(200, bytes.length);
|
||||
try (OutputStream os = ex.getResponseBody()) { os.write(bytes); }
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() { server.stop(0); }
|
||||
}
|
||||
-141
@@ -1,141 +0,0 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import dev.relism.flash.ext.oidc.OidcConfig;
|
||||
import dev.relism.flash.ext.oidc.OidcExtension;
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import dev.relism.flash.extension.FlashConfiguration;
|
||||
import dev.relism.flash.testing.FlashResponse;
|
||||
import dev.relism.flash.testing.FlashTest;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* {@code @RolesAllowed}/{@code @ScopesAllowed} on an {@link McpTool} — see
|
||||
* {@link McpOidcIntegration#compileToolPolicy}. Same real-discovery/real-JWKS/real-RS256-token
|
||||
* approach as {@link McpExtensionSecurityTest}, against {@code fixtures.secured}'s tools.
|
||||
*/
|
||||
class McpAuthPolicyTest {
|
||||
|
||||
private static final String SECURED_TOOLS = "dev.relism.flash.ext.mcp.authfixtures.secured";
|
||||
private static final String AUTHENTICATED_ONLY_TOOLS = "dev.relism.flash.ext.mcp.authfixtures.authenticatedonly";
|
||||
|
||||
private static final FakeOidcProvider provider = newProvider();
|
||||
|
||||
@RegisterExtension
|
||||
static FlashTest secured = FlashTest.of(app -> {
|
||||
app.install(new OidcExtension(OidcConfig.builder(
|
||||
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
|
||||
app.install(new McpExtension(McpConfig.builder("secure-server")
|
||||
.toolsPackage(SECURED_TOOLS)
|
||||
.security(McpSecurity.REQUIRED)
|
||||
.build()));
|
||||
});
|
||||
|
||||
/** Tokens are audience-bound to this server, so the port has to be read back after boot. */
|
||||
private static String resourceId() {
|
||||
return "http://127.0.0.1:" + secured.port() + "/mcp";
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void closeProvider() {
|
||||
provider.close();
|
||||
}
|
||||
|
||||
// ── Tool policy ──────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void rolesAllowed_deniesWithoutRole_allowsWithRole() throws Exception {
|
||||
callTool("admin_only", provider.signToken("user-1", resourceId(), null))
|
||||
.expectStatus(200)
|
||||
.expectBodyContains("\"isError\":true")
|
||||
.expectBodyContains("missing required role");
|
||||
|
||||
callTool("admin_only", provider.signToken("user-1", resourceId(), null, "admin"))
|
||||
.expectStatus(200)
|
||||
.expectBodyContains("\"isError\":false")
|
||||
.expectBodyContains("ok");
|
||||
}
|
||||
|
||||
@Test
|
||||
void scopesAllowed_deniesWithoutScope_allowsWithScope() throws Exception {
|
||||
callTool("write_only", provider.signToken("user-1", resourceId(), "read"))
|
||||
.expectStatus(200)
|
||||
.expectBodyContains("\"isError\":true")
|
||||
.expectBodyContains("missing required scope");
|
||||
|
||||
callTool("write_only", provider.signToken("user-1", resourceId(), "read write"))
|
||||
.expectStatus(200)
|
||||
.expectBodyContains("\"isError\":false")
|
||||
.expectBodyContains("written");
|
||||
}
|
||||
|
||||
@Test
|
||||
void unannotatedTool_unaffectedByOtherToolsPolicies() throws Exception {
|
||||
callTool("open", provider.signToken("user-1", resourceId(), null))
|
||||
.expectStatus(200)
|
||||
.expectBodyContains("\"isError\":false")
|
||||
.expectBodyContains("open");
|
||||
}
|
||||
|
||||
private static FlashResponse callTool(String toolName, String token) {
|
||||
return secured.request()
|
||||
.header("Accept", "application/json")
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\""
|
||||
+ toolName + "\"}}")
|
||||
.post("/mcp");
|
||||
}
|
||||
|
||||
// ── Boot-time rejection ──────────────────────────────────────────────────
|
||||
// These assert that start() throws, so they build the app directly rather than through
|
||||
// FlashTest — a harness whose job is to boot an app is the wrong tool for asserting that
|
||||
// booting fails. Port 0 still removes the old free-port dance.
|
||||
|
||||
private FlashApp bootFailure;
|
||||
|
||||
@AfterEach
|
||||
void releaseBootFailureListener() {
|
||||
if (bootFailure != null) bootFailure.stop().join();
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolAnnotated_butSecurityNone_failsAtBoot() {
|
||||
bootFailure = mcpApp(SECURED_TOOLS, McpSecurity.NONE);
|
||||
|
||||
IllegalStateException error = assertThrows(IllegalStateException.class, bootFailure::start);
|
||||
assertTrue(error.getMessage().contains("no active OAuth2 protection"), error.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void bareAuthenticated_hasNoEffect_failsAtBoot() {
|
||||
bootFailure = mcpApp(AUTHENTICATED_ONLY_TOOLS, McpSecurity.REQUIRED);
|
||||
|
||||
IllegalStateException error = assertThrows(IllegalStateException.class, bootFailure::start);
|
||||
assertTrue(error.getMessage().contains("no effect"), error.getMessage());
|
||||
}
|
||||
|
||||
private static FlashApp mcpApp(String toolsPackage, McpSecurity security) {
|
||||
FlashApp app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(0).host("127.0.0.1").shutdownDrainTimeoutMs(250).build());
|
||||
app.install(new OidcExtension(OidcConfig.builder(
|
||||
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
|
||||
app.install(new McpExtension(McpConfig.builder("secure-server")
|
||||
.toolsPackage(toolsPackage)
|
||||
.security(security)
|
||||
.build()));
|
||||
return app;
|
||||
}
|
||||
|
||||
private static FakeOidcProvider newProvider() {
|
||||
try {
|
||||
return new FakeOidcProvider();
|
||||
} catch (Exception failure) {
|
||||
throw new IllegalStateException("Could not start the fake OIDC provider", failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import dev.relism.flash.exceptions.HttpException;
|
||||
import dev.relism.flash.testing.FlashTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* Application middleware on the MCP route. Until this existed a consumer had no way to put rate
|
||||
* limiting, audit logging or tracing in front of {@code /mcp} — the chain was assembled entirely
|
||||
* inside the extension.
|
||||
*/
|
||||
class McpConfigMiddlewareTest {
|
||||
|
||||
private static final AtomicInteger CALLS = new AtomicInteger();
|
||||
|
||||
@RegisterExtension
|
||||
static FlashTest mcp = FlashTest.of(app -> app.install(new McpExtension(
|
||||
McpConfig.builder("middleware-server")
|
||||
.version("1.0.0")
|
||||
.toolsPackage("dev.relism.flash.ext.mcp.fixtures")
|
||||
.security(McpSecurity.NONE)
|
||||
.middleware(
|
||||
next -> (req, res) -> {
|
||||
CALLS.incrementAndGet();
|
||||
return next.handle(req, res);
|
||||
},
|
||||
next -> (req, res) -> {
|
||||
if ("deny".equals(req.header("X-Test-Gate"))) throw HttpException.forbidden();
|
||||
return next.handle(req, res);
|
||||
})
|
||||
.build())));
|
||||
|
||||
@Test
|
||||
void appMiddlewareRunsOnTheMcpRoute() {
|
||||
int before = CALLS.get();
|
||||
mcp.request()
|
||||
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}")
|
||||
.post("/mcp")
|
||||
.expectStatus(200);
|
||||
assertEquals(before + 1, CALLS.get());
|
||||
}
|
||||
|
||||
/**
|
||||
* The point of the hook: with {@link McpSecurity#NONE}, an app's own guard is the only thing
|
||||
* in front of the endpoint — which is how an app that does not authenticate with OAuth2
|
||||
* protects {@code /mcp} at all.
|
||||
*/
|
||||
@Test
|
||||
void appMiddlewareCanRejectTheRequest() {
|
||||
mcp.request()
|
||||
.header("X-Test-Gate", "deny")
|
||||
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}")
|
||||
.post("/mcp")
|
||||
.expectStatus(403);
|
||||
}
|
||||
}
|
||||
-179
@@ -1,179 +0,0 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import dev.relism.flash.ext.oidc.OidcConfig;
|
||||
import dev.relism.flash.ext.oidc.OidcExtension;
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import dev.relism.flash.extension.FlashApplication;
|
||||
import dev.relism.flash.extension.FlashConfiguration;
|
||||
import dev.relism.flash.testing.FlashRequest;
|
||||
import dev.relism.flash.testing.FlashResponse;
|
||||
import dev.relism.flash.testing.FlashTest;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Exercises the actual OAuth2 resolution rules against a real {@code flash-ext-oidc}
|
||||
* installation backed by {@link FakeOidcProvider} — real discovery, real JWKS, real RS256
|
||||
* tokens — plus the fail-fast/degrade behavior when oidc is absent.
|
||||
*
|
||||
* <p>Four server configurations differ only in how MCP security is declared, so each gets its
|
||||
* own {@link FlashTest} and they share one provider.
|
||||
*/
|
||||
class McpExtensionSecurityTest {
|
||||
|
||||
private static final String TOOLS_PACKAGE = "dev.relism.flash.ext.mcp.fixtures";
|
||||
private static final String EXPLICIT_RESOURCE_ID = "https://mcp.example.com/mcp";
|
||||
|
||||
private static final FakeOidcProvider provider = newProvider();
|
||||
|
||||
/** MCP asked for AUTO security with no oidc installed — should degrade to public. */
|
||||
@RegisterExtension
|
||||
static FlashTest degraded = FlashTest.of(app -> app.install(new McpExtension(
|
||||
McpConfig.builder("auto-server")
|
||||
.toolsPackage(TOOLS_PACKAGE)
|
||||
.security(McpSecurity.AUTO)
|
||||
.build())));
|
||||
|
||||
/** REQUIRED with oidc, resource identifier derived from the request. */
|
||||
@RegisterExtension
|
||||
static FlashTest secured = FlashTest.of(securedApp(null, null));
|
||||
|
||||
/** REQUIRED with oidc and an explicitly declared resource identifier. */
|
||||
@RegisterExtension
|
||||
static FlashTest securedWithResourceId = FlashTest.of(securedApp(EXPLICIT_RESOURCE_ID, null));
|
||||
|
||||
/** REQUIRED with oidc and advertised scopes. */
|
||||
@RegisterExtension
|
||||
static FlashTest securedWithScopes =
|
||||
FlashTest.of(securedApp(null, new String[] {"openid", "profile", "email"}));
|
||||
|
||||
@AfterAll
|
||||
static void closeProvider() {
|
||||
provider.close();
|
||||
}
|
||||
|
||||
// ── No oidc installed ────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void required_withoutOidc_throwsAtBoot() {
|
||||
// Asserting that boot fails, so this one builds its app directly rather than through
|
||||
// the harness; port(0) still removes the old free-port dance.
|
||||
FlashApp app = FlashApp.create(FlashConfiguration.builder()
|
||||
.port(0).host("127.0.0.1").shutdownDrainTimeoutMs(250).build());
|
||||
app.install(new McpExtension(McpConfig.builder("secure-server")
|
||||
.toolsPackage(TOOLS_PACKAGE)
|
||||
.security(McpSecurity.REQUIRED)
|
||||
.build()));
|
||||
try {
|
||||
assertThrows(IllegalStateException.class, app::start);
|
||||
} finally {
|
||||
app.stop().join();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void auto_withoutOidc_degradesToPublic() {
|
||||
post(degraded, initializeBody(), null).expectStatus(200);
|
||||
}
|
||||
|
||||
// ── REQUIRED with oidc ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void required_withOidc_rejectsMissingToken() {
|
||||
post(secured, initializeBody(), null).expectStatus(401);
|
||||
}
|
||||
|
||||
@Test
|
||||
void required_withOidc_rejectsWrongAudience() throws Exception {
|
||||
String token = provider.signToken("user-1", "https://someone-else.example.com/resource");
|
||||
|
||||
post(securedWithResourceId, initializeBody(), token).expectStatus(403);
|
||||
}
|
||||
|
||||
@Test
|
||||
void required_withOidc_acceptsValidAudience() throws Exception {
|
||||
String token = provider.signToken("user-1", EXPLICIT_RESOURCE_ID);
|
||||
|
||||
post(securedWithResourceId, initializeBody(), token)
|
||||
.expectStatus(200)
|
||||
.expectBodyContains("\"protocolVersion\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void required_withOidc_noExplicitResourceIdentifier_derivesFromRequestAndEnforcesAudience() throws Exception {
|
||||
String derivedResourceId = "http://127.0.0.1:" + secured.port() + "/mcp";
|
||||
|
||||
post(secured, initializeBody(), provider.signToken("user-1", derivedResourceId))
|
||||
.expectStatus(200);
|
||||
post(secured, initializeBody(), provider.signToken("user-1", "https://someone-else.example.com/resource"))
|
||||
.expectStatus(403);
|
||||
}
|
||||
|
||||
@Test
|
||||
void required_withOidc_missingToken_challengeIncludesResourceMetadata() {
|
||||
FlashResponse response = post(secured, initializeBody(), null).expectStatus(401);
|
||||
|
||||
String challenge = response.header("WWW-Authenticate");
|
||||
assertTrue(challenge != null && challenge.contains("resource_metadata=\"http://127.0.0.1:"
|
||||
+ secured.port() + "/.well-known/oauth-protected-resource/mcp\""),
|
||||
"WWW-Authenticate: " + challenge);
|
||||
}
|
||||
|
||||
// ── Protected resource metadata ──────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
void required_withOidc_noExplicitConfig_publishesProtectedResourceMetadata() {
|
||||
FlashResponse response = secured.get("/.well-known/oauth-protected-resource/mcp")
|
||||
.expectStatus(200)
|
||||
.expectBodyContains("\"resource\":\"http://127.0.0.1:" + secured.port() + "/mcp\"")
|
||||
.expectBodyContains("\"authorization_servers\":[\"" + provider.issuer() + "\"]");
|
||||
|
||||
assertTrue(!response.body().contains("scopes_supported"),
|
||||
"scopes_supported must be omitted when unset: " + response.body());
|
||||
}
|
||||
|
||||
@Test
|
||||
void scopesSupported_published_inProtectedResourceMetadata() {
|
||||
securedWithScopes.get("/.well-known/oauth-protected-resource/mcp")
|
||||
.expectStatus(200)
|
||||
.expectBodyContains("\"scopes_supported\":[\"openid\",\"profile\",\"email\"]");
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private static FlashApplication securedApp(String resourceIdentifier, String[] scopesSupported) {
|
||||
return app -> {
|
||||
app.install(new OidcExtension(OidcConfig.builder(
|
||||
provider.issuer(), "mcp-client", "secret", "/auth/callback").build()));
|
||||
|
||||
McpConfig.Builder mcp = McpConfig.builder("secure-server")
|
||||
.toolsPackage(TOOLS_PACKAGE)
|
||||
.security(McpSecurity.REQUIRED);
|
||||
if (resourceIdentifier != null) mcp.resourceIdentifier(resourceIdentifier);
|
||||
if (scopesSupported != null) mcp.scopesSupported(scopesSupported);
|
||||
app.install(new McpExtension(mcp.build()));
|
||||
};
|
||||
}
|
||||
|
||||
private static FlashResponse post(FlashTest server, String body, String bearerToken) {
|
||||
FlashRequest request = server.request().header("Accept", "application/json").json(body);
|
||||
if (bearerToken != null) request.header("Authorization", "Bearer " + bearerToken);
|
||||
return request.post("/mcp");
|
||||
}
|
||||
|
||||
private static String initializeBody() {
|
||||
return "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}";
|
||||
}
|
||||
|
||||
private static FakeOidcProvider newProvider() {
|
||||
try {
|
||||
return new FakeOidcProvider();
|
||||
} catch (Exception failure) {
|
||||
throw new IllegalStateException("Could not start the fake OIDC provider", failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -16,7 +16,7 @@ class McpRegistryTest {
|
||||
|
||||
@Test
|
||||
void scan_findsAndPrecompilesToolsResourcesPrompts() throws Exception {
|
||||
McpRegistry registry = McpRegistry.scan("dev.relism.flash.ext.mcp.fixtures", new FlashContext(), false, "realm_access.roles");
|
||||
McpRegistry registry = McpRegistry.scan("dev.relism.flash.ext.mcp.fixtures", new FlashContext(), null);
|
||||
|
||||
assertTrue(registry.hasTools());
|
||||
assertTrue(registry.hasResources());
|
||||
@@ -47,7 +47,7 @@ class McpRegistryTest {
|
||||
@Test
|
||||
void scan_emptyPackage_throwsInitializationException() {
|
||||
assertThrows(InitializationException.class,
|
||||
() -> McpRegistry.scan("dev.relism.flash.ext.mcp.doesnotexist", new FlashContext(), false, "realm_access.roles"));
|
||||
() -> McpRegistry.scan("dev.relism.flash.ext.mcp.doesnotexist", new FlashContext(), null));
|
||||
}
|
||||
|
||||
private static JsonNode findByField(JsonNode array, String field, String value) {
|
||||
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
package dev.relism.flash.ext.mcp;
|
||||
|
||||
import dev.relism.flash.ext.security.AuthenticationMechanism;
|
||||
import dev.relism.flash.ext.security.Principal;
|
||||
import dev.relism.flash.ext.security.SecurityExtension;
|
||||
import dev.relism.flash.ext.security.SecurityScheme;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.ext.security.apikey.ApiKey;
|
||||
import dev.relism.flash.ext.security.apikey.ApiKeyExtension;
|
||||
import dev.relism.flash.ext.security.apikey.GeneratedApiKey;
|
||||
import dev.relism.flash.ext.security.oidc.OidcExtension;
|
||||
import dev.relism.flash.ext.security.oidc.OidcProvider;
|
||||
import dev.relism.flash.ext.security.test.FakeOidcProvider;
|
||||
import dev.relism.flash.testing.FlashRequest;
|
||||
import dev.relism.flash.testing.FlashResponse;
|
||||
import dev.relism.flash.testing.FlashTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class McpSecurityTest {
|
||||
|
||||
static final FakeOidcProvider provider = start();
|
||||
static final GeneratedApiKey KEY = new ApiKeyExtension<String>("mk", id -> null).generate();
|
||||
static final ApiKeyExtension<String> apiKeys = new ApiKeyExtension<>("mk", id -> id.equals(KEY.id()) ? new ApiKey<>(KEY.id(), KEY.secretHash(), "agent", null, null) : null);
|
||||
|
||||
@RegisterExtension
|
||||
static final FlashTest app = FlashTest.of(flash -> flash
|
||||
.install(new SecurityExtension().roles((identity, role, on) -> identity.principal().name().equals(role + "@" + on.get("project"))))
|
||||
.install(new OidcExtension(OidcProvider.of("fake", provider.issuer(), "app", "secret")))
|
||||
.install(apiKeys)
|
||||
.install(new McpExtension(McpConfig.builder("secure").toolsPackage("dev.relism.flash.ext.mcp.authfixtures.secured")
|
||||
.scopesSupported("openid", "email").build())));
|
||||
|
||||
static final OidcExtension oidc = new OidcExtension(OidcProvider.of("fake", provider.issuer(), "app", "secret"));
|
||||
|
||||
/** Another issuer the application trusts, which the restricted endpoint below must neither accept nor advertise. */
|
||||
static final AuthenticationMechanism elsewhere = new AuthenticationMechanism() {
|
||||
@Override public Principal authenticate(Request req) {
|
||||
return "Other".equals(req.header("Authorization")) ? () -> "other" : null;
|
||||
}
|
||||
@Override public List<SecurityScheme> schemes() {
|
||||
return List.of(SecurityScheme.openIdConnect("other", "https://other.example"));
|
||||
}
|
||||
};
|
||||
|
||||
/** Only the OIDC provider authenticates this endpoint, however many mechanisms the application has. */
|
||||
@RegisterExtension
|
||||
static final FlashTest restricted = FlashTest.of(flash -> flash
|
||||
.install(new SecurityExtension().mechanism(elsewhere))
|
||||
.install(oidc)
|
||||
.install(apiKeys)
|
||||
.install(new McpExtension(McpConfig.builder("restricted").toolsPackage("dev.relism.flash.ext.mcp.fixtures")
|
||||
.mechanisms(oidc).requireTokenAudience(false).build())));
|
||||
|
||||
/** The same chain with the RFC 8707 check turned off, for an authorization server that cannot mint a resource audience. */
|
||||
@RegisterExtension
|
||||
static final FlashTest relaxed = FlashTest.of(flash -> flash
|
||||
.install(new SecurityExtension().roles((identity, role, on) -> false))
|
||||
.install(new OidcExtension(OidcProvider.of("fake", provider.issuer(), "app", "secret")))
|
||||
.install(new McpExtension(McpConfig.builder("relaxed").toolsPackage("dev.relism.flash.ext.mcp.authfixtures.secured")
|
||||
.requireTokenAudience(false).build())));
|
||||
|
||||
static FakeOidcProvider start() {
|
||||
try {
|
||||
return new FakeOidcProvider();
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
|
||||
static String resource() {
|
||||
return "http://127.0.0.1:" + app.port() + "/mcp";
|
||||
}
|
||||
|
||||
static FlashResponse call(Consumer<FlashRequest> credential, String method, String params) {
|
||||
return app.request().with(credential).header("Accept", "application/json")
|
||||
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"" + method + "\",\"params\":" + params + "}")
|
||||
.post("/mcp");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anAnonymousCallIsChallengedWithTheResourceMetadata() {
|
||||
call(request -> {}, "initialize", "{}").expectStatus(401)
|
||||
.expectHeader("WWW-Authenticate", "Bearer resource_metadata=\"http://127.0.0.1:" + app.port() + "/.well-known/oauth-protected-resource/mcp\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void theProtectedResourceMetadataNamesTheIssuer() {
|
||||
app.get("/.well-known/oauth-protected-resource/mcp").expectStatus(200)
|
||||
.expectBody("{\"resource\":\"" + resource() + "\",\"authorization_servers\":[\"" + provider.issuer() + "\"],\"scopes_supported\":[\"openid\",\"email\"]}");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aTokenIsAcceptedOnlyForThisResource() {
|
||||
call(provider.bearer("u", Map.of("aud", resource())), "initialize", "{}").expectStatus(200).expectBodyContains("protocolVersion");
|
||||
call(provider.bearer("u", Map.of("aud", "https://elsewhere.example/mcp")), "initialize", "{}").expectStatus(403);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aTokenWithoutTheResourceAudienceIsAcceptedWhenTheCheckIsOff() {
|
||||
relaxed.request().with(provider.bearer("u", Map.of("aud", "https://elsewhere.example/mcp"))).header("Accept", "application/json")
|
||||
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}")
|
||||
.post("/mcp").expectStatus(200).expectBodyContains("protocolVersion");
|
||||
}
|
||||
|
||||
/** The resource is the application's own origin: a forwarded header naming another host cannot make its tokens good here. */
|
||||
@Test
|
||||
void aForwardedHostCannotChooseTheResource() {
|
||||
app.request().with(provider.bearer("u", Map.of("aud", "https://evil.example/mcp")))
|
||||
.header("X-Forwarded-Proto", "https").header("X-Forwarded-Host", "evil.example").header("Accept", "application/json")
|
||||
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}").post("/mcp").expectStatus(403);
|
||||
}
|
||||
|
||||
@Test
|
||||
void anEndpointRestrictedToSomeMechanismsAcceptsAndAdvertisesOnlyThem() {
|
||||
restricted.get("/.well-known/oauth-protected-resource/mcp").expectStatus(200)
|
||||
.expectBody("{\"resource\":\"http://127.0.0.1:" + restricted.port() + "/mcp\",\"authorization_servers\":[\"" + provider.issuer() + "\"]}");
|
||||
Consumer<FlashRequest> key = request -> request.header("Authorization", "Bearer " + KEY.token());
|
||||
Consumer<FlashRequest> other = request -> request.header("Authorization", "Other");
|
||||
for (Consumer<FlashRequest> refused : List.of(key, other)) {
|
||||
restricted.request().with(refused).header("Accept", "application/json")
|
||||
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}").post("/mcp").expectStatus(401);
|
||||
}
|
||||
restricted.request().with(provider.bearer("u", Map.of())).header("Accept", "application/json")
|
||||
.json("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}").post("/mcp").expectStatus(200);
|
||||
}
|
||||
|
||||
/** An API key is not audience-bound: the same chain authenticates agents that never saw an authorization server. */
|
||||
@Test
|
||||
void anApiKeyIsAcceptedBesideOAuth() {
|
||||
call(request -> request.header("Authorization", "Bearer " + KEY.token()), "initialize", "{}").expectStatus(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void toolPoliciesReadTheirTargetFromTheArguments() {
|
||||
Consumer<FlashRequest> admin = provider.bearer("admin@42", Map.of("aud", resource()));
|
||||
call(admin, "tools/call", "{\"name\":\"admin_only\",\"arguments\":{\"project\":\"42\"}}").expectBodyContains("\"isError\":false");
|
||||
call(admin, "tools/call", "{\"name\":\"admin_only\",\"arguments\":{\"project\":\"7\"}}").expectBodyContains("denied: missing role");
|
||||
call(provider.bearer("u", Map.of("aud", resource(), "scope", "write")), "tools/call", "{\"name\":\"write_only\"}").expectBodyContains("written");
|
||||
call(provider.bearer("u", Map.of("aud", resource())), "tools/call", "{\"name\":\"write_only\"}").expectBodyContains("denied: missing scope");
|
||||
}
|
||||
|
||||
@Test
|
||||
void securityIsRequiredUnlessDeclaredOff() {
|
||||
FlashTest unsecured = FlashTest.of(flash -> flash.install(new McpExtension(McpConfig.builder("x").toolsPackage("dev.relism.flash.ext.mcp.fixtures").build())));
|
||||
assertThrows(Exception.class, () -> unsecured.get("/mcp"));
|
||||
FlashTest contradictory = FlashTest.of(flash -> flash.install(new McpExtension(McpConfig.builder("x")
|
||||
.toolsPackage("dev.relism.flash.ext.mcp.authfixtures.secured").security(McpSecurity.NONE).build())));
|
||||
assertThrows(Exception.class, () -> contradictory.get("/mcp"));
|
||||
}
|
||||
}
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
package dev.relism.flash.ext.mcp.authfixtures.authenticatedonly;
|
||||
|
||||
import dev.relism.flash.ext.mcp.McpTool;
|
||||
import dev.relism.flash.ext.mcp.TextContent;
|
||||
import dev.relism.flash.ext.mcp.Tool;
|
||||
import dev.relism.flash.ext.mcp.ToolArguments;
|
||||
import dev.relism.flash.ext.mcp.ToolResponse;
|
||||
import dev.relism.flash.ext.oidc.Authenticated;
|
||||
|
||||
/** Deliberately misconfigured fixture: bare @Authenticated has no effect on an McpTool — see
|
||||
* McpOidcIntegration#compileToolPolicy. Boot must fail with a clear message, not silently no-op. */
|
||||
@Tool(name = "pointless", description = "Exists only to prove @Authenticated alone fails boot")
|
||||
@Authenticated
|
||||
public class PointlessAuthTool extends McpTool {
|
||||
|
||||
@Override
|
||||
public ToolResponse call(ToolArguments args) {
|
||||
return ToolResponse.success(new TextContent("unreachable"));
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -5,10 +5,10 @@ import dev.relism.flash.ext.mcp.TextContent;
|
||||
import dev.relism.flash.ext.mcp.Tool;
|
||||
import dev.relism.flash.ext.mcp.ToolArguments;
|
||||
import dev.relism.flash.ext.mcp.ToolResponse;
|
||||
import dev.relism.flash.ext.oidc.RolesAllowed;
|
||||
import dev.relism.flash.ext.security.RolesAllowed;
|
||||
|
||||
@Tool(name = "admin_only", description = "Only callable with the admin role")
|
||||
@RolesAllowed("admin")
|
||||
@RolesAllowed(value = "admin", on = "project")
|
||||
public class AdminOnlyTool extends McpTool {
|
||||
|
||||
@Override
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import dev.relism.flash.ext.mcp.TextContent;
|
||||
import dev.relism.flash.ext.mcp.Tool;
|
||||
import dev.relism.flash.ext.mcp.ToolArguments;
|
||||
import dev.relism.flash.ext.mcp.ToolResponse;
|
||||
import dev.relism.flash.ext.oidc.ScopesAllowed;
|
||||
import dev.relism.flash.ext.security.ScopesAllowed;
|
||||
|
||||
@Tool(name = "write_only", description = "Only callable with the write scope")
|
||||
@ScopesAllowed("write")
|
||||
|
||||
@@ -1,462 +0,0 @@
|
||||
# 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.
|
||||
|
||||
Standards alignment focuses on OIDC Core + OAuth2 bearer APIs while preserving Flash's
|
||||
hot-path model (middleware compiled at mount time, no heavy runtime work).
|
||||
|
||||
## What it provides
|
||||
|
||||
| Component | Description |
|
||||
|---|---|
|
||||
| `GET {prefix}/login` | Starts the OIDC flow: builds the authorization URL with PKCE + state, redirects |
|
||||
| `GET {prefix}/callback` | Exchanges the code, validates the ID token, creates a session, redirects |
|
||||
| `POST {prefix}/logout` | Invalidates the session, redirects to the provider's `end_session_endpoint` |
|
||||
| `@Authenticated` | Annotation: protects a class-based handler (redirects browsers, 401 for API clients) |
|
||||
| `@RolesAllowed(...)` | Annotation: protects with role check (OR semantics) |
|
||||
| `@ScopesAllowed(...)` | Annotation: protects with scope check (`ALL` default, `ANY` optional) |
|
||||
| `OidcMiddleware` | Programmatic middleware for lambda routes |
|
||||
| `ClaimsHolder` / `OidcUser` | Thread-local user info accessible from any protected handler |
|
||||
| `JwtValidator` | JWKS-backed JWT validator (PKCE + key rotation + caching) |
|
||||
|
||||
## Dependencies
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-oidc</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
Transitive: `nimbus-jose-jwt`, `json-smart`.
|
||||
Optional: `flash-ext-openapi` — if present, OIDC security schemes are added to the OpenAPI spec automatically.
|
||||
|
||||
## Installation
|
||||
|
||||
```java
|
||||
FlashApp.create(8080)
|
||||
.install(new JacksonExtension())
|
||||
.install(new OpenApiExtension(...)) // optional — enables Swagger security
|
||||
.install(new OidcExtension(
|
||||
OidcConfig.builder(
|
||||
"https://idp.example.com",
|
||||
"my-client", "my-secret", "/auth/callback")
|
||||
.build()
|
||||
))
|
||||
.start();
|
||||
```
|
||||
|
||||
Install order is irrelevant. The two-phase extension model guarantees all services
|
||||
(including `OpenApiSecurityRegistry` from `flash-ext-openapi`) are registered before
|
||||
any extension's routes phase runs.
|
||||
|
||||
### Keycloak shortcut
|
||||
|
||||
```java
|
||||
OidcConfig.keycloak(
|
||||
"https://keycloak.example.com", // server URL (no realm)
|
||||
"myrealm", // realm
|
||||
"my-client", "my-secret", // client credentials
|
||||
"/auth/callback") // redirect URI (server-relative)
|
||||
.https() // behind TLS
|
||||
.build()
|
||||
```
|
||||
|
||||
`keycloak()` pre-sets `rolesClaimPath("realm_access.roles")` and constructs the issuer as
|
||||
`{serverUrl}/realms/{realm}`.
|
||||
|
||||
### Authelia / generic IdP
|
||||
|
||||
```java
|
||||
OidcConfig.builder("https://auth.example.com", "my-client", "secret", "/auth/callback")
|
||||
.rolesClaimPath("groups")
|
||||
.build()
|
||||
```
|
||||
|
||||
## OidcConfig reference
|
||||
|
||||
### Required fields
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `issuer` | Provider base URL — also used for OIDC discovery |
|
||||
| `clientId` | OAuth2 client ID |
|
||||
| `clientSecret` | OAuth2 client secret |
|
||||
| `redirectUri` | Callback URI; server-relative paths (starting with `/`) are resolved at request time |
|
||||
|
||||
### Builder options
|
||||
|
||||
| Method | Default | Description |
|
||||
|---|---|---|
|
||||
| `.scopes("openid profile email")` | `"openid profile email"` | Space-separated requested scopes |
|
||||
| `.routePrefix("/auth")` | `"/auth"` | Prefix for login/callback/logout routes |
|
||||
| `.selfScheme("http")` | `"http"` | Scheme used when resolving server-relative redirect URIs |
|
||||
| `.https()` | — | Shorthand for `.selfScheme("https")` |
|
||||
| `.rolesClaimPath("realm_access.roles")` | `"realm_access.roles"` | Dot-path to the roles array in JWT claims |
|
||||
| `.scopeClaimPaths("scope,scp")` | `"scope,scp"` | Comma-separated claim paths used to resolve OAuth scopes |
|
||||
| `.algorithm("RS256")` | `"RS256"` | JWS algorithm for token validation |
|
||||
| `.postLogoutRedirectUri("/")` | `"/"` | Where to redirect after logout |
|
||||
| `.sessionStore(store)` | `InMemoryOidcSessionStore` | Custom session store (see below) |
|
||||
| `.clientAuthMethod(ClientAuthMethod.POST)` | `POST` | `POST` = credentials in body; `BASIC` = `Authorization: Basic` |
|
||||
| `.insecureTls()` | `false` | Disables TLS certificate verification — **development only** |
|
||||
| `.schemeName("myscheme")` | derived from issuer | OpenAPI security scheme name |
|
||||
|
||||
### Environment variables (`OidcConfig.fromEnv()`)
|
||||
|
||||
```
|
||||
OIDC_ISSUER required
|
||||
OIDC_CLIENT_ID required
|
||||
OIDC_CLIENT_SECRET required
|
||||
OIDC_REDIRECT_URI required e.g. /auth/callback
|
||||
OIDC_SCOPES default: openid profile email
|
||||
OIDC_ROUTE_PREFIX default: /auth
|
||||
OIDC_SELF_SCHEME default: http
|
||||
OIDC_ROLES_CLAIM default: realm_access.roles
|
||||
OIDC_SCOPE_CLAIMS default: scope,scp
|
||||
OIDC_ALGORITHM default: RS256
|
||||
OIDC_POST_LOGOUT_REDIRECT default: /
|
||||
OIDC_CLIENT_AUTH_METHOD default: POST
|
||||
```
|
||||
|
||||
## Protecting routes
|
||||
|
||||
### Class-based handlers (annotations)
|
||||
|
||||
```java
|
||||
@Route(method = HttpMethod.GET, path = "/me")
|
||||
@Authenticated
|
||||
public class MePage extends JacksonHandler {
|
||||
@Override
|
||||
public Object handle(Request req, Response res) {
|
||||
OidcUser u = ClaimsHolder.user();
|
||||
return json(res, Map.of("sub", u.sub(), "email", u.email()));
|
||||
}
|
||||
}
|
||||
|
||||
@Route(method = HttpMethod.GET, path = "/admin")
|
||||
@RolesAllowed("admin") // OR semantics: "admin" OR "superuser"
|
||||
// @RolesAllowed({"admin", "superuser"})
|
||||
public class AdminPage extends JacksonHandler { ... }
|
||||
|
||||
@Route(method = HttpMethod.POST, path = "/orders")
|
||||
@ScopesAllowed("orders:write") // default = ALL semantics
|
||||
public class CreateOrder extends JacksonHandler { ... }
|
||||
|
||||
@Route(method = HttpMethod.POST, path = "/payments")
|
||||
@ScopesAllowed(value = {"payments:write", "payments:admin"}, match = ScopesAllowed.Match.ANY)
|
||||
public class PayOrder extends JacksonHandler { ... }
|
||||
|
||||
@Route(method = HttpMethod.DELETE, path = "/admin/users/{id}")
|
||||
@RolesAllowed("admin")
|
||||
@ScopesAllowed("users:delete") // combined with AND semantics
|
||||
public class DeleteUser extends JacksonHandler { ... }
|
||||
```
|
||||
|
||||
The middleware is injected automatically by the annotation processor — no manual wiring needed.
|
||||
|
||||
Annotation composition rules:
|
||||
|
||||
- `@Authenticated` requires auth only
|
||||
- `@RolesAllowed` implies authentication + role OR-check
|
||||
- `@ScopesAllowed` implies authentication + scope check (`ALL`/`ANY`)
|
||||
- combining `@RolesAllowed` + `@ScopesAllowed` uses AND semantics
|
||||
- `@Authenticated(optional = true)` cannot be combined with role/scope constraints
|
||||
|
||||
### Lambda routes (manual middleware)
|
||||
|
||||
For lambda routes, pass the middleware as a varargs argument. Retrieve `OidcMiddleware`
|
||||
from the context inside another extension's `routes()` phase, or after `start()`:
|
||||
|
||||
```java
|
||||
OidcMiddleware oidc = app.ctx().require(OidcMiddleware.class);
|
||||
|
||||
// Authentication only
|
||||
app.get("/api/me", (req, res) -> {
|
||||
OidcUser u = ClaimsHolder.user(); // never null here
|
||||
return Map.of("sub", u.sub(), "email", u.email());
|
||||
}, oidc.protect());
|
||||
|
||||
// Authentication + role check
|
||||
app.delete("/api/admin/users/{id}", (req, res) -> {
|
||||
OidcUser u = ClaimsHolder.user();
|
||||
// ...
|
||||
}, oidc.requireRole("admin"));
|
||||
|
||||
// Multiple roles (OR): passes if user holds any one of them
|
||||
app.get("/api/reports", (req, res) -> { ... }, oidc.requireRole("admin", "reports-viewer"));
|
||||
|
||||
// Require all listed scopes
|
||||
app.post("/api/orders", (req, res) -> { ... }, oidc.requireScopes("orders:write", "payments:write"));
|
||||
|
||||
// Require at least one listed scope
|
||||
app.post("/api/payments", (req, res) -> { ... }, oidc.requireAnyScope("payments:write", "payments:admin"));
|
||||
```
|
||||
|
||||
`oidc.protect()` / `oidc.requireRole(...)` / `oidc.requireScopes(...)` return a `Middleware` — a composable
|
||||
`Handler → Handler` wrapper. Flash applies middleware right-to-left so the OIDC check
|
||||
runs before your handler.
|
||||
|
||||
## Accessing the authenticated user
|
||||
|
||||
`ClaimsHolder` holds the JWT claims for the current request in a `ThreadLocal`.
|
||||
It is populated by the OIDC middleware before your handler runs and cleared in the
|
||||
`finally` block afterward. It is safe with virtual threads (each request gets its
|
||||
own virtual thread, so `ThreadLocal` values are naturally isolated).
|
||||
|
||||
### OidcUser (preferred)
|
||||
|
||||
```java
|
||||
OidcUser u = ClaimsHolder.user(); // never null inside a protected handler
|
||||
|
||||
String sub = u.sub(); // unique user ID
|
||||
String email = u.email();
|
||||
String username = u.username(); // preferred_username
|
||||
String name = u.name(); // full display name
|
||||
|
||||
// Roles — pass the dot-path matching your provider's claim structure
|
||||
List<String> roles = u.roles("realm_access.roles"); // Keycloak realm roles
|
||||
List<String> clientRoles = u.roles("resource_access.my-client.roles"); // Keycloak client roles
|
||||
List<String> groups = u.roles("groups"); // Authelia
|
||||
|
||||
boolean isAdmin = u.hasRole("realm_access.roles", "admin");
|
||||
|
||||
// Scopes (OIDC/OAuth2 generic): checks "scope" then "scp"
|
||||
List<String> scopes = u.scopes();
|
||||
boolean canWrite = u.hasScope("orders:write");
|
||||
|
||||
// Custom claim path resolution (for provider-specific payloads)
|
||||
List<String> customScopes = u.scopes("scope,scp,permissions.scopes");
|
||||
boolean canApprove = u.hasScope("permissions.scopes", "orders:approve");
|
||||
|
||||
// Arbitrary claim
|
||||
String locale = (String) u.claim("locale");
|
||||
Long exp = u.claim("exp", Long.class);
|
||||
|
||||
// Full raw map (escape hatch)
|
||||
Map<String, Object> all = u.claims();
|
||||
```
|
||||
|
||||
### Raw access (escape hatch)
|
||||
|
||||
```java
|
||||
Map<String, Object> claims = ClaimsHolder.get();
|
||||
String email = ClaimsHolder.claim("email");
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
The middleware adds negligible overhead on the hot path for authenticated requests:
|
||||
|
||||
| Step | Cost |
|
||||
|---|---|
|
||||
| `Authorization` header check | `O(1)` map lookup |
|
||||
| Cookie parse | `O(cookie_length)` single pass scan |
|
||||
| Session lookup | `O(1)` `ConcurrentHashMap.get()` |
|
||||
| Token expiry check | `O(1)` `Instant` comparison |
|
||||
| `ClaimsHolder.set()` | `O(1)` `ThreadLocal.set()` |
|
||||
|
||||
No network calls, no cryptography, no JSON parsing on the happy path (valid session).
|
||||
JWKS key fetching only happens for Bearer token validation and is cached + rate-limited by
|
||||
Nimbus's `JWKSourceBuilder`. Silent token refresh only triggers when the access token expires.
|
||||
|
||||
Role/scope claim paths are compiled once during middleware construction (mount time), not per request.
|
||||
|
||||
## Authentication flow details
|
||||
|
||||
On each request the middleware resolves credentials in this order:
|
||||
|
||||
1. **Bearer token** (`Authorization: Bearer <jwt>`) — validated against JWKS.
|
||||
2. **Session cookie** (`oidc_session`) — looked up in the session store; transparently
|
||||
refreshed if the access token is expired (silent refresh via refresh token).
|
||||
3. **No valid credentials**:
|
||||
- Browser clients (no `Accept: application/json`) → redirect to `{prefix}/login?redirect={path}`
|
||||
- API clients → `401 Unauthorized`
|
||||
|
||||
### API error semantics (RFC 6750)
|
||||
|
||||
For API clients (`Accept: application/json`) the middleware includes `WWW-Authenticate`:
|
||||
|
||||
- missing credentials: `Bearer realm="<schemeName>"`
|
||||
- invalid bearer token: `Bearer realm="<schemeName>", error="invalid_token"`
|
||||
- insufficient scopes: `Bearer realm="<schemeName>", error="insufficient_scope", scope="<required scopes>"`
|
||||
|
||||
This enables interoperable client-side handling and proper OAuth2 challenge semantics.
|
||||
|
||||
### Token validation (OIDC Core §3.1.3.7)
|
||||
|
||||
| Check | Access token | ID token |
|
||||
|---|---|---|
|
||||
| Signature (JWKS) | yes | yes |
|
||||
| `iss` | yes | yes |
|
||||
| `aud` = clientId | no (varies by provider) | yes |
|
||||
| `exp`, `iat`, `sub` | yes | yes |
|
||||
| `nonce` | — | yes |
|
||||
|
||||
JWKS keys are cached, rate-limited, and retried on cache-miss (handles key rotation).
|
||||
|
||||
### Claim merge strategy
|
||||
|
||||
At callback time the extension merges access token + ID token claims:
|
||||
|
||||
- Access token claims first (contains provider-specific data like `realm_access.roles`)
|
||||
- ID token claims override (contains verified identity: `sub`, `email`, `name`, …)
|
||||
|
||||
This is provider-agnostic: authorization claims live in the AT per RFC 9068,
|
||||
identity claims live in the IT per OIDC Core.
|
||||
|
||||
## Standards & compliance notes
|
||||
|
||||
This extension is designed to be compliant with the most relevant OIDC/OAuth2 RFCs:
|
||||
|
||||
- RFC 8414 (Authorization Server Metadata): discovery via `/.well-known/openid-configuration`
|
||||
- OpenID Connect Core 1.0: Authorization Code flow + PKCE + `nonce` validation on ID token
|
||||
- RFC 7636 (PKCE): S256 challenge/verifier flow
|
||||
- RFC 6750 (Bearer Token Usage): `WWW-Authenticate` challenges with standard error codes
|
||||
- RFC 9068 (JWT Profile for Access Tokens): JWT bearer access-token validation path
|
||||
- RFC 7519 / RFC 7517 / RFC 7515 family: JWT/JWK/JWS validation via Nimbus + JWKS caching/rotation
|
||||
|
||||
Provider interoperability details:
|
||||
|
||||
- scope extraction supports both standard forms: `scope` (space-delimited string) and `scp` (list/string)
|
||||
- roles remain configurable via `rolesClaimPath` (`realm_access.roles`, `groups`, etc.)
|
||||
- scope claim fallback chain is configurable via `scopeClaimPaths`
|
||||
|
||||
## Testing scopes with Keycloak
|
||||
|
||||
Quick path to test `@ScopesAllowed` end-to-end:
|
||||
|
||||
1. **Create a client scope**
|
||||
- Realm -> Client scopes -> Create
|
||||
- Name: `orders:write` (or any scope name you want to enforce)
|
||||
2. **Attach it to your client**
|
||||
- Clients -> `<your-client>` -> Client scopes
|
||||
- Add the scope as `Default` (always in token) or `Optional` (requested via `scope` param)
|
||||
3. **Ensure scope mapper reaches the token**
|
||||
- For most Keycloak setups this is automatic via built-in `microprofile-jwt`/scope mappers
|
||||
- Verify the access token contains either `scope` string or `scp` list
|
||||
4. **Request the scope in Flash config**
|
||||
- Include it in `OidcConfig.scopes(...)`, e.g. `"openid profile email orders:write"`
|
||||
5. **Protect a handler**
|
||||
- `@ScopesAllowed("orders:write")` on class-based handlers
|
||||
- or `oidc.requireScopes("orders:write")` for lambda routes
|
||||
6. **Verify behavior**
|
||||
- token with scope -> 200
|
||||
- token without scope -> 403 + `WWW-Authenticate: ... insufficient_scope`
|
||||
|
||||
Useful token inspection flow while testing:
|
||||
|
||||
- Obtain a token from Keycloak
|
||||
- Decode payload (`jwt.io` or local tool)
|
||||
- check `scope` / `scp` claims
|
||||
- call your protected endpoint and inspect status + `WWW-Authenticate`
|
||||
|
||||
## Session store
|
||||
|
||||
The default `InMemoryOidcSessionStore` is sufficient for single-instance deployments.
|
||||
For clustered deployments, implement `OidcSessionStore`:
|
||||
|
||||
```java
|
||||
public interface OidcSessionStore {
|
||||
void save(OidcSession session);
|
||||
Optional<OidcSession> find(String sessionId);
|
||||
void delete(String sessionId);
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
OidcConfig.builder(...)
|
||||
.sessionStore(new RedisOidcSessionStore(redisClient))
|
||||
.build()
|
||||
```
|
||||
|
||||
`OidcSession` fields: `id`, `accessToken`, `idToken`, `refreshToken`, `expiresAt` (`Instant`), `claims` (merged map).
|
||||
|
||||
## Logout
|
||||
|
||||
Add a logout button anywhere in your UI — a `<form>` is sufficient (no JavaScript needed):
|
||||
|
||||
```html
|
||||
<form method="POST" action="/auth/logout">
|
||||
<button type="submit">Logout</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
The `POST {prefix}/logout` handler:
|
||||
1. Reads the `oidc_session` cookie, looks up the session, retrieves the `id_token`.
|
||||
2. Deletes the local session and clears the cookie (`Max-Age=0`).
|
||||
3. If the provider has an `end_session_endpoint` (standard IdPs do), redirects there with
|
||||
`?id_token_hint=<idToken>&post_logout_redirect_uri=<postLogoutRedirectUri>` — this logs
|
||||
the user out of the IdP as well.
|
||||
4. Otherwise redirects to `postLogoutRedirectUri` (default: `/`).
|
||||
|
||||
## Bearer token (API clients)
|
||||
|
||||
For API-to-API or SPA-to-API calls, pass a Bearer access token directly. The middleware
|
||||
validates the JWT signature against JWKS and extracts the claims — no session involved:
|
||||
|
||||
```
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
The token must be a JWT (opaque tokens are not supported). Claims are available via
|
||||
`ClaimsHolder.user()` as usual.
|
||||
|
||||
## Multi-tenant
|
||||
|
||||
Multiple OIDC providers on one server — each `OidcExtension` instance is fully independent
|
||||
(its own PKCE state store, session store, validator, and middleware):
|
||||
|
||||
```java
|
||||
OidcConfig tenantA = OidcConfig.builder("https://idp/realms/a", "clientA", "secretA", "/a/auth/callback")
|
||||
.routePrefix("/a/auth").schemeName("tenantA").build();
|
||||
|
||||
OidcConfig tenantB = OidcConfig.builder("https://idp/realms/b", "clientB", "secretB", "/b/auth/callback")
|
||||
.routePrefix("/b/auth").schemeName("tenantB").build();
|
||||
|
||||
app.install(new OidcExtension(tenantA))
|
||||
.install(new OidcExtension(tenantB));
|
||||
```
|
||||
|
||||
To reference a specific tenant's middleware on lambda routes, keep the extension instances
|
||||
and retrieve `OidcMiddleware` from context after `start()`:
|
||||
|
||||
```java
|
||||
OidcExtension extA = new OidcExtension(tenantA);
|
||||
OidcExtension extB = new OidcExtension(tenantB);
|
||||
|
||||
FlashApp app = FlashApp.create(8080)
|
||||
.install(extA)
|
||||
.install(extB)
|
||||
.start()
|
||||
.join(); // wait for bind
|
||||
|
||||
OidcMiddleware mwA = app.ctx().require(OidcMiddleware.class); // last registered = tenantB
|
||||
```
|
||||
|
||||
> **Note:** because both extensions register `OidcMiddleware.class` in the same context,
|
||||
> only the last one wins under that key. For multi-tenant setups, use distinct context
|
||||
> keys or provide middleware under a wrapper/alias type, or use lambda routes with explicit
|
||||
> middleware captured from the extension instance before `install()`.
|
||||
|
||||
Class-based handlers annotated with `@Authenticated` / `@RolesAllowed` get the last
|
||||
registered processor's middleware. For true multi-tenant class-based routing, install
|
||||
tenant-specific annotation processors with different annotations.
|
||||
|
||||
## OpenAPI integration
|
||||
|
||||
If `flash-ext-openapi` is on the classpath and installed (order irrelevant),
|
||||
the extension automatically:
|
||||
|
||||
- Adds a `components.securitySchemes` entry for the provider (OAuth2, authorizationCode flow)
|
||||
- Adds `security` requirements to every operation whose handler carries `@Authenticated`
|
||||
, `@RolesAllowed`, or `@ScopesAllowed`
|
||||
|
||||
No extra code needed. To customize the scheme name:
|
||||
|
||||
```java
|
||||
OidcConfig.builder(...).schemeName("keycloak").build()
|
||||
```
|
||||
|
||||
If `flash-ext-openapi` is absent the integration is silently skipped.
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
package dev.relism.flash.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).
|
||||
*
|
||||
* <p>Set {@code optional = true} on public routes that personalise their response when
|
||||
* the user happens to be logged in but should remain accessible to guests. The middleware
|
||||
* will populate {@link ClaimsHolder} if credentials are present and silently skip it
|
||||
* otherwise — the request is never rejected.
|
||||
*
|
||||
* <pre>{@code
|
||||
* // Hard auth — redirects / 401 when unauthenticated:
|
||||
* @Route(method = HttpMethod.GET, path = "/api/profile")
|
||||
* @Authenticated
|
||||
* public class GetProfile extends JacksonHandler { ... }
|
||||
*
|
||||
* // Soft auth — guest-friendly, ClaimsHolder populated only when logged in:
|
||||
* @Route(method = HttpMethod.GET, path = "/")
|
||||
* @Authenticated(optional = true)
|
||||
* public class HomePage extends HtmlHandler { ... }
|
||||
* }</pre>
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public @interface Authenticated {
|
||||
/**
|
||||
* When {@code true} the middleware never rejects unauthenticated requests — it only
|
||||
* populates {@link ClaimsHolder} when valid credentials are present.
|
||||
* Defaults to {@code false} (hard authentication required).
|
||||
*/
|
||||
boolean optional() default false;
|
||||
}
|
||||
-71
@@ -1,71 +0,0 @@
|
||||
package dev.relism.flash.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
@@ -1,18 +0,0 @@
|
||||
package dev.relism.flash.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
@@ -1,50 +0,0 @@
|
||||
package dev.relism.flash.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
@@ -1,20 +0,0 @@
|
||||
package dev.relism.flash.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); }
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package dev.relism.flash.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));
|
||||
}
|
||||
}
|
||||
-184
@@ -1,184 +0,0 @@
|
||||
package dev.relism.flash.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.flash.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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
-98
@@ -1,98 +0,0 @@
|
||||
package dev.relism.flash.ext.oidc;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Compiled authorization policy derived from handler annotations at mount time.
|
||||
* Immutable and allocation-free on the request hot path.
|
||||
*/
|
||||
final class OidcAuthPolicy {
|
||||
|
||||
private static final String[] EMPTY = new String[0];
|
||||
|
||||
private static final OidcAuthPolicy AUTH_REQUIRED = new OidcAuthPolicy(
|
||||
false, EMPTY, EMPTY, ScopesAllowed.Match.ALL);
|
||||
private static final OidcAuthPolicy AUTH_OPTIONAL = new OidcAuthPolicy(
|
||||
true, EMPTY, EMPTY, ScopesAllowed.Match.ALL);
|
||||
|
||||
private final boolean optionalAuth;
|
||||
private final String[] requiredRoles;
|
||||
private final String[] requiredScopes;
|
||||
private final ScopesAllowed.Match scopeMatch;
|
||||
|
||||
private OidcAuthPolicy(boolean optionalAuth,
|
||||
String[] requiredRoles,
|
||||
String[] requiredScopes,
|
||||
ScopesAllowed.Match scopeMatch) {
|
||||
this.optionalAuth = optionalAuth;
|
||||
this.requiredRoles = requiredRoles;
|
||||
this.requiredScopes = requiredScopes;
|
||||
this.scopeMatch = scopeMatch;
|
||||
}
|
||||
|
||||
static OidcAuthPolicy authenticated() { return AUTH_REQUIRED; }
|
||||
|
||||
static OidcAuthPolicy optional() { return AUTH_OPTIONAL; }
|
||||
|
||||
static OidcAuthPolicy rolesAny(String... roles) {
|
||||
return new OidcAuthPolicy(false, normalizeRequired("RolesAllowed", roles), EMPTY, ScopesAllowed.Match.ALL);
|
||||
}
|
||||
|
||||
static OidcAuthPolicy scopes(String[] scopes, ScopesAllowed.Match match) {
|
||||
return new OidcAuthPolicy(false, EMPTY, normalizeRequired("ScopesAllowed", scopes), match);
|
||||
}
|
||||
|
||||
static OidcAuthPolicy compileFromAnnotations(Class<?> handlerClass) {
|
||||
Authenticated auth = handlerClass.getAnnotation(Authenticated.class);
|
||||
RolesAllowed roles = handlerClass.getAnnotation(RolesAllowed.class);
|
||||
ScopesAllowed scopes = handlerClass.getAnnotation(ScopesAllowed.class);
|
||||
|
||||
if (auth == null && roles == null && scopes == null) return null;
|
||||
|
||||
boolean optionalAuth = auth != null && auth.optional();
|
||||
String[] requiredRoles = roles != null ? normalizeRequired("RolesAllowed", roles.value()) : EMPTY;
|
||||
String[] requiredScopes = scopes != null ? normalizeRequired("ScopesAllowed", scopes.value()) : EMPTY;
|
||||
ScopesAllowed.Match scopeMatch = scopes != null ? scopes.match() : ScopesAllowed.Match.ALL;
|
||||
|
||||
if (optionalAuth && (requiredRoles.length > 0 || requiredScopes.length > 0)) {
|
||||
throw new IllegalStateException("@Authenticated(optional = true) cannot be combined with @RolesAllowed/@ScopesAllowed on "
|
||||
+ handlerClass.getName());
|
||||
}
|
||||
|
||||
return new OidcAuthPolicy(optionalAuth, requiredRoles, requiredScopes, scopeMatch);
|
||||
}
|
||||
|
||||
static List<String> openApiScopesFor(Class<?> handlerClass) {
|
||||
Authenticated auth = handlerClass.getAnnotation(Authenticated.class);
|
||||
RolesAllowed roles = handlerClass.getAnnotation(RolesAllowed.class);
|
||||
ScopesAllowed scopes = handlerClass.getAnnotation(ScopesAllowed.class);
|
||||
if (auth == null && roles == null && scopes == null) return null;
|
||||
if (scopes == null) return List.of();
|
||||
return List.of(normalizeRequired("ScopesAllowed", scopes.value()));
|
||||
}
|
||||
|
||||
boolean optionalAuth() { return optionalAuth; }
|
||||
|
||||
String[] requiredRoles() { return requiredRoles; }
|
||||
|
||||
String[] requiredScopes() { return requiredScopes; }
|
||||
|
||||
ScopesAllowed.Match scopeMatch() { return scopeMatch; }
|
||||
|
||||
private static String[] normalizeRequired(String annotation, String[] values) {
|
||||
if (values == null || values.length == 0)
|
||||
throw new IllegalStateException("@" + annotation + " requires at least one value");
|
||||
|
||||
LinkedHashSet<String> normalized = new LinkedHashSet<>(values.length);
|
||||
for (String raw : values) {
|
||||
if (raw == null) continue;
|
||||
String trimmed = raw.trim();
|
||||
if (!trimmed.isEmpty()) normalized.add(trimmed);
|
||||
}
|
||||
if (normalized.isEmpty())
|
||||
throw new IllegalStateException("@" + annotation + " requires at least one non-empty value");
|
||||
|
||||
return normalized.toArray(String[]::new);
|
||||
}
|
||||
}
|
||||
-261
@@ -1,261 +0,0 @@
|
||||
package dev.relism.flash.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
|
||||
* .scopeClaimPaths("scope,scp") // default; supports many IdPs
|
||||
* .build();
|
||||
*
|
||||
* // Authelia
|
||||
* OidcConfig.builder(
|
||||
* "https://auth.example.com",
|
||||
* "my-app", "secret", "/auth/callback")
|
||||
* .rolesClaimPath("groups")
|
||||
* .scopeClaimPaths("scope,scp")
|
||||
* .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 scopeClaimPaths;
|
||||
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.scopeClaimPaths = b.scopeClaimPaths;
|
||||
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; }
|
||||
/** Comma-separated claim paths used to read OAuth2 scopes (default: {@code "scope,scp"}). */
|
||||
public String scopeClaimPaths() { return scopeClaimPaths; }
|
||||
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_SCOPE_CLAIMS default: scope,scp
|
||||
* 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"))
|
||||
.scopeClaimPaths (envOr("OIDC_SCOPE_CLAIMS", "scope,scp"))
|
||||
.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 scopeClaimPaths = "scope,scp";
|
||||
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; }
|
||||
/** Comma-separated claim paths used to resolve OAuth2 scopes (default: {@code scope,scp}). */
|
||||
public Builder scopeClaimPaths(String paths) { this.scopeClaimPaths = paths; 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
-337
@@ -1,337 +0,0 @@
|
||||
package dev.relism.flash.ext.oidc;
|
||||
|
||||
import dev.relism.flash.ext.openapi.OpenApiContributor;
|
||||
import dev.relism.flash.ext.openapi.OpenApiContributorRegistry;
|
||||
import dev.relism.flash.ext.openapi.OpenApiOperationContribution;
|
||||
import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import dev.relism.flash.extension.FlashExtension;
|
||||
import dev.relism.flash.extension.FlashRegistrar;
|
||||
import dev.relism.flash.routing.MiddlewareKey;
|
||||
import dev.relism.flash.routing.MiddlewareNode;
|
||||
import dev.relism.flash.models.Request;
|
||||
|
||||
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.*;
|
||||
|
||||
/**
|
||||
* Full OIDC Authorization Code + PKCE flow for Flash.
|
||||
*
|
||||
* <p>At {@link #provide}, the extension:
|
||||
* <ol>
|
||||
* <li>Fetches the provider discovery document — fail-fast at startup.</li>
|
||||
* <li>Provides {@link OidcMiddleware} and {@link JwtValidator} in the context.</li>
|
||||
* <li>Registers annotation processors for {@link Authenticated}, {@link RolesAllowed}
|
||||
* and {@link ScopesAllowed}.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>At {@link #routes}, three routes are registered:
|
||||
* <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 provider
|
||||
* end-session endpoint (if available) or to {@link OidcConfig#postLogoutRedirectUri()}.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <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 static final MiddlewareKey POLICY = MiddlewareKey.of("flash.oidc.policy");
|
||||
|
||||
private final OidcConfig config;
|
||||
|
||||
// Initialized in provide(), used in routes() — private to this extension instance.
|
||||
private OidcProviderMetadata meta;
|
||||
private OidcStateStore stateStore;
|
||||
private TokenClient tokenClient;
|
||||
private JwtValidator validator;
|
||||
private OidcMiddleware oidcMw;
|
||||
|
||||
public OidcExtension(OidcConfig config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
// ── Phase 1: services ─────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
HttpClient http = buildHttpClient(config);
|
||||
|
||||
// Discover provider endpoints (blocking; fail fast at startup).
|
||||
try {
|
||||
meta = DiscoveryClient.fetch(config.issuer(), http);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("OIDC discovery failed for issuer: " + config.issuer(), e);
|
||||
}
|
||||
|
||||
validator = new JwtValidator(meta.jwksUri(), config.issuer(), config.clientId(), config.algorithm(), http);
|
||||
stateStore = new OidcStateStore();
|
||||
tokenClient = new TokenClient(http, config);
|
||||
oidcMw = new OidcMiddleware(validator, config, meta, tokenClient);
|
||||
|
||||
ctx.provide(OidcMiddleware.class, oidcMw);
|
||||
ctx.provide(JwtValidator.class, validator);
|
||||
|
||||
ctx.addAnnotationProcessor(handlerClass -> {
|
||||
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(handlerClass);
|
||||
return policy != null ? List.of(MiddlewareNode.of(POLICY, oidcMw.policyMiddleware(policy))) : List.of();
|
||||
});
|
||||
ctx.onReady(() -> registerRoutes(app, ctx));
|
||||
}
|
||||
|
||||
private void registerRoutes(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
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();
|
||||
String nonce = UUID.randomUUID().toString();
|
||||
|
||||
String redirect = req.query("redirect");
|
||||
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.redirect(authUrl);
|
||||
return null;
|
||||
});
|
||||
|
||||
// ── 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.header("Set-Cookie", sessionCookie(session.id()))
|
||||
.redirect(entry.originalUrl());
|
||||
return null;
|
||||
});
|
||||
|
||||
// ── POST {prefix}/logout ──────────────────────────────────────────────
|
||||
// Invalidates the local session and redirects to end_session_endpoint.
|
||||
app.post(prefix + "/logout", (req, res) -> {
|
||||
String sessionId = OidcMiddleware.cookieValue(req, "oidc_session");
|
||||
String idTokenHint = null;
|
||||
|
||||
if (sessionId != null) {
|
||||
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.header("Set-Cookie", clearCookie).redirect(location);
|
||||
return null;
|
||||
});
|
||||
|
||||
// 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.
|
||||
* 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<>();
|
||||
if (tokens.accessToken() != null) merged.putAll(JwtUtils.parseClaims(tokens.accessToken()));
|
||||
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 sslCtx = SSLContext.getInstance("TLS");
|
||||
sslCtx.init(null, trustAll, new SecureRandom());
|
||||
return HttpClient.newBuilder().sslContext(sslCtx).build();
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to create trust-all SSLContext", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String absoluteRedirectUri(Request req) {
|
||||
return absoluteSelf(req, config.redirectUri());
|
||||
}
|
||||
|
||||
private String absoluteSelf(Request req, String uri) {
|
||||
if (!uri.startsWith("/")) return uri;
|
||||
return OidcMiddleware.selfOrigin(req, config.selfScheme()) + 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 OpenApiContributorRegistry} is actually on the classpath.
|
||||
*/
|
||||
private static final class OpenApiIntegration {
|
||||
static void register(FlashContext ctx,
|
||||
OidcConfig config, OidcProviderMetadata meta) {
|
||||
ctx.find(OpenApiContributorRegistry.class)
|
||||
.ifPresent(registry -> registry.add(new OpenApiContributor() {
|
||||
|
||||
@Override
|
||||
public Map<String, Object> componentContributions() {
|
||||
Map<String, String> scopesMap = new LinkedHashMap<>();
|
||||
for (String s : config.scopes().split("\\s+")) {
|
||||
if (!s.isBlank()) scopesMap.put(s, s);
|
||||
}
|
||||
Map<String, Object> flow = new LinkedHashMap<>();
|
||||
flow.put("authorizationUrl", meta.authorizationEndpoint());
|
||||
flow.put("tokenUrl", meta.tokenEndpoint());
|
||||
flow.put("scopes", scopesMap);
|
||||
|
||||
Map<String, Object> scheme = new LinkedHashMap<>();
|
||||
scheme.put("type", "oauth2");
|
||||
scheme.put("flows", Map.of("authorizationCode", flow));
|
||||
|
||||
Map<String, Object> securitySchemes = new LinkedHashMap<>();
|
||||
securitySchemes.put(config.schemeName(), scheme);
|
||||
return Map.of("securitySchemes", securitySchemes);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OpenApiOperationContribution operationFor(Class<?> handlerClass) {
|
||||
OpenApiOperationContribution.Builder out =
|
||||
OpenApiOperationContribution.builder();
|
||||
|
||||
List<String> operationScopes = OidcAuthPolicy.openApiScopesFor(handlerClass);
|
||||
if (operationScopes != null) {
|
||||
out.security(config.schemeName(), operationScopes);
|
||||
}
|
||||
|
||||
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(handlerClass);
|
||||
if (policy == null || policy.optionalAuth()) return out.build();
|
||||
|
||||
out.response(401, OpenApiResponseContribution.of("Authentication required"));
|
||||
|
||||
String[] roles = policy.requiredRoles();
|
||||
String[] scopes = policy.requiredScopes();
|
||||
if (roles.length == 0 && scopes.length == 0) return out.build();
|
||||
|
||||
String roleMessage = roles.length == 0 ? null : roleRequiredMessage(roles);
|
||||
String scopeMessage = scopes.length == 0 ? null : scopeRequiredMessage(scopes);
|
||||
if (roleMessage != null && scopeMessage != null) {
|
||||
out.response(403, OpenApiResponseContribution.of(roleMessage + "; " + scopeMessage));
|
||||
} else
|
||||
out.response(403, OpenApiResponseContribution.of(Objects.requireNonNullElse(roleMessage, scopeMessage)));
|
||||
return out.build();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private static String roleRequiredMessage(String[] roles) {
|
||||
if (roles.length == 1) return "\"" + roles[0] + "\" role required";
|
||||
return "Roles \"" + String.join(", ", roles) + "\" are required";
|
||||
}
|
||||
|
||||
private static String scopeRequiredMessage(String[] scopes) {
|
||||
if (scopes.length == 1) return "\"" + scopes[0] + "\" scope required";
|
||||
return "Scopes \"" + String.join(", ", scopes) + "\" are required";
|
||||
}
|
||||
}
|
||||
}
|
||||
-550
@@ -1,550 +0,0 @@
|
||||
package dev.relism.flash.ext.oidc;
|
||||
|
||||
import dev.relism.flash.exceptions.HttpException;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import dev.relism.flash.models.Response;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Request-level OIDC middleware. Exposed in the {@link FlashContext}
|
||||
* for manual use on lambda routes; injected automatically for handlers annotated with
|
||||
* {@link Authenticated}, {@link RolesAllowed} or {@link ScopesAllowed}.
|
||||
*
|
||||
* <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 static final String BEARER = "Bearer";
|
||||
|
||||
private final JwtValidator validator;
|
||||
private final OidcConfig config;
|
||||
private final OidcProviderMetadata meta;
|
||||
private final TokenClient tokenClient;
|
||||
private final String[] roleClaimPathParts;
|
||||
private final String[][] scopeClaimPathParts;
|
||||
|
||||
OidcMiddleware(JwtValidator validator, OidcConfig config,
|
||||
OidcProviderMetadata meta, TokenClient tokenClient) {
|
||||
this.validator = validator;
|
||||
this.config = config;
|
||||
this.meta = meta;
|
||||
this.tokenClient = tokenClient;
|
||||
this.roleClaimPathParts = splitClaimPath(config.rolesClaimPath());
|
||||
this.scopeClaimPathParts = splitClaimPaths(config.scopeClaimPaths());
|
||||
}
|
||||
|
||||
// -- Public API -----------------------------------------------------------
|
||||
|
||||
/** The single configured claim path used by every transport for role checks. */
|
||||
public String rolesClaimPath() { return config.rolesClaimPath(); }
|
||||
|
||||
/**
|
||||
* 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 protect(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link #protect()}, but a 401 challenge also carries {@code resource_metadata}
|
||||
* (RFC 9728 §5.1), resolved against this request's own scheme/host exactly like
|
||||
* {@link OidcExtension}'s redirect URIs. {@code resourceMetadataPath} is an absolute path
|
||||
* (e.g. {@code "/.well-known/oauth-protected-resource/mcp"}); pass {@code null} for plain
|
||||
* challenges. Used by {@code flash-ext-mcp} to make its Protected Resource Metadata
|
||||
* document discoverable straight from the {@code WWW-Authenticate} header, per the MCP
|
||||
* Authorization spec.
|
||||
*/
|
||||
public Middleware protect(String resourceMetadataPath) {
|
||||
return next -> (req, res) -> {
|
||||
Map<String, Object> claims = resolve(req, res, resourceMetadataPath);
|
||||
if (claims == null) return null; // redirect already written
|
||||
ClaimsHolder.set(claims);
|
||||
try {
|
||||
return next.handle(req, res);
|
||||
} finally {
|
||||
ClaimsHolder.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** OIDC issuer this middleware validates tokens against — the {@code iss} claim it enforces. */
|
||||
public String issuer() { return config.issuer(); }
|
||||
|
||||
/** Scheme used to build this app's own absolute URLs — see {@link OidcConfig#selfScheme()}. */
|
||||
public String selfScheme() { return config.selfScheme(); }
|
||||
|
||||
/**
|
||||
* Silently populates {@link ClaimsHolder} if a valid bearer token or session cookie
|
||||
* is present, but never rejects or redirects unauthenticated requests. Use this on
|
||||
* public routes that want to personalise the response when the user happens to be
|
||||
* logged in (e.g. showing a username on a landing page).
|
||||
*
|
||||
* <pre>{@code
|
||||
* app.get("/", handler, oidc.optional());
|
||||
* // Inside handler: ClaimsHolder.user() is non-null iff the user is logged in.
|
||||
* }</pre>
|
||||
*/
|
||||
public Middleware optional() {
|
||||
return next -> (req, res) -> {
|
||||
Map<String, Object> claims = resolveQuiet(req);
|
||||
if (claims != null) ClaimsHolder.set(claims);
|
||||
try {
|
||||
return next.handle(req, res);
|
||||
} finally {
|
||||
ClaimsHolder.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compiled authorization policy path used by annotation-driven mounting.
|
||||
* The policy is immutable and built once at boot.
|
||||
*/
|
||||
public Middleware authorize(OidcAuthPolicy policy) {
|
||||
if (policy.optionalAuth()) return optional();
|
||||
return next -> (req, res) -> {
|
||||
Map<String, Object> claims = resolve(req, res);
|
||||
if (claims == null) return null;
|
||||
enforcePolicy(claims, policy, res);
|
||||
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 authorize(OidcAuthPolicy.rolesAny(roles));
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires all listed scopes to be present in the token.
|
||||
* Scopes are resolved from configured claim paths (default: {@code scope,scp}).
|
||||
*/
|
||||
public Middleware requireScopes(String... scopes) {
|
||||
return authorize(OidcAuthPolicy.scopes(scopes, ScopesAllowed.Match.ALL));
|
||||
}
|
||||
|
||||
/**
|
||||
* Requires at least one of the listed scopes to be present in the token.
|
||||
* Scopes are resolved from configured claim paths (default: {@code scope,scp}).
|
||||
*/
|
||||
public Middleware requireAnyScope(String... scopes) {
|
||||
return authorize(OidcAuthPolicy.scopes(scopes, ScopesAllowed.Match.ANY));
|
||||
}
|
||||
|
||||
// -- Package-private: AnnotationProcessor hooks ---------------------------
|
||||
|
||||
Middleware authenticatedMiddleware() { return protect(); }
|
||||
Middleware optionalMiddleware() { return optional(); }
|
||||
Middleware rolesMiddleware(String[] required) { return requireRole(required); }
|
||||
Middleware scopesMiddleware(String[] required, ScopesAllowed.Match match) {
|
||||
return authorize(OidcAuthPolicy.scopes(required, match));
|
||||
}
|
||||
Middleware policyMiddleware(OidcAuthPolicy policy) { return authorize(policy); }
|
||||
|
||||
// -- Internals ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Like {@link #resolve} but never redirects or throws — returns {@code null} silently
|
||||
* when no valid credentials are present. Used by {@link #optional()}.
|
||||
*/
|
||||
private Map<String, Object> resolveQuiet(Request req) {
|
||||
String bearerToken = extractBearerToken(req.header("Authorization"));
|
||||
if (bearerToken != null) {
|
||||
try {
|
||||
return validator.validate(bearerToken);
|
||||
} catch (Exception ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
if (session.refreshToken() != null) {
|
||||
try {
|
||||
OidcSession refreshed = doRefresh(session);
|
||||
config.sessionStore().save(refreshed);
|
||||
return refreshed.claims();
|
||||
} catch (Exception ignored) { }
|
||||
}
|
||||
config.sessionStore().delete(sessionId);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, Response res) {
|
||||
return resolve(req, res, null);
|
||||
}
|
||||
|
||||
private Map<String, Object> resolve(Request req, Response res, String resourceMetadataPath) {
|
||||
// 1. Bearer token
|
||||
String bearerToken = extractBearerToken(req.header("Authorization"));
|
||||
if (bearerToken != null) {
|
||||
try {
|
||||
return validator.validate(bearerToken);
|
||||
} catch (HttpException e) {
|
||||
res.header("WWW-Authenticate", invalidTokenChallenge(req, resourceMetadataPath));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// 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")) {
|
||||
res.header("WWW-Authenticate", bearerChallenge(req, resourceMetadataPath));
|
||||
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.redirect(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 enforcePolicy(Map<String, Object> claims, OidcAuthPolicy policy, Response res) {
|
||||
checkRoles(claims, policy.requiredRoles());
|
||||
checkScopes(claims, policy.requiredScopes(), policy.scopeMatch(), res);
|
||||
}
|
||||
|
||||
private void checkRoles(Map<String, Object> claims, String[] required) {
|
||||
if (required.length == 0) return;
|
||||
if (rolesAllowed(claims, required)) return;
|
||||
throw HttpException.forbidden();
|
||||
}
|
||||
|
||||
private void checkScopes(Map<String, Object> claims, String[] required, ScopesAllowed.Match match,
|
||||
Response res) {
|
||||
if (required.length == 0) return;
|
||||
if (scopesAllowed(claims, required, match)) return;
|
||||
res.header("WWW-Authenticate", insufficientScopeChallenge(required));
|
||||
throw HttpException.forbidden();
|
||||
}
|
||||
|
||||
static String extractBearerToken(String authorizationHeader) {
|
||||
if (authorizationHeader == null) return null;
|
||||
int len = authorizationHeader.length();
|
||||
int start = 0;
|
||||
while (start < len && Character.isWhitespace(authorizationHeader.charAt(start))) start++;
|
||||
int schemeEnd = start + BEARER.length();
|
||||
if (schemeEnd > len || !authorizationHeader.regionMatches(true, start, BEARER, 0, BEARER.length())) {
|
||||
return null;
|
||||
}
|
||||
if (schemeEnd == len || !Character.isWhitespace(authorizationHeader.charAt(schemeEnd))) {
|
||||
return null;
|
||||
}
|
||||
int tokenStart = schemeEnd;
|
||||
while (tokenStart < len && Character.isWhitespace(authorizationHeader.charAt(tokenStart))) tokenStart++;
|
||||
if (tokenStart >= len) return null;
|
||||
int tokenEnd = len;
|
||||
while (tokenEnd > tokenStart && Character.isWhitespace(authorizationHeader.charAt(tokenEnd - 1))) tokenEnd--;
|
||||
return tokenEnd > tokenStart ? authorizationHeader.substring(tokenStart, tokenEnd) : null;
|
||||
}
|
||||
|
||||
String bearerChallenge() {
|
||||
return bearerChallenge(null, null);
|
||||
}
|
||||
|
||||
private String bearerChallenge(Request req, String resourceMetadataPath) {
|
||||
String base = BEARER + " realm=\"" + quoted(config.schemeName()) + "\"";
|
||||
if (resourceMetadataPath == null) return base;
|
||||
return base + ", resource_metadata=\"" + quoted(absoluteSelf(req, resourceMetadataPath)) + "\"";
|
||||
}
|
||||
|
||||
String invalidTokenChallenge() {
|
||||
return invalidTokenChallenge(null, null);
|
||||
}
|
||||
|
||||
private String invalidTokenChallenge(Request req, String resourceMetadataPath) {
|
||||
return bearerChallenge(req, resourceMetadataPath) + ", error=\"invalid_token\"";
|
||||
}
|
||||
|
||||
String insufficientScopeChallenge(String[] requiredScopes) {
|
||||
return bearerChallenge() + ", error=\"insufficient_scope\", scope=\""
|
||||
+ quoted(spaceDelimited(requiredScopes)) + "\"";
|
||||
}
|
||||
|
||||
private String absoluteSelf(Request req, String path) {
|
||||
if (!path.startsWith("/")) return path;
|
||||
return selfOrigin(req, config.selfScheme()) + path;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code scheme://host} clients actually reach this app on — the basis for every absolute
|
||||
* URL it publishes about itself (OAuth2 {@code redirect_uri}, the RFC 9728 resource
|
||||
* identifier and the {@code resource_metadata} challenge). Behind a reverse proxy the
|
||||
* request's own {@code Host} is the upstream address the proxy dialled, so
|
||||
* {@code X-Forwarded-Host}/{@code -Proto} win whenever present: without them the app would
|
||||
* name an address no client can resolve, and OAuth2 discovery fails with no error anyone
|
||||
* can trace back to here. Trusted unconditionally — a caller able to reach this app without
|
||||
* passing the proxy can do worse than spoof a self URL.
|
||||
*/
|
||||
public static String selfOrigin(Request req, String fallbackScheme) {
|
||||
String forwardedHost = req.header("X-Forwarded-Host");
|
||||
if (forwardedHost == null) return fallbackScheme + "://" + req.header("Host");
|
||||
String forwardedProto = req.header("X-Forwarded-Proto");
|
||||
return (forwardedProto != null ? forwardedProto : fallbackScheme) + "://" + forwardedHost;
|
||||
}
|
||||
|
||||
private static String spaceDelimited(String[] values) {
|
||||
if (values == null || values.length == 0) return "";
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < values.length; i++) {
|
||||
if (i > 0) sb.append(' ');
|
||||
sb.append(values[i]);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String quoted(String value) {
|
||||
StringBuilder out = new StringBuilder(value.length() + 8);
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
char c = value.charAt(i);
|
||||
if (c == '"' || c == '\\') out.append('\\');
|
||||
out.append(c);
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
boolean rolesAllowed(Map<String, Object> claims, String[] required) {
|
||||
Object actual = valueAtPath(claims, roleClaimPathParts);
|
||||
if (actual == null) return false;
|
||||
for (String role : required) {
|
||||
if (containsToken(actual, role)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
boolean scopesAllowed(Map<String, Object> claims, String[] required, ScopesAllowed.Match match) {
|
||||
if (match == ScopesAllowed.Match.ALL) {
|
||||
for (String scope : required) {
|
||||
if (!hasScope(claims, scope)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
for (String scope : required) {
|
||||
if (hasScope(claims, scope)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean hasScope(Map<String, Object> claims, String scope) {
|
||||
for (String[] pathParts : scopeClaimPathParts) {
|
||||
Object value = valueAtPath(claims, pathParts);
|
||||
if (value != null && containsToken(value, scope)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Object valueAtPath(Map<String, Object> claims, String[] pathParts) {
|
||||
Object current = claims;
|
||||
for (String part : pathParts) {
|
||||
if (!(current instanceof Map<?, ?> map)) return null;
|
||||
current = map.get(part);
|
||||
if (current == null) return null;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private static boolean containsToken(Object source, String token) {
|
||||
if (source instanceof String s) return containsDelimitedToken(s, token);
|
||||
if (source instanceof List<?> list) {
|
||||
for (Object item : list) {
|
||||
if (item == null) continue;
|
||||
if (tokenEquals(item.toString(), token)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (source instanceof Object[] arr) {
|
||||
for (Object item : arr) {
|
||||
if (item == null) continue;
|
||||
if (tokenEquals(item.toString(), token)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return tokenEquals(source.toString(), token);
|
||||
}
|
||||
|
||||
private static boolean containsDelimitedToken(String value, String token) {
|
||||
int len = value.length();
|
||||
int i = 0;
|
||||
while (i < len) {
|
||||
while (i < len && isScopeDelimiter(value.charAt(i))) i++;
|
||||
int start = i;
|
||||
while (i < len && !isScopeDelimiter(value.charAt(i))) i++;
|
||||
int end = i;
|
||||
if (end > start && end - start == token.length() && value.regionMatches(start, token, 0, token.length())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean tokenEquals(String value, String token) {
|
||||
int start = 0;
|
||||
int end = value.length();
|
||||
while (start < end && Character.isWhitespace(value.charAt(start))) start++;
|
||||
while (end > start && Character.isWhitespace(value.charAt(end - 1))) end--;
|
||||
return end - start == token.length() && value.regionMatches(start, token, 0, token.length());
|
||||
}
|
||||
|
||||
private static boolean isScopeDelimiter(char c) {
|
||||
return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ',';
|
||||
}
|
||||
|
||||
private static String[] splitClaimPath(String path) {
|
||||
if (path == null || path.isBlank()) {
|
||||
throw new IllegalStateException("OIDC claim path cannot be blank");
|
||||
}
|
||||
List<String> parts = new ArrayList<>(4);
|
||||
int start = 0;
|
||||
int len = path.length();
|
||||
for (int i = 0; i <= len; i++) {
|
||||
if (i == len || path.charAt(i) == '.') {
|
||||
String p = path.substring(start, i).trim();
|
||||
if (!p.isEmpty()) parts.add(p);
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
if (parts.isEmpty()) {
|
||||
throw new IllegalStateException("OIDC claim path cannot be blank");
|
||||
}
|
||||
return parts.toArray(String[]::new);
|
||||
}
|
||||
|
||||
private static String[][] splitClaimPaths(String paths) {
|
||||
String source = (paths == null || paths.isBlank()) ? "scope,scp" : paths;
|
||||
List<String[]> out = new ArrayList<>(4);
|
||||
int start = 0;
|
||||
int len = source.length();
|
||||
for (int i = 0; i <= len; i++) {
|
||||
if (i == len || source.charAt(i) == ',') {
|
||||
String raw = source.substring(start, i).trim();
|
||||
if (!raw.isEmpty()) out.add(splitClaimPath(raw));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
if (out.isEmpty()) {
|
||||
return new String[][]{ splitClaimPath("scope"), splitClaimPath("scp") };
|
||||
}
|
||||
return out.toArray(String[][]::new);
|
||||
}
|
||||
|
||||
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;
|
||||
int len = header.length();
|
||||
int start = 0;
|
||||
while (start < len) {
|
||||
int semi = header.indexOf(';', start);
|
||||
int end = semi < 0 ? len : semi;
|
||||
int eq = header.indexOf('=', start);
|
||||
if (eq > start && eq < end) {
|
||||
int ns = start, ne = eq;
|
||||
while (ns < ne && header.charAt(ns) == ' ') ns++;
|
||||
while (ne > ns && header.charAt(ne-1) == ' ') ne--;
|
||||
if (ne - ns == name.length() && header.regionMatches(ns, name, 0, name.length()))
|
||||
return header.substring(eq + 1, end).strip();
|
||||
}
|
||||
start = end + 1;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
package dev.relism.flash.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
|
||||
) {}
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
package dev.relism.flash.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
@@ -1,14 +0,0 @@
|
||||
package dev.relism.flash.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);
|
||||
}
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
package dev.relism.flash.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
@@ -1,10 +0,0 @@
|
||||
package dev.relism.flash.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
|
||||
) {}
|
||||
-237
@@ -1,237 +0,0 @@
|
||||
package dev.relism.flash.ext.oidc;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.ArrayList;
|
||||
|
||||
/**
|
||||
* 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("realm_access.roles"), "scopes", u.scopes());
|
||||
* }, 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);
|
||||
}
|
||||
|
||||
// -- Scopes ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolves OAuth2 scopes from standard OIDC/OAuth claims using fallback order:
|
||||
* {@code scope} then {@code scp}. Supports both space-separated string and list forms.
|
||||
*/
|
||||
public List<String> scopes() {
|
||||
return scopes("scope,scp");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves scopes from comma-separated claim paths (example: {@code "scope,scp,permissions.scopes"}).
|
||||
*/
|
||||
public List<String> scopes(String claimPaths) {
|
||||
List<String> out = new ArrayList<>();
|
||||
for (String[] path : splitClaimPaths(claimPaths)) {
|
||||
Object value = valueAtPath(path);
|
||||
if (value == null) continue;
|
||||
if (value instanceof String s) {
|
||||
appendDelimitedTokens(out, s);
|
||||
continue;
|
||||
}
|
||||
if (value instanceof List<?> list) {
|
||||
for (Object item : list) {
|
||||
if (item == null) continue;
|
||||
String token = item.toString().trim();
|
||||
if (!token.isEmpty()) out.add(token);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
String token = value.toString().trim();
|
||||
if (!token.isEmpty()) out.add(token);
|
||||
}
|
||||
return out.isEmpty() ? List.of() : List.copyOf(out);
|
||||
}
|
||||
|
||||
/** Returns {@code true} if the user has {@code scope}, searching default claim paths {@code scope,scp}. */
|
||||
public boolean hasScope(String scope) {
|
||||
return hasScope("scope,scp", scope);
|
||||
}
|
||||
|
||||
/** Returns {@code true} if the user has {@code scope} in any of {@code claimPaths}. */
|
||||
public boolean hasScope(String claimPaths, String scope) {
|
||||
if (scope == null || scope.isBlank()) return false;
|
||||
String target = scope.trim();
|
||||
for (String[] path : splitClaimPaths(claimPaths)) {
|
||||
Object value = valueAtPath(path);
|
||||
if (value == null) continue;
|
||||
if (value instanceof String s && containsDelimitedToken(s, target)) return true;
|
||||
if (value instanceof List<?> list) {
|
||||
for (Object item : list) {
|
||||
if (item == null) continue;
|
||||
if (target.equals(item.toString().trim())) return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (target.equals(value.toString().trim())) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
}
|
||||
|
||||
private Object valueAtPath(String[] path) {
|
||||
Object current = claims;
|
||||
for (String part : path) {
|
||||
if (!(current instanceof Map<?, ?> m)) return null;
|
||||
current = m.get(part);
|
||||
if (current == null) return null;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private static String[][] splitClaimPaths(String claimPaths) {
|
||||
String source = (claimPaths == null || claimPaths.isBlank()) ? "scope,scp" : claimPaths;
|
||||
List<String[]> out = new ArrayList<>(4);
|
||||
int start = 0;
|
||||
int len = source.length();
|
||||
for (int i = 0; i <= len; i++) {
|
||||
if (i == len || source.charAt(i) == ',') {
|
||||
String raw = source.substring(start, i).trim();
|
||||
if (!raw.isEmpty()) out.add(splitPath(raw));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
return out.isEmpty() ? new String[][]{ splitPath("scope"), splitPath("scp") } : out.toArray(String[][]::new);
|
||||
}
|
||||
|
||||
private static String[] splitPath(String path) {
|
||||
List<String> out = new ArrayList<>(4);
|
||||
int start = 0;
|
||||
int len = path.length();
|
||||
for (int i = 0; i <= len; i++) {
|
||||
if (i == len || path.charAt(i) == '.') {
|
||||
String raw = path.substring(start, i).trim();
|
||||
if (!raw.isEmpty()) out.add(raw);
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
return out.isEmpty() ? new String[]{ path } : out.toArray(String[]::new);
|
||||
}
|
||||
|
||||
private static void appendDelimitedTokens(List<String> target, String source) {
|
||||
int len = source.length();
|
||||
int i = 0;
|
||||
while (i < len) {
|
||||
while (i < len && isDelimiter(source.charAt(i))) i++;
|
||||
int start = i;
|
||||
while (i < len && !isDelimiter(source.charAt(i))) i++;
|
||||
if (i > start) target.add(source.substring(start, i));
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean containsDelimitedToken(String source, String token) {
|
||||
int len = source.length();
|
||||
int i = 0;
|
||||
while (i < len) {
|
||||
while (i < len && isDelimiter(source.charAt(i))) i++;
|
||||
int start = i;
|
||||
while (i < len && !isDelimiter(source.charAt(i))) i++;
|
||||
int end = i;
|
||||
if (end > start && end - start == token.length() && source.regionMatches(start, token, 0, token.length())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isDelimiter(char c) {
|
||||
return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == ',';
|
||||
}
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
package dev.relism.flash.ext.oidc;
|
||||
|
||||
import dev.relism.flash.exceptions.HttpException;
|
||||
|
||||
/**
|
||||
* Thrown when OIDC token validation fails (signature, claims, nonce, expiry, etc.).
|
||||
* Distinct from {@link 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);
|
||||
}
|
||||
}
|
||||
-36
@@ -1,36 +0,0 @@
|
||||
package dev.relism.flash.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);
|
||||
}
|
||||
}
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
package dev.relism.flash.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();
|
||||
}
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
package dev.relism.flash.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 token carries the required OAuth2 scopes.
|
||||
* Authentication is implicitly required.
|
||||
*
|
||||
* <p>Scopes are resolved from the configured claim paths in
|
||||
* {@link OidcConfig#scopeClaimPaths()} (default: {@code "scope,scp"}) and support
|
||||
* both standard formats:
|
||||
* <ul>
|
||||
* <li>{@code scope}: space-separated string</li>
|
||||
* <li>{@code scp}: string list (or string)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <pre>{@code
|
||||
* @Route(method = HttpMethod.GET, path = "/api/orders")
|
||||
* @ScopesAllowed("orders:read")
|
||||
* public class ListOrders extends JacksonHandler { ... }
|
||||
*
|
||||
* @Route(method = HttpMethod.POST, path = "/api/orders")
|
||||
* @ScopesAllowed(value = {"orders:write", "payments:write"}, match = ScopesAllowed.Match.ANY)
|
||||
* public class CreateOrder extends JacksonHandler { ... }
|
||||
* }</pre>
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public @interface ScopesAllowed {
|
||||
/** Required scopes. */
|
||||
String[] value();
|
||||
|
||||
/** Matching mode for {@link #value()}. */
|
||||
Match match() default Match.ALL;
|
||||
|
||||
enum Match {
|
||||
/** Any one required scope is sufficient. */
|
||||
ANY,
|
||||
/** All required scopes must be present. */
|
||||
ALL
|
||||
}
|
||||
}
|
||||
-112
@@ -1,112 +0,0 @@
|
||||
package dev.relism.flash.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;
|
||||
}
|
||||
}
|
||||
-94
@@ -1,94 +0,0 @@
|
||||
package dev.relism.flash.ext.oidc;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class OidcAuthPolicyTest {
|
||||
|
||||
static class PlainHandler {}
|
||||
|
||||
@Authenticated
|
||||
static class AuthenticatedHandler {}
|
||||
|
||||
@Authenticated(optional = true)
|
||||
static class OptionalHandler {}
|
||||
|
||||
@RolesAllowed({"admin", " editor ", "admin"})
|
||||
static class RolesHandler {}
|
||||
|
||||
@ScopesAllowed(value = {"orders:write", " payments:write ", "orders:write"}, match = ScopesAllowed.Match.ANY)
|
||||
static class ScopesHandler {}
|
||||
|
||||
@Authenticated
|
||||
@RolesAllowed("admin")
|
||||
@ScopesAllowed(value = {"orders:read", "payments:read"}, match = ScopesAllowed.Match.ALL)
|
||||
static class CombinedHandler {}
|
||||
|
||||
@Authenticated(optional = true)
|
||||
@ScopesAllowed("orders:read")
|
||||
static class InvalidOptionalHandler {}
|
||||
|
||||
@Test
|
||||
void compileFromAnnotations_noSecurityAnnotations_returnsNull() {
|
||||
assertNull(OidcAuthPolicy.compileFromAnnotations(PlainHandler.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void compileFromAnnotations_authenticated_createsRequiredAuthPolicy() {
|
||||
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(AuthenticatedHandler.class);
|
||||
assertNotNull(policy);
|
||||
assertFalse(policy.optionalAuth());
|
||||
assertEquals(0, policy.requiredRoles().length);
|
||||
assertEquals(0, policy.requiredScopes().length);
|
||||
}
|
||||
|
||||
@Test
|
||||
void compileFromAnnotations_optionalAuth_createsOptionalPolicy() {
|
||||
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(OptionalHandler.class);
|
||||
assertNotNull(policy);
|
||||
assertTrue(policy.optionalAuth());
|
||||
}
|
||||
|
||||
@Test
|
||||
void compileFromAnnotations_rolesAndScopes_areNormalizedAndMerged() {
|
||||
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(CombinedHandler.class);
|
||||
assertNotNull(policy);
|
||||
assertFalse(policy.optionalAuth());
|
||||
assertArrayEquals(new String[]{"admin"}, policy.requiredRoles());
|
||||
assertArrayEquals(new String[]{"orders:read", "payments:read"}, policy.requiredScopes());
|
||||
assertEquals(ScopesAllowed.Match.ALL, policy.scopeMatch());
|
||||
}
|
||||
|
||||
@Test
|
||||
void compileFromAnnotations_scopesAny_preservesMatchModeAndDedupes() {
|
||||
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(ScopesHandler.class);
|
||||
assertNotNull(policy);
|
||||
assertArrayEquals(new String[]{"orders:write", "payments:write"}, policy.requiredScopes());
|
||||
assertEquals(ScopesAllowed.Match.ANY, policy.scopeMatch());
|
||||
}
|
||||
|
||||
@Test
|
||||
void compileFromAnnotations_optionalCannotBeCombinedWithConstraints() {
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> OidcAuthPolicy.compileFromAnnotations(InvalidOptionalHandler.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void openApiScopesFor_returnsScopesWhenPresent() {
|
||||
assertEquals(List.of("orders:write", "payments:write"),
|
||||
OidcAuthPolicy.openApiScopesFor(ScopesHandler.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void openApiScopesFor_rolesOnly_returnsEmptyList() {
|
||||
assertEquals(List.of(), OidcAuthPolicy.openApiScopesFor(RolesHandler.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void openApiScopesFor_noSecurity_returnsNull() {
|
||||
assertNull(OidcAuthPolicy.openApiScopesFor(PlainHandler.class));
|
||||
}
|
||||
}
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
package dev.relism.flash.ext.oidc;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class OidcMiddlewareAuthzTest {
|
||||
|
||||
private static OidcMiddleware middleware(String rolesPath, String scopePaths) {
|
||||
OidcConfig cfg = OidcConfig.builder("https://idp.example.com", "client", "secret", "/auth/callback")
|
||||
.rolesClaimPath(rolesPath)
|
||||
.scopeClaimPaths(scopePaths)
|
||||
.build();
|
||||
return new OidcMiddleware(null, cfg, null, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rolesAllowed_readsConfiguredNestedClaimPath() {
|
||||
OidcMiddleware mw = middleware("realm_access.roles", "scope,scp");
|
||||
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", List.of("user", "admin")));
|
||||
|
||||
assertTrue(mw.rolesAllowed(claims, new String[]{"admin"}));
|
||||
assertFalse(mw.rolesAllowed(claims, new String[]{"ops"}));
|
||||
}
|
||||
|
||||
@Test
|
||||
void scopesAllowed_all_requiresEveryScope() {
|
||||
OidcMiddleware mw = middleware("roles", "scope,scp");
|
||||
Map<String, Object> claims = Map.of("scope", "openid profile orders:read");
|
||||
|
||||
assertTrue(mw.scopesAllowed(claims, new String[]{"openid", "orders:read"}, ScopesAllowed.Match.ALL));
|
||||
assertFalse(mw.scopesAllowed(claims, new String[]{"openid", "orders:write"}, ScopesAllowed.Match.ALL));
|
||||
}
|
||||
|
||||
@Test
|
||||
void scopesAllowed_any_acceptsAnyConfiguredScopeSource() {
|
||||
OidcMiddleware mw = middleware("roles", "scope,scp,permissions.scopes");
|
||||
Map<String, Object> claims = Map.of(
|
||||
"scp", List.of("payments:write"),
|
||||
"permissions", Map.of("scopes", "orders:approve")
|
||||
);
|
||||
|
||||
assertTrue(mw.scopesAllowed(claims, new String[]{"orders:approve", "orders:read"}, ScopesAllowed.Match.ANY));
|
||||
assertTrue(mw.scopesAllowed(claims, new String[]{"payments:write"}, ScopesAllowed.Match.ANY));
|
||||
assertFalse(mw.scopesAllowed(claims, new String[]{"unknown"}, ScopesAllowed.Match.ANY));
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractBearerToken_acceptsCaseInsensitiveBearerAndTrimsSpaces() {
|
||||
assertEquals("abc.def.ghi", OidcMiddleware.extractBearerToken("Bearer abc.def.ghi"));
|
||||
assertEquals("abc", OidcMiddleware.extractBearerToken(" bearer abc "));
|
||||
assertNull(OidcMiddleware.extractBearerToken("Basic Zm9vOmJhcg=="));
|
||||
assertNull(OidcMiddleware.extractBearerToken("Bearer"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void bearerChallenge_containsRealmAndRfcErrors() {
|
||||
OidcMiddleware mw = middleware("roles", "scope,scp");
|
||||
|
||||
String basic = mw.bearerChallenge();
|
||||
String invalid = mw.invalidTokenChallenge();
|
||||
String insufficient = mw.insufficientScopeChallenge(new String[]{"orders:read", "payments:write"});
|
||||
|
||||
assertTrue(basic.startsWith("Bearer realm=\""));
|
||||
assertTrue(invalid.contains("error=\"invalid_token\""));
|
||||
assertTrue(insufficient.contains("error=\"insufficient_scope\""));
|
||||
assertTrue(insufficient.contains("scope=\"orders:read payments:write\""));
|
||||
}
|
||||
}
|
||||
-124
@@ -1,124 +0,0 @@
|
||||
package dev.relism.flash.ext.oidc;
|
||||
|
||||
import dev.relism.flash.ext.openapi.OpenApiContributorRegistry;
|
||||
import dev.relism.flash.ext.openapi.OpenApiOperationContribution;
|
||||
import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
|
||||
import dev.relism.flash.ext.openapi.OpenApiContributor;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class OidcOpenApiInteropTest {
|
||||
|
||||
@Authenticated
|
||||
static class AuthOnly {}
|
||||
|
||||
@Authenticated(optional = true)
|
||||
static class AuthOptional {}
|
||||
|
||||
@RolesAllowed("admin")
|
||||
static class OneRole {}
|
||||
|
||||
@RolesAllowed({"admin", "operator"})
|
||||
static class MultiRole {}
|
||||
|
||||
@ScopesAllowed("orders:write")
|
||||
static class OneScope {}
|
||||
|
||||
@ScopesAllowed({"orders:write", "payments:write"})
|
||||
static class MultiScope {}
|
||||
|
||||
@RolesAllowed("admin")
|
||||
@ScopesAllowed("orders:write")
|
||||
static class RoleAndScope {}
|
||||
|
||||
@Test
|
||||
void autoResponses_authOnly() throws Exception {
|
||||
Map<Integer, String> responses = responses(AuthOnly.class);
|
||||
assertEquals("Authentication required", responses.get(401));
|
||||
assertFalse(responses.containsKey(403));
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoResponses_optionalAuth_addsNothing() throws Exception {
|
||||
Map<Integer, String> responses = responses(AuthOptional.class);
|
||||
assertTrue(responses.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoResponses_oneRole_formatsSingular() throws Exception {
|
||||
Map<Integer, String> responses = responses(OneRole.class);
|
||||
assertEquals("Authentication required", responses.get(401));
|
||||
assertEquals("\"admin\" role required", responses.get(403));
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoResponses_multiRoles_formatsPlural() throws Exception {
|
||||
Map<Integer, String> responses = responses(MultiRole.class);
|
||||
assertEquals("Roles \"admin, operator\" are required", responses.get(403));
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoResponses_oneScope_formatsSingular() throws Exception {
|
||||
Map<Integer, String> responses = responses(OneScope.class);
|
||||
assertEquals("\"orders:write\" scope required", responses.get(403));
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoResponses_multiScopes_formatsPlural() throws Exception {
|
||||
Map<Integer, String> responses = responses(MultiScope.class);
|
||||
assertEquals("Scopes \"orders:write, payments:write\" are required", responses.get(403));
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoResponses_roleAndScope_combinesMessages() throws Exception {
|
||||
Map<Integer, String> responses = responses(RoleAndScope.class);
|
||||
assertEquals("\"admin\" role required; \"orders:write\" scope required", responses.get(403));
|
||||
}
|
||||
|
||||
@Test
|
||||
void securityContribution_presentForAuthenticatedHandler() throws Exception {
|
||||
OpenApiOperationContribution operation = contributor().operationFor(AuthOnly.class);
|
||||
List<Map<String, List<String>>> security = operation.security();
|
||||
assertEquals(1, security.size());
|
||||
assertTrue(security.getFirst().containsKey("issuer"));
|
||||
}
|
||||
|
||||
private static OpenApiContributor contributor() throws Exception {
|
||||
Class<?> clazz = Class.forName("dev.relism.flash.ext.oidc.OidcExtension$OpenApiIntegration");
|
||||
Constructor<?> ctor = clazz.getDeclaredConstructor();
|
||||
ctor.setAccessible(true);
|
||||
Object instance = ctor.newInstance();
|
||||
|
||||
Method m = clazz.getDeclaredMethod("register", FlashContext.class, OidcConfig.class, OidcProviderMetadata.class);
|
||||
m.setAccessible(true);
|
||||
|
||||
FlashContext ctx = new FlashContext();
|
||||
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
|
||||
ctx.provide(OpenApiContributorRegistry.class, registry);
|
||||
ctx.complete();
|
||||
|
||||
OidcConfig config = OidcConfig.builder("https://issuer", "c", "s", "/cb").build();
|
||||
OidcProviderMetadata meta = new OidcProviderMetadata("a", "t", "u", "j", "e");
|
||||
m.invoke(instance, ctx, config, meta);
|
||||
|
||||
return registry.contributors().getFirst();
|
||||
}
|
||||
|
||||
private static Map<Integer, String> responses(Class<?> cls) throws Exception {
|
||||
Map<Integer, OpenApiResponseContribution> byCode = contributor().operationFor(cls).responses();
|
||||
java.util.LinkedHashMap<Integer, String> out = new java.util.LinkedHashMap<>();
|
||||
for (Map.Entry<Integer, OpenApiResponseContribution> e : byCode.entrySet()) {
|
||||
out.put(e.getKey(), e.getValue().description());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
package dev.relism.flash.ext.oidc;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class OidcUserScopesTest {
|
||||
|
||||
@Test
|
||||
void scopes_readsStandardScopeString() {
|
||||
OidcUser user = new OidcUser(Map.of("scope", "openid profile orders:read"));
|
||||
|
||||
assertEquals(List.of("openid", "profile", "orders:read"), user.scopes());
|
||||
assertTrue(user.hasScope("orders:read"));
|
||||
assertFalse(user.hasScope("orders:write"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void scopes_fallsBackToScpArray() {
|
||||
OidcUser user = new OidcUser(Map.of("scp", List.of("orders:write", "payments:write")));
|
||||
|
||||
assertEquals(List.of("orders:write", "payments:write"), user.scopes());
|
||||
assertTrue(user.hasScope("payments:write"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void scopes_supportsCustomClaimPaths() {
|
||||
OidcUser user = new OidcUser(Map.of("permissions", Map.of("scopes", List.of("a", "b"))));
|
||||
|
||||
assertEquals(List.of("a", "b"), user.scopes("permissions.scopes"));
|
||||
assertTrue(user.hasScope("permissions.scopes", "a"));
|
||||
assertFalse(user.hasScope("permissions.scopes", "x"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void scopes_combinesMultipleClaimPathsInOrder() {
|
||||
OidcUser user = new OidcUser(Map.of(
|
||||
"scope", "openid",
|
||||
"scp", List.of("profile", "orders:read")
|
||||
));
|
||||
|
||||
assertEquals(List.of("openid", "profile", "orders:read"), user.scopes("scope,scp"));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
# flash-ext-openapi
|
||||
|
||||
OpenAPI 3.0.3 generation + Swagger UI for Flash.
|
||||
OpenAPI 3.0.3 generation and Swagger UI, built from what the handlers already say about
|
||||
themselves.
|
||||
|
||||
## What it provides
|
||||
|
||||
@@ -10,8 +11,6 @@ OpenAPI 3.0.3 generation + Swagger UI for Flash.
|
||||
| `GET /openapi.yaml` | OpenAPI spec YAML |
|
||||
| `GET /openapi/swagger` | Swagger UI |
|
||||
|
||||
## Install
|
||||
|
||||
```java
|
||||
FlashApp.create(8080)
|
||||
.install(new JacksonExtension())
|
||||
@@ -20,135 +19,140 @@ FlashApp.create(8080)
|
||||
.startAndBlock();
|
||||
```
|
||||
|
||||
## Operation annotation
|
||||
## What you get without writing anything
|
||||
|
||||
Every class-based route is documented, annotated or not. Read off the code:
|
||||
|
||||
- **path parameters**, from `/{id}` in the route
|
||||
- **the request body**, from the handler's own body type (see below)
|
||||
- **the response schema**, from what `handle` returns — an object, a `List<T>`, a `Map<String, T>`
|
||||
- **the media types**: `@Consumes` for what it reads, `@Produces` for what it answers, which are
|
||||
two different questions — taking a JSON body does not make the answer JSON
|
||||
- **error responses**, in the one shape Flash answers failures with: `{"error": "...", "status": 404}`
|
||||
- **security and rate limiting**, from the extensions that enforce them
|
||||
|
||||
Annotations add what the code cannot say: prose, extra statuses, examples. They never repeat it.
|
||||
|
||||
## Leaving a route out
|
||||
|
||||
```java
|
||||
@GET("/healthz")
|
||||
@Undocumented
|
||||
public final class Health extends RequestHandler { ... }
|
||||
```
|
||||
|
||||
Every route is documented, so a document never lies by omission. `@Undocumented` says a route is
|
||||
not part of the API — a health check, an internal callback, something on its way out. On a base
|
||||
class it leaves out every handler written against it.
|
||||
|
||||
## Request bodies
|
||||
|
||||
A handler that extends `BodyHandler` — `JsonHandler` and `XmlHandler`, and anything else that
|
||||
reads a format — declares its body type in its signature, and that is the whole documentation:
|
||||
|
||||
```java
|
||||
@POST("/users")
|
||||
public final class CreateUser extends JsonHandler<NewUser> {
|
||||
@Override protected Object handle(Request req, Response res, NewUser body) {
|
||||
return users.create(body);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```yaml
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/NewUser' }
|
||||
```
|
||||
|
||||
The media type of the body comes from `@Consumes` on the base class, so an XML handler documents
|
||||
itself as XML without a word from the route.
|
||||
|
||||
For a handler that reads the body by hand, or to describe it as something else, declare it:
|
||||
|
||||
```java
|
||||
@PUT("/users")
|
||||
@RequestBody(value = User.class, array = true, description = "Users to store")
|
||||
public final class ReplaceUsers extends RequestHandler { ... }
|
||||
```
|
||||
|
||||
## Operations
|
||||
|
||||
```java
|
||||
@GET("/users/{id}")
|
||||
@ApiOperation(summary = "Get user", description = "Returns one user", tags = {"users"})
|
||||
@Parameter(name = "expand", in = ParameterIn.QUERY, type = SchemaType.STRING, examples = {"roles", "permissions"})
|
||||
@APIResponse(
|
||||
responseCode = "200",
|
||||
description = "User found",
|
||||
content = @Content(contentType = ContentType.JSON, schema = UserDto.class)
|
||||
)
|
||||
public final class GetUser extends RequestHandler { ... }
|
||||
```
|
||||
|
||||
## Response patterns
|
||||
`@ApiOperation` is optional: without it the route is still in the document, with no summary.
|
||||
|
||||
### Single object
|
||||
## Responses
|
||||
|
||||
The success response is inferred. Declare one only to say more:
|
||||
|
||||
```java
|
||||
@APIResponse(
|
||||
responseCode = "200",
|
||||
description = "User found",
|
||||
content = @Content(contentType = ContentType.JSON, schema = UserDto.class)
|
||||
)
|
||||
@APIResponse(responseCode = "200", description = "User found",
|
||||
content = @Content(schema = UserDto.class, example = "{\"id\":\"usr-1\"}"))
|
||||
@APIResponse(responseCode = "409", description = "That email is taken")
|
||||
@APIResponse(responseCode = "204", description = "Deleted")
|
||||
```
|
||||
|
||||
### Array
|
||||
- `content.schema` omitted on a 2xx: the handler's return type.
|
||||
- Any 4xx or 5xx without an explicit schema: Flash's error object, referenced from `components` —
|
||||
in JSON, which is what Flash answers a failure with whatever the route produces.
|
||||
- `content.array = true` wraps whichever schema was chosen.
|
||||
- No schema and nothing to infer one from: a response with no body, which is what a 204 is.
|
||||
|
||||
The media type of every answer is the handler's `@Produces`, JSON when nothing says otherwise. It
|
||||
is not on `@Content`: one handler answers in one format, and a status code does not change that.
|
||||
|
||||
**A response several operations share is written once.** Identical answers — the 401 of every
|
||||
guarded route, the 429 of every limited one — become `components.responses` entries referenced by
|
||||
`$ref`, instead of being repeated on every path.
|
||||
|
||||
## DTO schemas
|
||||
|
||||
```java
|
||||
@APIResponse(
|
||||
responseCode = "200",
|
||||
description = "Users listed",
|
||||
content = @Content(contentType = ContentType.JSON, schema = UserDto.class, array = true)
|
||||
)
|
||||
@Schema(name = "User", title = "User DTO", description = "Public user")
|
||||
public record UserDto(
|
||||
@SchemaProperty(title = "ID", example = "USR-100") String id,
|
||||
@SchemaProperty(hidden = true) String internalDebug) {}
|
||||
```
|
||||
|
||||
### No content
|
||||
|
||||
```java
|
||||
@APIResponse(
|
||||
responseCode = "204",
|
||||
description = "Deleted",
|
||||
content = @Content(contentType = ContentType.NONE)
|
||||
)
|
||||
```
|
||||
|
||||
### Inferred from handler return type
|
||||
|
||||
```java
|
||||
@APIResponse(
|
||||
responseCode = "200",
|
||||
content = @Content
|
||||
)
|
||||
```
|
||||
|
||||
If `content.schema` is omitted, schema is inferred from the handler `handle(...)` return type.
|
||||
Explicit `content.schema` always wins over inference.
|
||||
|
||||
Inference defaults:
|
||||
|
||||
- `UserDto` -> object schema for `UserDto`
|
||||
- `List<UserDto>` / `Set<UserDto>` / `UserDto[]` -> `array` with `items: UserDto`
|
||||
- `Map<String, UserDto>` -> `object` with `additionalProperties: UserDto`
|
||||
|
||||
## DTO schema metadata
|
||||
|
||||
```java
|
||||
@Schema(name = "User", title = "User DTO", description = "Public user", deprecated = false)
|
||||
public class UserDto {
|
||||
|
||||
@SchemaProperty(title = "ID", required = true, example = "USR-100", enumeration = {"USR-100", "USR-101"})
|
||||
public String id;
|
||||
|
||||
@SchemaProperty(hidden = true)
|
||||
public String internalDebug;
|
||||
}
|
||||
```
|
||||
|
||||
Supported field-level exclusion:
|
||||
|
||||
- `@Schema(hidden = true)` / `@SchemaProperty(hidden = true)`
|
||||
- `@JsonIgnore`
|
||||
- `@JsonIgnoreProperties(...)`
|
||||
- `transient` / `static`
|
||||
Each type is described once under `components.schemas` and referenced everywhere it appears.
|
||||
Field-level exclusion: `@Schema(hidden = true)`, `@SchemaProperty(hidden = true)`, `@JsonIgnore`,
|
||||
`@JsonIgnoreProperties`, `transient`, `static`. `jakarta.validation` constraints (`@NotNull`,
|
||||
`@NotBlank`, `@NotEmpty`, `@Size`, `@Min`, `@Max`, `@Email`, `@Pattern`) become the schema's own
|
||||
bounds and required fields, so a rule is written once and documented for free.
|
||||
|
||||
## Contributor API
|
||||
|
||||
OpenAPI is extension-agnostic. Other extensions contribute with `OpenApiContributor` via
|
||||
`OpenApiContributorRegistry`.
|
||||
OpenAPI is extension-agnostic. Other extensions contribute through `OpenApiContributor`, held in
|
||||
`OpenApiContributorRegistry`:
|
||||
|
||||
Supported contribution surfaces:
|
||||
|
||||
- `components` fragments (merged with last-wins)
|
||||
- `components` fragments (merged last-wins)
|
||||
- operation `security` requirements (additive)
|
||||
- operation `responses` and response `headers` (additive)
|
||||
|
||||
Merge policy:
|
||||
Manual `@APIResponse` description always wins over a contributor's for the same status.
|
||||
|
||||
- contributor collisions use **last-wins**
|
||||
- manual `@APIResponse` description always wins over contributors for the same status
|
||||
### Security interop
|
||||
|
||||
## OIDC interop
|
||||
With `flash-ext-security-core` installed, every registered mechanism's scheme lands under
|
||||
`components.securitySchemes`, and every operation carrying a security annotation lists them as
|
||||
`security` alternatives with automatic `401` and — for roles or scopes — `403` responses.
|
||||
|
||||
When `flash-ext-oidc` is installed, OpenAPI integrates automatically:
|
||||
### Limiter interop
|
||||
|
||||
- security scheme under `components.securitySchemes`
|
||||
- per-operation `security`
|
||||
- auto responses (class-based handlers):
|
||||
- `401 Authentication required`
|
||||
- `403` role/scope required messages when applicable
|
||||
|
||||
Manual `@APIResponse` for the same status code always wins.
|
||||
|
||||
## Limiter interop
|
||||
|
||||
When `flash-ext-limiter` is installed, handlers with `@Limit` automatically get response
|
||||
headers documented in OpenAPI:
|
||||
|
||||
- `X-RateLimit-Limit`
|
||||
- `X-RateLimit-Remaining`
|
||||
- `X-RateLimit-Reset`
|
||||
- `Retry-After` on `429`
|
||||
|
||||
If `429` is missing, it is auto-added as `Too Many Requests`.
|
||||
When `flash-ext-limiter` is installed, handlers with `@Limit` document `X-RateLimit-Limit`,
|
||||
`X-RateLimit-Remaining`, `X-RateLimit-Reset`, and a `429` with `Retry-After`.
|
||||
|
||||
## Notes
|
||||
|
||||
- Operations are collected from final boot-time routes for class-based handlers with `@ApiOperation`.
|
||||
- Documented paths always match runtime paths (including scope namespaces/prefixes/rewrites).
|
||||
- Route path params are auto-discovered from `/{id}`.
|
||||
- Parameter annotations are mainly for query/header/cookie enrichment.
|
||||
- Output responses are sorted by numeric status code.
|
||||
- Operations come from the final boot-time routes, so documented paths match runtime paths,
|
||||
namespaces, prefixes and rewrites included.
|
||||
- Lambda routes are not documented: there is no class to read.
|
||||
- Responses are sorted by status code; the document is rebuilt only when a route is added.
|
||||
|
||||
+8
-4
@@ -1,19 +1,23 @@
|
||||
package dev.relism.flash.ext.openapi;
|
||||
|
||||
import dev.relism.flash.http.ContentType;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
/**
|
||||
* OpenAPI response content descriptor.
|
||||
* What a response carries: the schema, and an example of it.
|
||||
*
|
||||
* <p>The media type is not here — it is the handler's, declared with
|
||||
* {@link dev.relism.flash.routing.Produces @Produces} and JSON when nothing says otherwise. A
|
||||
* response with no schema, and no return type to infer one from, is documented without a body.
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target({ElementType.TYPE, ElementType.FIELD, ElementType.METHOD})
|
||||
public @interface Content {
|
||||
ContentType contentType() default ContentType.JSON;
|
||||
Class<?> schema() default Void.class;
|
||||
boolean array() default false;
|
||||
|
||||
/** One example body, shown beside the schema. */
|
||||
String example() default "";
|
||||
}
|
||||
|
||||
+317
-426
@@ -1,47 +1,51 @@
|
||||
package dev.relism.flash.ext.openapi;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty.Access;
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.http.HttpStatus;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.Response;
|
||||
import dev.relism.flash.models.BodyHandler;
|
||||
import dev.relism.flash.routing.Consumes;
|
||||
import dev.relism.flash.routing.Produces;
|
||||
import dev.relism.flash.routing.Route;
|
||||
|
||||
import java.lang.annotation.Annotation;
|
||||
import java.lang.reflect.Array;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.GenericArrayType;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Modifier;
|
||||
import java.lang.reflect.ParameterizedType;
|
||||
import java.lang.reflect.Type;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* OpenAPI document assembler.
|
||||
* Assembles the OpenAPI document from what the handlers already say about themselves.
|
||||
*
|
||||
* <p>A route is documented whether or not it carries annotations: its path parameters come from
|
||||
* the path, its request body from the handler's own body type, its response schema from what
|
||||
* {@code handle} returns, and its error bodies from the one shape Flash answers failures with.
|
||||
* Annotations add what code cannot say — a summary, an example, a second status — and never have
|
||||
* to repeat what it can.
|
||||
*
|
||||
* <p>A response that several operations share is written once under {@code components} and
|
||||
* referenced, so the security and rate-limiting answers appear once rather than on every path.
|
||||
*/
|
||||
public final class OpenApiBuilder {
|
||||
|
||||
private static final String OPENAPI_VERSION = "3.0.3";
|
||||
private static final String ERROR_SCHEMA = "Error";
|
||||
private static final String ERROR_REF = "#/components/schemas/" + ERROR_SCHEMA;
|
||||
private static final String RESPONSE_REF = "#/components/responses/";
|
||||
|
||||
/** What {@code AbstractRouter} answers every failure with: one object, everywhere. */
|
||||
private static final Map<String, Object> ERROR_SHAPE = Map.of(
|
||||
"type", "object",
|
||||
"properties", Map.of("error", Map.of("type", "string"), "status", Map.of("type", "integer")),
|
||||
"required", List.of("error", "status"));
|
||||
|
||||
private String title = "API";
|
||||
private String version = "1.0.0";
|
||||
@@ -49,8 +53,9 @@ public final class OpenApiBuilder {
|
||||
|
||||
private final Map<String, Map<String, Object>> paths = new LinkedHashMap<>();
|
||||
private final Map<String, Map<String, Class<?>>> operationHandlers = new LinkedHashMap<>();
|
||||
private final SchemaRegistry schemas = new SchemaRegistry();
|
||||
private final Schemas schemas = new Schemas();
|
||||
private OpenApiContributorRegistry contributorRegistry;
|
||||
private boolean errorsDocumented;
|
||||
private int revision;
|
||||
private int builtRevision = -1;
|
||||
private Map<String, Object> cachedSpec;
|
||||
@@ -60,273 +65,384 @@ public final class OpenApiBuilder {
|
||||
public OpenApiBuilder description(String description) { this.description = description; return this; }
|
||||
void setContributorRegistry(OpenApiContributorRegistry registry) { this.contributorRegistry = registry; }
|
||||
|
||||
/**
|
||||
* Documents one route. {@code op} is optional: a route without it is still an operation.
|
||||
* A handler marked {@link Undocumented} is left out entirely.
|
||||
*/
|
||||
public void addOperation(Route route, ApiOperation op, Class<?> handlerClass) {
|
||||
if (handlerClass.isAnnotationPresent(Undocumented.class)) return;
|
||||
|
||||
String path = normalizePath(route.path());
|
||||
String method = route.method().name().toLowerCase(Locale.ROOT);
|
||||
|
||||
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);
|
||||
if (op != null) {
|
||||
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", List.of(op.tags()));
|
||||
if (op.deprecated()) operation.put("deprecated", true);
|
||||
}
|
||||
|
||||
buildParameters(operation, handlerClass, route);
|
||||
buildResponses(operation, handlerClass);
|
||||
parameters(operation, handlerClass, route);
|
||||
requestBody(operation, handlerClass);
|
||||
responses(operation, handlerClass);
|
||||
|
||||
paths.computeIfAbsent(path, k -> new LinkedHashMap<>()).put(method, operation);
|
||||
operationHandlers.computeIfAbsent(path, k -> new LinkedHashMap<>()).put(method, handlerClass);
|
||||
paths.computeIfAbsent(path, p -> new LinkedHashMap<>()).put(method, operation);
|
||||
operationHandlers.computeIfAbsent(path, p -> new LinkedHashMap<>()).put(method, handlerClass);
|
||||
revision++;
|
||||
}
|
||||
|
||||
public Map<String, Object> build() {
|
||||
int r = revision;
|
||||
int current = revision;
|
||||
Map<String, Object> cached = cachedSpec;
|
||||
if (cached != null && builtRevision == r) return cached;
|
||||
if (cached != null && builtRevision == current) return cached;
|
||||
|
||||
List<OpenApiContributor> contributors = contributors();
|
||||
Map<String, Object> renderedPaths = new LinkedHashMap<>();
|
||||
for (var path : paths.entrySet()) {
|
||||
Map<String, Class<?>> handlers = operationHandlers.getOrDefault(path.getKey(), Map.of());
|
||||
Map<String, Object> pathItem = new LinkedHashMap<>();
|
||||
for (var method : path.getValue().entrySet()) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> declared = (Map<String, Object>) method.getValue();
|
||||
Map<String, Object> operation = new LinkedHashMap<>(declared);
|
||||
Class<?> handler = handlers.get(method.getKey());
|
||||
if (handler != null && !contributors.isEmpty()) applyContributorSecurity(operation, handler, contributors);
|
||||
pathItem.put(method.getKey(), operation);
|
||||
}
|
||||
renderedPaths.put(path.getKey(), pathItem);
|
||||
}
|
||||
|
||||
Map<String, Object> sharedResponses = hoistSharedResponses(renderedPaths);
|
||||
|
||||
Map<String, Object> info = new LinkedHashMap<>();
|
||||
info.put("title", title);
|
||||
info.put("version", version);
|
||||
if (!description.isEmpty()) info.put("description", description);
|
||||
|
||||
List<OpenApiContributor> contributors = contributorRegistry != null
|
||||
? contributorRegistry.contributors() : List.of();
|
||||
|
||||
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);
|
||||
Class<?> handler = handlers.get(methodEntry.getKey());
|
||||
if (handler != null && !contributors.isEmpty()) {
|
||||
applyContributorOperation(op, handler, contributors);
|
||||
}
|
||||
renderedPathItem.put(methodEntry.getKey(), op);
|
||||
}
|
||||
renderedPaths.put(pathEntry.getKey(), renderedPathItem);
|
||||
}
|
||||
|
||||
Map<String, Object> spec = new LinkedHashMap<>();
|
||||
spec.put("openapi", OPENAPI_VERSION);
|
||||
spec.put("info", info);
|
||||
spec.put("paths", renderedPaths);
|
||||
|
||||
Map<String, Object> components = new LinkedHashMap<>();
|
||||
Map<String, Object> renderedSchemas = schemas.render();
|
||||
Map<String, Object> renderedSchemas = new LinkedHashMap<>(schemas.render());
|
||||
if (errorsDocumented) renderedSchemas.put(ERROR_SCHEMA, ERROR_SHAPE);
|
||||
if (!renderedSchemas.isEmpty()) components.put("schemas", renderedSchemas);
|
||||
if (!contributors.isEmpty()) applyContributorComponents(components, contributors);
|
||||
if (!sharedResponses.isEmpty()) components.put("responses", sharedResponses);
|
||||
for (OpenApiContributor contributor : contributors) {
|
||||
Map<String, Object> contributed = contributor.componentContributions();
|
||||
if (contributed != null && !contributed.isEmpty()) deepMergeLastWins(components, contributed);
|
||||
}
|
||||
if (!components.isEmpty()) spec.put("components", components);
|
||||
|
||||
cachedSpec = spec;
|
||||
builtRevision = r;
|
||||
builtRevision = current;
|
||||
return spec;
|
||||
}
|
||||
|
||||
private void buildParameters(Map<String, Object> op, Class<?> cls, Route route) {
|
||||
List<Map<String, Object>> params = new ArrayList<>();
|
||||
// ── Parameters ────────────────────────────────────────────────────────────
|
||||
|
||||
private void parameters(Map<String, Object> operation, Class<?> handlerClass, Route route) {
|
||||
List<Map<String, Object>> parameters = new ArrayList<>();
|
||||
|
||||
String path = route.path();
|
||||
int i = 0;
|
||||
while (i < path.length()) {
|
||||
int open = path.indexOf('{', i);
|
||||
if (open < 0) break;
|
||||
for (int open = path.indexOf('{'); open >= 0; open = path.indexOf('{', open + 1)) {
|
||||
int close = path.indexOf('}', open);
|
||||
if (close < 0) break;
|
||||
String name = path.substring(open + 1, close);
|
||||
params.add(new LinkedHashMap<>(Map.of(
|
||||
"name", name,
|
||||
parameters.add(new LinkedHashMap<>(Map.of(
|
||||
"name", path.substring(open + 1, close),
|
||||
"in", "path",
|
||||
"required", true,
|
||||
"schema", Map.of("type", "string")
|
||||
)));
|
||||
i = close + 1;
|
||||
"schema", Map.of("type", "string"))));
|
||||
open = close;
|
||||
}
|
||||
|
||||
for (Parameter ann : cls.getAnnotationsByType(Parameter.class)) {
|
||||
Map<String, Object> p = new LinkedHashMap<>();
|
||||
p.put("name", ann.name());
|
||||
p.put("in", ann.in().wireValue());
|
||||
p.put("required", ann.required());
|
||||
if (!ann.description().isEmpty()) p.put("description", ann.description());
|
||||
if (!ann.style().isEmpty()) p.put("style", ann.style());
|
||||
if (ann.explode()) p.put("explode", true);
|
||||
if (ann.allowEmptyValue()) p.put("allowEmptyValue", true);
|
||||
for (Parameter declared : handlerClass.getAnnotationsByType(Parameter.class)) {
|
||||
Map<String, Object> parameter = new LinkedHashMap<>();
|
||||
parameter.put("name", declared.name());
|
||||
parameter.put("in", declared.in().wireValue());
|
||||
parameter.put("required", declared.required());
|
||||
if (!declared.description().isEmpty()) parameter.put("description", declared.description());
|
||||
if (!declared.style().isEmpty()) parameter.put("style", declared.style());
|
||||
if (declared.explode()) parameter.put("explode", true);
|
||||
if (declared.allowEmptyValue()) parameter.put("allowEmptyValue", true);
|
||||
|
||||
Map<String, Object> schema = new LinkedHashMap<>();
|
||||
schema.put("type", ann.type().wireValue());
|
||||
if (!ann.example().isEmpty()) schema.put("example", ann.example());
|
||||
p.put("schema", schema);
|
||||
if (ann.examples().length > 0) p.put("examples", toExamples(ann.examples()));
|
||||
params.add(p);
|
||||
schema.put("type", declared.type().wireValue());
|
||||
if (!declared.example().isEmpty()) schema.put("example", declared.example());
|
||||
parameter.put("schema", schema);
|
||||
if (declared.examples().length > 0) parameter.put("examples", examples(declared.examples()));
|
||||
parameters.add(parameter);
|
||||
}
|
||||
|
||||
if (!params.isEmpty()) op.put("parameters", params);
|
||||
if (!parameters.isEmpty()) operation.put("parameters", parameters);
|
||||
}
|
||||
|
||||
private void buildResponses(Map<String, Object> op, Class<?> cls) {
|
||||
APIResponse[] anns = cls.getAnnotationsByType(APIResponse.class);
|
||||
Map<Integer, Map<String, Object>> responseByCode = new LinkedHashMap<>();
|
||||
private static Map<String, Object> examples(String[] values) {
|
||||
Map<String, Object> examples = new LinkedHashMap<>();
|
||||
for (int i = 0; i < values.length; i++) examples.put("example" + (i + 1), Map.of("value", values[i]));
|
||||
return examples;
|
||||
}
|
||||
|
||||
for (APIResponse ann : anns) {
|
||||
int code = parseStatus(ann.responseCode());
|
||||
responseByCode.put(code, buildAnnotatedResponse(code, ann, cls));
|
||||
// ── Request body ──────────────────────────────────────────────────────────
|
||||
|
||||
/** From {@link RequestBody}, or from the body type the handler declares in its own signature. */
|
||||
private void requestBody(Map<String, Object> operation, Class<?> handlerClass) {
|
||||
RequestBody declared = handlerClass.getAnnotation(RequestBody.class);
|
||||
Class<?> type = declared != null ? declared.value() : BodyHandler.bodyTypeOf(handlerClass);
|
||||
if (type == null || type == Void.class || type == Object.class) return;
|
||||
|
||||
Consumes consumes = handlerClass.getAnnotation(Consumes.class);
|
||||
ContentType contentType = declared != null ? declared.contentType()
|
||||
: consumes != null ? consumes.value() : ContentType.JSON;
|
||||
|
||||
Map<String, Object> schema = schemas.referenceFor(type);
|
||||
if (declared != null && declared.array()) schema = arrayOf(schema);
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
if (declared != null && !declared.description().isEmpty()) body.put("description", declared.description());
|
||||
body.put("required", declared == null || declared.required());
|
||||
body.put("content", Map.of(mediaTypeOf(contentType), Map.of("schema", schema)));
|
||||
operation.put("requestBody", body);
|
||||
}
|
||||
|
||||
// ── Responses ─────────────────────────────────────────────────────────────
|
||||
|
||||
private void responses(Map<String, Object> operation, Class<?> handlerClass) {
|
||||
APIResponse[] declared = handlerClass.getAnnotationsByType(APIResponse.class);
|
||||
Map<Integer, Map<String, Object>> byStatus = new LinkedHashMap<>();
|
||||
Set<Integer> declaredStatuses = new HashSet<>();
|
||||
|
||||
for (APIResponse response : declared) {
|
||||
int status = parseStatus(response.responseCode());
|
||||
declaredStatuses.add(status);
|
||||
byStatus.put(status, response(status, response, handlerClass));
|
||||
}
|
||||
if (byStatus.isEmpty()) byStatus.put(200, inferredResponse(handlerClass));
|
||||
|
||||
if (responseByCode.isEmpty()) {
|
||||
responseByCode.put(200, Map.of("description", "OK"));
|
||||
}
|
||||
|
||||
applyContributorResponses(responseByCode, cls);
|
||||
applyContributorResponses(byStatus, handlerClass, declaredStatuses);
|
||||
|
||||
Map<String, Object> responses = new LinkedHashMap<>();
|
||||
responseByCode.entrySet().stream()
|
||||
byStatus.entrySet().stream()
|
||||
.sorted(Map.Entry.comparingByKey(Comparator.naturalOrder()))
|
||||
.forEach(e -> responses.put(String.valueOf(e.getKey()), e.getValue()));
|
||||
op.put("responses", responses);
|
||||
.forEach(entry -> responses.put(String.valueOf(entry.getKey()), entry.getValue()));
|
||||
operation.put("responses", responses);
|
||||
}
|
||||
|
||||
private Map<String, Object> response(int status, APIResponse declared, Class<?> handlerClass) {
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("description", declared.description().isEmpty() ? reasonFor(status) : declared.description());
|
||||
|
||||
Content content = declared.content();
|
||||
Map<String, Object> schema = schemaFor(content, status, handlerClass);
|
||||
if (schema == null) return response; // nothing to describe: a response with no body
|
||||
|
||||
Map<String, Object> media = new LinkedHashMap<>();
|
||||
media.put("schema", schema);
|
||||
if (!content.example().isEmpty()) media.put("example", content.example());
|
||||
response.put("content", Map.of(mediaTypeOf(producedBy(handlerClass)), media));
|
||||
return response;
|
||||
}
|
||||
|
||||
/** Explicit first, then what the handler returns for a success, and the error shape for a failure. */
|
||||
private Map<String, Object> schemaFor(Content content, int status, Class<?> handlerClass) {
|
||||
if (content.schema() != Void.class) {
|
||||
Map<String, Object> schema = schemas.referenceFor(content.schema());
|
||||
return content.array() ? arrayOf(schema) : schema;
|
||||
}
|
||||
if (status >= 400) return errorSchema();
|
||||
|
||||
Map<String, Object> inferred = returnSchema(handlerClass);
|
||||
if (inferred == null) return null;
|
||||
return content.array() && !"array".equals(inferred.get("type")) ? arrayOf(inferred) : inferred;
|
||||
}
|
||||
|
||||
/** A route that documents nothing still answers something: describe what it returns. */
|
||||
private Map<String, Object> inferredResponse(Class<?> handlerClass) {
|
||||
Map<String, Object> response = new LinkedHashMap<>(Map.of("description", reasonFor(200)));
|
||||
Map<String, Object> schema = returnSchema(handlerClass);
|
||||
if (schema != null) {
|
||||
response.put("content", Map.of(mediaTypeOf(producedBy(handlerClass)), Map.of("schema", schema)));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* What this handler answers in. {@code @Consumes} says what it reads, which is a different
|
||||
* question: taking a JSON body does not make the answer JSON.
|
||||
*/
|
||||
private static ContentType producedBy(Class<?> handlerClass) {
|
||||
Produces produces = handlerClass.getAnnotation(Produces.class);
|
||||
return produces == null ? ContentType.JSON : produces.value();
|
||||
}
|
||||
|
||||
/** The schema of whatever {@code handle} gives back, or null when it says nothing useful. */
|
||||
private Map<String, Object> returnSchema(Class<?> handlerClass) {
|
||||
Type returned = returnTypeOf(handlerClass);
|
||||
Class<?> raw = Schemas.rawType(returned);
|
||||
if (raw == null || raw == Object.class || raw == Void.class || raw == void.class) return null;
|
||||
if (raw.getName().equals("dev.relism.flash.models.Response")) return null;
|
||||
|
||||
Map<String, Object> schema = schemas.schemaForType(returned);
|
||||
return schema == null || schema.isEmpty() ? null : schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* The {@code handle} a handler writes itself, not the one its base class fixes: a handler that
|
||||
* takes a body implements the three-argument one, and that is where its return type is.
|
||||
*/
|
||||
private static Type returnTypeOf(Class<?> handlerClass) {
|
||||
for (Class<?> current = handlerClass; current != null && current != Object.class; current = current.getSuperclass()) {
|
||||
Method found = null;
|
||||
for (Method method : current.getDeclaredMethods()) {
|
||||
if (!method.getName().equals("handle") || method.isBridge() || method.isSynthetic()) continue;
|
||||
if (found == null || method.getParameterCount() > found.getParameterCount()) found = method;
|
||||
}
|
||||
if (found != null) return found.getGenericReturnType();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Map<String, Object> errorSchema() {
|
||||
errorsDocumented = true;
|
||||
return Map.of("$ref", ERROR_REF);
|
||||
}
|
||||
|
||||
// ── Contributors ──────────────────────────────────────────────────────────
|
||||
|
||||
private List<OpenApiContributor> contributors() {
|
||||
return contributorRegistry != null ? contributorRegistry.contributors() : List.of();
|
||||
}
|
||||
|
||||
private void applyContributorResponses(Map<Integer, Map<String, Object>> responseByCode, Class<?> handlerClass) {
|
||||
APIResponse[] manual = handlerClass.getAnnotationsByType(APIResponse.class);
|
||||
Set<Integer> manualStatusCodes = new HashSet<>();
|
||||
for (APIResponse ann : manual) {
|
||||
manualStatusCodes.add(parseStatus(ann.responseCode()));
|
||||
}
|
||||
|
||||
private void applyContributorResponses(Map<Integer, Map<String, Object>> byStatus, Class<?> handlerClass,
|
||||
Set<Integer> declaredStatuses) {
|
||||
for (OpenApiContributor contributor : contributors()) {
|
||||
OpenApiOperationContribution contribution = contributor.operationFor(handlerClass);
|
||||
if (contribution == null) continue;
|
||||
|
||||
for (Map.Entry<Integer, OpenApiResponseContribution> entry : contribution.responses().entrySet()) {
|
||||
int status = entry.getKey();
|
||||
OpenApiResponseContribution responseContribution = entry.getValue();
|
||||
if (responseContribution == null) continue;
|
||||
|
||||
Map<String, Object> response = responseByCode.computeIfAbsent(status, __ -> new LinkedHashMap<>());
|
||||
mergeContributorResponse(response, responseContribution, status, manualStatusCodes);
|
||||
for (var contributed : contribution.responses().entrySet()) {
|
||||
if (contributed.getValue() == null) continue;
|
||||
int status = contributed.getKey();
|
||||
Map<String, Object> response = byStatus.computeIfAbsent(status, s -> new LinkedHashMap<>());
|
||||
merge(response, contributed.getValue(), status, declaredStatuses.contains(status));
|
||||
}
|
||||
|
||||
OpenApiResponseContribution allResponses = contribution.allResponses();
|
||||
if (allResponses != null) {
|
||||
for (Map.Entry<Integer, Map<String, Object>> entry : responseByCode.entrySet()) {
|
||||
mergeContributorResponse(entry.getValue(), allResponses, entry.getKey(), manualStatusCodes);
|
||||
}
|
||||
OpenApiResponseContribution everywhere = contribution.allResponses();
|
||||
if (everywhere == null) continue;
|
||||
for (var response : byStatus.entrySet()) {
|
||||
merge(response.getValue(), everywhere, response.getKey(), declaredStatuses.contains(response.getKey()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void mergeContributorResponse(Map<String, Object> response,
|
||||
OpenApiResponseContribution contribution,
|
||||
int status,
|
||||
Set<Integer> manualStatusCodes) {
|
||||
String desc = contribution.description();
|
||||
if (!manualStatusCodes.contains(status) && desc != null && !desc.isBlank()) {
|
||||
response.put("description", desc);
|
||||
}
|
||||
private void merge(Map<String, Object> response, OpenApiResponseContribution contributed, int status, boolean declared) {
|
||||
String description = contributed.description();
|
||||
if (!declared && description != null && !description.isBlank()) response.put("description", description);
|
||||
|
||||
Map<String, Map<String, Object>> headerContributions = contribution.headers();
|
||||
if (!headerContributions.isEmpty()) {
|
||||
Map<String, Map<String, Object>> headers = contributed.headers();
|
||||
if (!headers.isEmpty()) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> headers = (Map<String, Object>) response.computeIfAbsent("headers", __ -> new LinkedHashMap<>());
|
||||
for (Map.Entry<String, Map<String, Object>> h : headerContributions.entrySet()) {
|
||||
headers.put(h.getKey(), new LinkedHashMap<>(h.getValue()));
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.containsKey("description")) {
|
||||
response.put("description", defaultDescription(status));
|
||||
Map<String, Object> target = (Map<String, Object>) response.computeIfAbsent("headers", h -> new LinkedHashMap<>());
|
||||
headers.forEach((name, header) -> target.put(name, new LinkedHashMap<>(header)));
|
||||
}
|
||||
if (!response.containsKey("description")) response.put("description", reasonFor(status));
|
||||
// A status a contributor added is a failure Flash answers in its own shape.
|
||||
if (status >= 400 && !response.containsKey("content")) {
|
||||
response.put("content", Map.of(mediaTypeOf(ContentType.JSON), Map.of("schema", errorSchema())));
|
||||
} // Flash answers a failure in JSON whatever the route produces
|
||||
}
|
||||
|
||||
private static void applyContributorComponents(Map<String, Object> components, List<OpenApiContributor> contributors) {
|
||||
private void applyContributorSecurity(Map<String, Object> operation, Class<?> handlerClass,
|
||||
List<OpenApiContributor> contributors) {
|
||||
for (OpenApiContributor contributor : contributors) {
|
||||
Map<String, Object> c = contributor.componentContributions();
|
||||
if (c == null || c.isEmpty()) continue;
|
||||
deepMergeLastWins(components, c);
|
||||
OpenApiOperationContribution contribution = contributor.operationFor(handlerClass);
|
||||
if (contribution == null || contribution.security().isEmpty()) continue;
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, List<String>>> security =
|
||||
(List<Map<String, List<String>>>) operation.computeIfAbsent("security", s -> new ArrayList<>());
|
||||
security.addAll(contribution.security());
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static void deepMergeLastWins(Map<String, Object> target, Map<String, Object> incoming) {
|
||||
for (Map.Entry<String, Object> e : incoming.entrySet()) {
|
||||
Object existing = target.get(e.getKey());
|
||||
Object value = e.getValue();
|
||||
if (existing instanceof Map<?, ?> em && value instanceof Map<?, ?> vm) {
|
||||
Map<String, Object> merged = new LinkedHashMap<>((Map<String, Object>) em);
|
||||
deepMergeLastWins(merged, (Map<String, Object>) vm);
|
||||
target.put(e.getKey(), merged);
|
||||
incoming.forEach((key, value) -> {
|
||||
Object existing = target.get(key);
|
||||
if (existing instanceof Map<?, ?> from && value instanceof Map<?, ?> to) {
|
||||
Map<String, Object> merged = new LinkedHashMap<>((Map<String, Object>) from);
|
||||
deepMergeLastWins(merged, (Map<String, Object>) to);
|
||||
target.put(key, merged);
|
||||
} else {
|
||||
target.put(e.getKey(), value);
|
||||
target.put(key, value);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void applyContributorOperation(Map<String, Object> op, Class<?> handlerClass, List<OpenApiContributor> contributors) {
|
||||
for (OpenApiContributor contributor : contributors) {
|
||||
OpenApiOperationContribution contribution = contributor.operationFor(handlerClass);
|
||||
if (contribution == null || contribution.isEmpty()) continue;
|
||||
// ── Shared responses ──────────────────────────────────────────────────────
|
||||
|
||||
if (!contribution.security().isEmpty()) {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, List<String>>> security = (List<Map<String, List<String>>>) op
|
||||
.computeIfAbsent("security", __ -> new ArrayList<>());
|
||||
security.addAll(contribution.security());
|
||||
/**
|
||||
* The answer a status is usually given is written once under {@code components.responses} and
|
||||
* referenced. Authentication and rate limiting say the same thing on every route they guard;
|
||||
* the document should say it once, under that status's own name. A route that answers the same
|
||||
* status differently keeps its own wording, inline, rather than pushing a second name into the
|
||||
* components.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> hoistSharedResponses(Map<String, Object> renderedPaths) {
|
||||
Map<String, Map<Object, Integer>> seen = new LinkedHashMap<>();
|
||||
for (Object pathItem : renderedPaths.values()) {
|
||||
for (Object operation : ((Map<String, Object>) pathItem).values()) {
|
||||
Map<String, Object> responses = (Map<String, Object>) ((Map<String, Object>) operation).get("responses");
|
||||
responses.forEach((status, response) ->
|
||||
seen.computeIfAbsent(status, s -> new LinkedHashMap<>()).merge(response, 1, Integer::sum));
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> shared = new LinkedHashMap<>();
|
||||
Map<String, Object> hoisted = new LinkedHashMap<>(); // status to the one body that is shared
|
||||
seen.forEach((status, bodies) -> {
|
||||
Map.Entry<Object, Integer> commonest = bodies.entrySet().stream()
|
||||
.max(Map.Entry.comparingByValue()).orElseThrow();
|
||||
if (commonest.getValue() < 2) return;
|
||||
String name = reasonFor(Integer.parseInt(status)).replace(" ", "");
|
||||
if (name.isEmpty() || shared.containsKey(name)) name = "Status" + status;
|
||||
shared.put(name, commonest.getKey());
|
||||
hoisted.put(status, name);
|
||||
});
|
||||
|
||||
for (Object pathItem : renderedPaths.values()) {
|
||||
for (Object operation : ((Map<String, Object>) pathItem).values()) {
|
||||
Map<String, Object> responses = (Map<String, Object>) ((Map<String, Object>) operation).get("responses");
|
||||
for (var response : responses.entrySet()) {
|
||||
String name = (String) hoisted.get(response.getKey());
|
||||
if (name != null && shared.get(name).equals(response.getValue())) {
|
||||
response.setValue(Map.of("$ref", RESPONSE_REF + name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return shared;
|
||||
}
|
||||
|
||||
private Map<String, Object> buildAnnotatedResponse(int code, APIResponse ann, Class<?> handlerClass) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("description", ann.description().isEmpty() ? defaultDescription(code) : ann.description());
|
||||
// ── Odds and ends ─────────────────────────────────────────────────────────
|
||||
|
||||
Content content = ann.content();
|
||||
if (content.contentType() == ContentType.NONE) return out;
|
||||
|
||||
Map<String, Object> schema = resolveResponseSchema(content, handlerClass);
|
||||
if (schema == null || schema.isEmpty()) return out;
|
||||
|
||||
out.put("content", Map.of(mediaTypeOf(content.contentType()), Map.of("schema", schema)));
|
||||
return out;
|
||||
private static Map<String, Object> arrayOf(Map<String, Object> items) {
|
||||
return Map.of("type", "array", "items", items);
|
||||
}
|
||||
|
||||
private static int parseStatus(String code) {
|
||||
try {
|
||||
return Integer.parseInt(code.trim());
|
||||
} catch (Exception e) {
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalStateException("Invalid APIResponse.responseCode: " + code);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> resolveResponseSchema(Content content, Class<?> handlerClass) {
|
||||
if (content.schema() != Void.class) {
|
||||
Map<String, Object> base = schemas.referenceFor(content.schema());
|
||||
return content.array() ? asArraySchema(base) : base;
|
||||
}
|
||||
|
||||
try {
|
||||
Method handle = handlerClass.getMethod("handle", Request.class, Response.class);
|
||||
Type ret = handle.getGenericReturnType();
|
||||
Class<?> raw = rawType(ret);
|
||||
if (raw == null || raw == Object.class || raw == Response.class || raw == Void.class || raw == void.class)
|
||||
return null;
|
||||
|
||||
Map<String, Object> inferred = schemas.schemaForType(ret);
|
||||
if (inferred == null || inferred.isEmpty()) return null;
|
||||
if (content.array() && !"array".equals(inferred.get("type"))) return asArraySchema(inferred);
|
||||
return inferred;
|
||||
} catch (NoSuchMethodException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static Map<String, Object> asArraySchema(Map<String, Object> itemSchema) {
|
||||
return Map.of("type", "array", "items", itemSchema);
|
||||
private static String reasonFor(int status) {
|
||||
String reason = HttpStatus.reasonForCode(status);
|
||||
return reason == null ? "" : reason;
|
||||
}
|
||||
|
||||
private static String mediaTypeOf(ContentType type) {
|
||||
@@ -334,245 +450,21 @@ public final class OpenApiBuilder {
|
||||
return bytes.length == 0 ? "application/octet-stream" : new String(bytes, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static Map<String, Object> toExamples(String[] examples) {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
for (int i = 0; i < examples.length; i++) {
|
||||
out.put("ex" + (i + 1), Map.of("value", examples[i]));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String normalizePath(String path) {
|
||||
String normalized = path.startsWith("/") ? path : "/" + path;
|
||||
while (normalized.startsWith("//")) {
|
||||
normalized = normalized.substring(1);
|
||||
}
|
||||
while (normalized.startsWith("//")) normalized = normalized.substring(1);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static String defaultDescription(int status) {
|
||||
String reason = HttpStatus.reasonForCode(status);
|
||||
return reason == null ? "" : reason;
|
||||
}
|
||||
|
||||
private static Class<?> rawType(Type type) {
|
||||
if (type instanceof Class<?> c) return c;
|
||||
if (type instanceof ParameterizedType p && p.getRawType() instanceof Class<?> c) return c;
|
||||
if (type instanceof GenericArrayType a) {
|
||||
Class<?> component = rawType(a.getGenericComponentType());
|
||||
return component == null ? null : Array.newInstance(component, 0).getClass();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Resolved once: jakarta.validation is an optional dependency of this module. */
|
||||
private static final boolean CONSTRAINTS_PRESENT = ConstraintHints.available();
|
||||
|
||||
private static final class SchemaRegistry {
|
||||
private static final Set<Class<?>> SIMPLE = Set.of(
|
||||
String.class, CharSequence.class,
|
||||
Boolean.class, Byte.class, Short.class, Integer.class, Long.class, Float.class, Double.class,
|
||||
boolean.class, byte.class, short.class, int.class, long.class, float.class, double.class,
|
||||
UUID.class, LocalDate.class, LocalDateTime.class, OffsetDateTime.class, Instant.class
|
||||
);
|
||||
|
||||
private final Map<Class<?>, String> names = new LinkedHashMap<>();
|
||||
private final Map<String, Map<String, Object>> docs = new LinkedHashMap<>();
|
||||
private final Set<Class<?>> resolving = new HashSet<>();
|
||||
|
||||
Map<String, Object> referenceFor(Class<?> type) {
|
||||
return schemaFor(type);
|
||||
}
|
||||
|
||||
Map<String, Object> schemaForType(Type type) {
|
||||
return schemaFor(type);
|
||||
}
|
||||
|
||||
Map<String, Object> render() {
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
for (var e : docs.entrySet()) out.put(e.getKey(), e.getValue());
|
||||
return out;
|
||||
}
|
||||
|
||||
private Map<String, Object> schemaFor(Type type) {
|
||||
if (type instanceof ParameterizedType p) {
|
||||
Class<?> raw = rawType(p);
|
||||
if (raw != null && Collection.class.isAssignableFrom(raw)) {
|
||||
Type item = p.getActualTypeArguments()[0];
|
||||
return Map.of("type", "array", "items", schemaFor(item));
|
||||
}
|
||||
if (raw != null && Map.class.isAssignableFrom(raw)) {
|
||||
Type value = p.getActualTypeArguments().length > 1 ? p.getActualTypeArguments()[1] : Object.class;
|
||||
return Map.of("type", "object", "additionalProperties", schemaFor(value));
|
||||
}
|
||||
if (raw != null) return schemaFor(raw);
|
||||
}
|
||||
|
||||
Class<?> cls = rawType(type);
|
||||
if (cls == null || cls == Object.class) return Map.of("type", "object");
|
||||
|
||||
if (cls.isArray()) return Map.of("type", "array", "items", schemaFor(cls.getComponentType()));
|
||||
if (Collection.class.isAssignableFrom(cls)) return Map.of("type", "array", "items", Map.of("type", "object"));
|
||||
if (Map.class.isAssignableFrom(cls)) return Map.of("type", "object", "additionalProperties", Map.of("type", "object"));
|
||||
|
||||
Map<String, Object> simple = simpleSchema(cls);
|
||||
if (simple != null) return simple;
|
||||
|
||||
return Map.of("$ref", "#/components/schemas/" + registerPojo(cls));
|
||||
}
|
||||
|
||||
private String registerPojo(Class<?> cls) {
|
||||
String existing = names.get(cls);
|
||||
if (existing != null) return existing;
|
||||
|
||||
String base = schemaName(cls);
|
||||
String name = base;
|
||||
int i = 2;
|
||||
while (docs.containsKey(name)) name = base + i++;
|
||||
names.put(cls, name);
|
||||
|
||||
if (resolving.contains(cls)) return name;
|
||||
|
||||
resolving.add(cls);
|
||||
docs.put(name, buildPojoSchema(cls));
|
||||
resolving.remove(cls);
|
||||
return name;
|
||||
}
|
||||
|
||||
private Map<String, Object> buildPojoSchema(Class<?> cls) {
|
||||
Schema typeSchema = cls.getAnnotation(Schema.class);
|
||||
JsonIgnoreProperties ignoredType = cls.getAnnotation(JsonIgnoreProperties.class);
|
||||
Set<String> ignored = ignoredType == null
|
||||
? Set.of()
|
||||
: new HashSet<>(Arrays.asList(ignoredType.value()));
|
||||
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("type", "object");
|
||||
if (typeSchema != null) applySchemaHints(out, typeSchema);
|
||||
|
||||
Map<String, Object> properties = new LinkedHashMap<>();
|
||||
List<String> required = new ArrayList<>();
|
||||
|
||||
for (Field f : cls.getDeclaredFields()) {
|
||||
int mod = f.getModifiers();
|
||||
if (Modifier.isStatic(mod) || Modifier.isTransient(mod)) continue;
|
||||
if (f.isAnnotationPresent(JsonIgnore.class)) continue;
|
||||
if (ignored.contains(f.getName())) continue;
|
||||
|
||||
String name = f.getName();
|
||||
JsonProperty jp = f.getAnnotation(JsonProperty.class);
|
||||
if (jp != null && !jp.value().isEmpty()) name = jp.value();
|
||||
|
||||
Schema ps = f.getAnnotation(Schema.class);
|
||||
SchemaProperty sp = f.getAnnotation(SchemaProperty.class);
|
||||
ArraySchema array = f.getAnnotation(ArraySchema.class);
|
||||
if ((ps != null && ps.hidden()) || (sp != null && sp.hidden())) continue;
|
||||
|
||||
if (sp != null && !sp.name().isEmpty()) name = sp.name();
|
||||
|
||||
Map<String, Object> property = new LinkedHashMap<>(schemaFor(f.getGenericType()));
|
||||
if (ps != null) applySchemaHints(property, ps);
|
||||
if (sp != null) applySchemaHints(property, sp);
|
||||
if (array != null) applyArrayHints(property, array);
|
||||
if (jp != null) {
|
||||
if (jp.access() == Access.READ_ONLY) property.put("readOnly", true);
|
||||
if (jp.access() == Access.WRITE_ONLY) property.put("writeOnly", true);
|
||||
}
|
||||
|
||||
// Constraints declared for flash-ext-validation also describe the contract, so
|
||||
// mirror them here rather than making callers restate every rule as @Schema.
|
||||
boolean constrainedRequired = CONSTRAINTS_PRESENT && ConstraintHints.apply(f, property);
|
||||
|
||||
properties.put(name, property);
|
||||
if (constrainedRequired
|
||||
|| (ps != null && ps.required()) || (sp != null && sp.required()) || (jp != null && jp.required()))
|
||||
required.add(name);
|
||||
}
|
||||
|
||||
if (!properties.isEmpty()) out.put("properties", properties);
|
||||
if (!required.isEmpty()) out.put("required", required);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static void applySchemaHints(Map<String, Object> target, Schema schema) {
|
||||
if (!schema.title().isEmpty()) target.put("title", schema.title());
|
||||
if (!schema.description().isEmpty()) target.put("description", schema.description());
|
||||
if (!schema.format().isEmpty()) target.put("format", schema.format());
|
||||
if (!schema.example().isEmpty()) target.put("example", schema.example());
|
||||
if (schema.enumeration().length > 0) target.put("enum", Arrays.asList(schema.enumeration()));
|
||||
if (schema.nullable()) target.put("nullable", true);
|
||||
if (schema.deprecated()) target.put("deprecated", true);
|
||||
}
|
||||
|
||||
private static void applySchemaHints(Map<String, Object> target, SchemaProperty schema) {
|
||||
if (!schema.title().isEmpty()) target.put("title", schema.title());
|
||||
if (!schema.description().isEmpty()) target.put("description", schema.description());
|
||||
if (!schema.format().isEmpty()) target.put("format", schema.format());
|
||||
if (!schema.example().isEmpty()) target.put("example", schema.example());
|
||||
if (schema.enumeration().length > 0) target.put("enum", Arrays.asList(schema.enumeration()));
|
||||
if (schema.nullable()) target.put("nullable", true);
|
||||
if (schema.deprecated()) target.put("deprecated", true);
|
||||
}
|
||||
|
||||
private Map<String, Object> withArrayType(Map<String, Object> property, ArraySchema array) {
|
||||
if ("array".equals(property.get("type"))) return property;
|
||||
Type itemType = array.itemClass() != Void.class ? array.itemClass() : Object.class;
|
||||
Map<String, Object> wrapped = new LinkedHashMap<>();
|
||||
wrapped.put("type", "array");
|
||||
wrapped.put("items", schemaFor(itemType));
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
private void applyArrayHints(Map<String, Object> property, ArraySchema array) {
|
||||
Map<String, Object> target = withArrayType(property, array);
|
||||
if (target != property) {
|
||||
property.clear();
|
||||
property.putAll(target);
|
||||
}
|
||||
if (array.uniqueItems()) property.put("uniqueItems", true);
|
||||
if (array.minItems() >= 0) property.put("minItems", array.minItems());
|
||||
if (array.maxItems() >= 0) property.put("maxItems", array.maxItems());
|
||||
}
|
||||
|
||||
private static String schemaName(Class<?> cls) {
|
||||
Schema schema = cls.getAnnotation(Schema.class);
|
||||
if (schema != null && !schema.name().isEmpty()) return schema.name();
|
||||
return cls.getSimpleName();
|
||||
}
|
||||
|
||||
private static Map<String, Object> simpleSchema(Class<?> cls) {
|
||||
if (!SIMPLE.contains(cls) && !cls.isEnum()) return null;
|
||||
|
||||
if (cls == String.class || CharSequence.class.isAssignableFrom(cls)) return Map.of("type", "string");
|
||||
if (cls == Boolean.class || cls == boolean.class) return Map.of("type", "boolean");
|
||||
if (cls == Integer.class || cls == int.class || cls == Long.class || cls == long.class ||
|
||||
cls == Short.class || cls == short.class || cls == Byte.class || cls == byte.class) {
|
||||
return Map.of("type", "integer");
|
||||
}
|
||||
if (cls == Float.class || cls == float.class || cls == Double.class || cls == double.class) {
|
||||
return Map.of("type", "number");
|
||||
}
|
||||
if (cls == UUID.class) return Map.of("type", "string", "format", "uuid");
|
||||
if (cls == LocalDate.class) return Map.of("type", "string", "format", "date");
|
||||
if (cls == LocalDateTime.class || cls == OffsetDateTime.class || cls == Instant.class)
|
||||
return Map.of("type", "string", "format", "date-time");
|
||||
if (cls.isEnum()) {
|
||||
Object[] constants = cls.getEnumConstants();
|
||||
List<String> values = new ArrayList<>(constants.length);
|
||||
for (Object c : constants) values.add(String.valueOf(c));
|
||||
return Map.of("type", "string", "enum", values);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The route a handler class declares, through {@code @Route} or any shorthand carrying it. */
|
||||
static Route routeOf(Class<?> cls) {
|
||||
Route direct = cls.getAnnotation(Route.class);
|
||||
if (direct != null) return direct;
|
||||
for (Annotation ann : cls.getAnnotations()) {
|
||||
Route meta = ann.annotationType().getAnnotation(Route.class);
|
||||
|
||||
for (Annotation annotation : cls.getAnnotations()) {
|
||||
Route meta = annotation.annotationType().getAnnotation(Route.class);
|
||||
if (meta == null) continue;
|
||||
String path = readPathValue(ann);
|
||||
String path = pathOf(annotation);
|
||||
if (path == null) continue;
|
||||
HttpMethod method = meta.method();
|
||||
return new Route() {
|
||||
@@ -584,11 +476,10 @@ public final class OpenApiBuilder {
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String readPathValue(Annotation ann) {
|
||||
private static String pathOf(Annotation annotation) {
|
||||
try {
|
||||
Object v = ann.annotationType().getMethod("value").invoke(ann);
|
||||
return v instanceof String s ? s : null;
|
||||
} catch (ReflectiveOperationException ignored) {
|
||||
return annotation.annotationType().getMethod("value").invoke(annotation) instanceof String path ? path : null;
|
||||
} catch (ReflectiveOperationException absent) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user