Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
829b9bf348 | ||
|
|
9d39e24ccb | ||
|
|
c5be6ac7b8 | ||
|
|
ea00182c7c |
Generated
+2
-2
@@ -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-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/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-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-auth-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-auth-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/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-openapi/src/main/resources" charset="UTF-8" />
|
||||||
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java" charset="UTF-8" />
|
<file url="file://$PROJECT_DIR$/flash-extensions/flash-ext-routeviewer/src/main/java" charset="UTF-8" />
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ a zero-allocation FSM router, bounded protocol state, and one shared request/res
|
|||||||
| `flash-testing` | JUnit 5 harness — boot an app on an ephemeral port, fake its services, assert on responses |
|
| `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` | Jackson JSON integration |
|
||||||
| `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI |
|
| `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-auth-core` | Authentication seam + role/scope authorization |
|
||||||
| `flash-extensions/flash-ext-mcp` | MCP (Model Context Protocol) server — Streamable HTTP, optional OAuth2 via flash-ext-oidc |
|
| `flash-extensions/flash-ext-auth-oidc` | OIDC Authorization Code + PKCE flow |
|
||||||
|
| `flash-extensions/flash-ext-mcp` | MCP (Model Context Protocol) server — Streamable HTTP, optional OAuth2 via flash-ext-auth-oidc |
|
||||||
| `flash-extensions/flash-ext-view-core` | Minimal shared SSR runtime primitives |
|
| `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-jte` | Opinionated jte SSR extension |
|
||||||
| `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension |
|
| `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension |
|
||||||
@@ -146,7 +147,8 @@ FlashApp.create(8080)
|
|||||||
See extension-specific READMEs for full details:
|
See extension-specific READMEs for full details:
|
||||||
- [`flash-ext-jackson`](flash-extensions/flash-ext-jackson/README.md)
|
- [`flash-ext-jackson`](flash-extensions/flash-ext-jackson/README.md)
|
||||||
- [`flash-ext-openapi`](flash-extensions/flash-ext-openapi/README.md)
|
- [`flash-ext-openapi`](flash-extensions/flash-ext-openapi/README.md)
|
||||||
- [`flash-ext-oidc`](flash-extensions/flash-ext-oidc/README.md)
|
- [`flash-ext-auth-core`](flash-extensions/flash-ext-auth-core/docs/README.md)
|
||||||
|
- [`flash-ext-auth-oidc`](flash-extensions/flash-ext-auth-oidc/docs/README.md)
|
||||||
- [`flash-ext-mcp`](flash-extensions/flash-ext-mcp/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-jte`](flash-extensions/flash-ext-view-jte/README.md)
|
||||||
- [`flash-ext-view-thymeleaf`](flash-extensions/flash-ext-view-thymeleaf/README.md)
|
- [`flash-ext-view-thymeleaf`](flash-extensions/flash-ext-view-thymeleaf/README.md)
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# flash-ext-auth-core
|
||||||
|
|
||||||
|
Authorization, and the plumbing that carries a caller's identity through a request. It does not
|
||||||
|
know how anyone signed in — that is a `CredentialSource`, and `flash-ext-auth-oidc` ships the
|
||||||
|
OpenID Connect one.
|
||||||
|
|
||||||
|
The split follows the same shape as `flash-ext-cache-core`/`-caffeine` and
|
||||||
|
`flash-ext-data-core`/`-hibernate`: the abstract half here, the implementations beside it.
|
||||||
|
|
||||||
|
## The model
|
||||||
|
|
||||||
|
```
|
||||||
|
request ──► CredentialSource.authenticate(req, res) ──► claims
|
||||||
|
│
|
||||||
|
ClaimsHolder.set (this module only)
|
||||||
|
│
|
||||||
|
AuthMiddleware matches roles / scopes
|
||||||
|
│
|
||||||
|
handler
|
||||||
|
```
|
||||||
|
|
||||||
|
| Type | What it is |
|
||||||
|
|---|---|
|
||||||
|
| `CredentialSource` | Turns what a request carries into claims, or rejects it. One per mechanism. |
|
||||||
|
| `AuthMiddleware` | Publishes the claims, enforces `@RolesAllowed`/`@ScopesAllowed`, clears up. |
|
||||||
|
| `ClaimsHolder` | The current request's claims. Read from anywhere; written only from here. |
|
||||||
|
| `Claims` | Typed view over a claims map — `sub()`, `email()`, `roles(path)`, `scopes()`. |
|
||||||
|
| `AuthPolicy` | What a handler's annotations compiled to, resolved once at boot. |
|
||||||
|
| `Session`, `SessionStore` | Server-side sessions for sources that keep them. |
|
||||||
|
|
||||||
|
Nothing outside this module can write `ClaimsHolder`. A source *returns* claims and the middleware
|
||||||
|
publishes them, so no code can put claims on a request that did not carry them.
|
||||||
|
|
||||||
|
## Using it
|
||||||
|
|
||||||
|
You rarely install this module directly — an extension that contributes a source does it for you:
|
||||||
|
|
||||||
|
```java
|
||||||
|
// inside your extension's configure(...)
|
||||||
|
AuthMiddleware auth = AuthMiddleware.install(ctx, AuthConfig.builder()
|
||||||
|
.rolesClaimPath("realm_access.roles")
|
||||||
|
.scopeClaimPaths("scope,scp")
|
||||||
|
.build(), mySource);
|
||||||
|
```
|
||||||
|
|
||||||
|
`install` publishes the middleware in the context and registers the annotation processor, so every
|
||||||
|
scanned handler carrying an auth annotation is mounted behind it. See
|
||||||
|
[`credential-sources.md`](credential-sources.md) to write a source of your own.
|
||||||
|
|
||||||
|
On lambda routes, take the middleware out of the context:
|
||||||
|
|
||||||
|
```java
|
||||||
|
AuthMiddleware auth = app.ctx().require(AuthMiddleware.class);
|
||||||
|
|
||||||
|
app.get("/api/me", (req, res) -> ClaimsHolder.claim("sub"), auth.protect());
|
||||||
|
app.get("/", homeHandler, auth.optional());
|
||||||
|
app.delete("/admin/users/{id}", deleteHandler, auth.requireRole("admin"));
|
||||||
|
app.post("/orders", createOrder, auth.requireScopes("orders:write"));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Annotations
|
||||||
|
|
||||||
|
On a scanned handler class, and mounted automatically:
|
||||||
|
|
||||||
|
| Annotation | Effect |
|
||||||
|
|---|---|
|
||||||
|
| `@Authenticated` | Any accepted credential. No role check. |
|
||||||
|
| `@Authenticated(optional = true)` | Never rejects; publishes claims when there are some. |
|
||||||
|
| `@RolesAllowed({"a","b"})` | Authenticated **and** holding at least one of the roles. |
|
||||||
|
| `@ScopesAllowed({"x","y"})` | Authenticated **and** holding all of the scopes. |
|
||||||
|
| `@ScopesAllowed(value = {...}, match = ANY)` | …at least one of them. |
|
||||||
|
|
||||||
|
`@Authenticated(optional = true)` cannot be combined with a role or scope requirement — asking for
|
||||||
|
a role on a route that admits anonymous callers is a contradiction, and it fails at boot rather
|
||||||
|
than at 3am.
|
||||||
|
|
||||||
|
## Where roles and scopes are read from
|
||||||
|
|
||||||
|
`AuthConfig` names the claim paths, because every provider spells them differently:
|
||||||
|
|
||||||
|
| | Default | Common alternatives |
|
||||||
|
|---|---|---|
|
||||||
|
| `rolesClaimPath` | `roles` | `realm_access.roles` (Keycloak), `groups` (Authelia) |
|
||||||
|
| `scopeClaimPaths` | `scope,scp` | plus e.g. `permissions.scopes` |
|
||||||
|
|
||||||
|
Paths are dot-separated and walk nested maps. Scope paths are a comma-separated list tried in
|
||||||
|
order, so a token that puts scopes in `scp` and a legacy one that uses `scope` both work.
|
||||||
|
|
||||||
|
Matching is deliberate about a distinction that bites otherwise:
|
||||||
|
|
||||||
|
- a **string** claim is split on spaces, tabs, newlines and commas — `"openid orders:read"` is two
|
||||||
|
scopes;
|
||||||
|
- a **list** claim is compared entry by entry, whole and trimmed — `["a b"]` is one role named
|
||||||
|
`a b`, not two.
|
||||||
|
|
||||||
|
Prefix matches never count: `administrator` does not satisfy `admin`.
|
||||||
|
|
||||||
|
## Ordering around authentication
|
||||||
|
|
||||||
|
`AuthMiddleware.POLICY` is the boot-time key the annotation-driven node mounts under. An extension
|
||||||
|
contributing its own middleware can order itself against it:
|
||||||
|
|
||||||
|
```java
|
||||||
|
MiddlewareNode.of(MY_KEY, myMiddleware).afterIfPresent(AuthMiddleware.POLICY);
|
||||||
|
```
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Writing a credential source
|
||||||
|
|
||||||
|
A `CredentialSource` is the only thing that stands between a request and its claims. Everything
|
||||||
|
else in this module — annotations, policy, matching, the holder — works the same regardless of
|
||||||
|
which one is installed.
|
||||||
|
|
||||||
|
```java
|
||||||
|
public interface CredentialSource {
|
||||||
|
Map<String, Object> authenticate(Request req, Response res);
|
||||||
|
Map<String, Object> peek(Request req);
|
||||||
|
default String insufficientScopeChallenge(String[] requiredScopes) { return null; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## `authenticate` has three outcomes, and the last two are not the same
|
||||||
|
|
||||||
|
| Return | Means | The middleware then |
|
||||||
|
|---|---|---|
|
||||||
|
| claims | a valid credential was presented | publishes them and calls the handler |
|
||||||
|
| `null` | **no** credential, and the source has already answered the request | stops, writes nothing more |
|
||||||
|
| throws `HttpException` | a credential **was** presented and is invalid | propagates it |
|
||||||
|
|
||||||
|
Flattening the last two is the single easiest way to get this wrong. "No session, send the browser
|
||||||
|
to the sign-in page" and "this token is forged" are different answers, and a caller can tell:
|
||||||
|
the first is a `302` to a login screen, the second a `401` the client must not retry blindly.
|
||||||
|
|
||||||
|
A source that returns `null` owns the response by then — it has redirected, or written a `401` with
|
||||||
|
its own `WWW-Authenticate` header. A source that throws sets any challenge header it owes *before*
|
||||||
|
throwing, because the exception unwinds past the middleware.
|
||||||
|
|
||||||
|
`peek` is the same resolution with every rejection removed: no throwing, no redirecting, `null`
|
||||||
|
when there is nothing valid. It backs `@Authenticated(optional = true)`, where an anonymous caller
|
||||||
|
is a normal outcome. Never make `peek` refresh state that `authenticate` would not have.
|
||||||
|
|
||||||
|
## A minimal source
|
||||||
|
|
||||||
|
```java
|
||||||
|
public final class ApiKeySource implements CredentialSource {
|
||||||
|
|
||||||
|
private final Map<String, Map<String, Object>> keys; // key -> claims
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> authenticate(Request req, Response res) {
|
||||||
|
String key = req.header("X-Api-Key");
|
||||||
|
if (key == null) {
|
||||||
|
res.header("WWW-Authenticate", "ApiKey realm=\"api\"");
|
||||||
|
throw HttpException.unauthorized();
|
||||||
|
}
|
||||||
|
Map<String, Object> claims = keys.get(key);
|
||||||
|
if (claims == null) throw HttpException.unauthorized(); // presented and wrong
|
||||||
|
return claims;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> peek(Request req) {
|
||||||
|
String key = req.header("X-Api-Key");
|
||||||
|
return key != null ? keys.get(key) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This one never returns `null` from `authenticate` — it has no sign-in flow to redirect into, so
|
||||||
|
"absent" and "invalid" both mean `401`. That is a legitimate shape; the three outcomes are what
|
||||||
|
the interface *allows*, not a checklist.
|
||||||
|
|
||||||
|
## Claims are yours to shape
|
||||||
|
|
||||||
|
The claims map is whatever your mechanism produces. `Claims` reads a few conventional keys —
|
||||||
|
`sub`, `email`, `name`, `preferred_username` — so populating those makes your source work with
|
||||||
|
code written against any other. Roles and scopes are read from wherever `AuthConfig` points, so
|
||||||
|
they can live under any key you like as long as the two agree.
|
||||||
|
|
||||||
|
## Installing it
|
||||||
|
|
||||||
|
```java
|
||||||
|
public final class ApiKeyExtension implements FlashExtension {
|
||||||
|
@Override
|
||||||
|
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||||
|
ctx.provide(ApiKeySource.class, source);
|
||||||
|
AuthMiddleware.install(ctx, AuthConfig.builder()
|
||||||
|
.rolesClaimPath("roles")
|
||||||
|
.build(), source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`AuthMiddleware.install` also registers the annotation processor, so scanned handlers carrying
|
||||||
|
`@Authenticated` and friends are mounted behind your source with nothing further to do.
|
||||||
|
|
||||||
|
## One source at a time
|
||||||
|
|
||||||
|
`AuthMiddleware` is published in the context under its own type, so installing two extensions that
|
||||||
|
each call `install` leaves the last one winning — quietly. If an app genuinely needs to accept two
|
||||||
|
kinds of credential, that is one source that tries both, not two sources: the order they are tried
|
||||||
|
in, and what happens when the first rejects, are decisions that have to live somewhere explicit.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Sessions
|
||||||
|
|
||||||
|
A `Session` is what a credential source keeps server-side between requests, looked up by a cookie.
|
||||||
|
Core owns the container; what goes in it is the source's business.
|
||||||
|
|
||||||
|
```java
|
||||||
|
public final class Session {
|
||||||
|
String id();
|
||||||
|
Map<String, Object> claims();
|
||||||
|
Instant expiresAt();
|
||||||
|
Map<String, Object> attributes();
|
||||||
|
boolean isExpired();
|
||||||
|
Object attribute(String key);
|
||||||
|
String attributeAsString(String key);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why it expires early
|
||||||
|
|
||||||
|
`isExpired()` returns true **30 seconds before** `expiresAt`. Without that window a session can
|
||||||
|
pass the check at the top of a request and be dead by the time the handler uses it — a class of
|
||||||
|
failure that reproduces once a day and never in a test. Renewal is therefore always slightly
|
||||||
|
premature, on purpose.
|
||||||
|
|
||||||
|
## Attributes
|
||||||
|
|
||||||
|
`attributes()` is opaque to this module. `flash-ext-auth-oidc` keeps its access, id and refresh
|
||||||
|
tokens there under its own keys, which is what lets renewal stay entirely inside that extension
|
||||||
|
while the session itself carries no OAuth2 vocabulary.
|
||||||
|
|
||||||
|
Store what your source needs to renew or revoke, and nothing a handler should be reading — handlers
|
||||||
|
read `claims()`.
|
||||||
|
|
||||||
|
## The store
|
||||||
|
|
||||||
|
```java
|
||||||
|
public interface SessionStore {
|
||||||
|
void save(Session session);
|
||||||
|
Optional<Session> find(String sessionId);
|
||||||
|
void delete(String sessionId);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`InMemorySessionStore` is the default: a `ConcurrentHashMap`, fine for a single instance, and it
|
||||||
|
loses every session on restart. Supply your own for Redis or JDBC when sessions have to survive a
|
||||||
|
deploy or be shared across nodes.
|
||||||
|
|
||||||
|
Sessions are immutable. Renewing one builds a new instance with the same `id()` and `save`s it
|
||||||
|
over the old — there is no mutate-in-place path, so a store can cache or serialise freely.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?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-auth-core</artifactId>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</project>
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
package dev.relism.flash.ext.auth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where authorization reads its inputs from. Deliberately small: everything about *obtaining* a
|
||||||
|
* credential belongs to the {@link CredentialSource} that produced it, and everything about
|
||||||
|
* *checking* one is right here.
|
||||||
|
*
|
||||||
|
* <p>Defaults are the generic spelling, not any one provider's. A source that knows better —
|
||||||
|
* {@code flash-ext-auth-oidc} defaults roles to Keycloak's {@code realm_access.roles} — builds
|
||||||
|
* its own {@code AuthConfig} with the paths its provider actually uses.
|
||||||
|
*/
|
||||||
|
public final class AuthConfig {
|
||||||
|
|
||||||
|
private final String rolesClaimPath;
|
||||||
|
private final String scopeClaimPaths;
|
||||||
|
|
||||||
|
private AuthConfig(Builder b) {
|
||||||
|
this.rolesClaimPath = b.rolesClaimPath;
|
||||||
|
this.scopeClaimPaths = b.scopeClaimPaths;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dot-separated path to the roles list in the claims (default: {@code roles}). */
|
||||||
|
public String rolesClaimPath() { return rolesClaimPath; }
|
||||||
|
|
||||||
|
/** Comma-separated claim paths scopes are read from, in order (default: {@code scope,scp}). */
|
||||||
|
public String scopeClaimPaths() { return scopeClaimPaths; }
|
||||||
|
|
||||||
|
public static Builder builder() { return new Builder(); }
|
||||||
|
|
||||||
|
public static final class Builder {
|
||||||
|
private String rolesClaimPath = "roles";
|
||||||
|
private String scopeClaimPaths = "scope,scp";
|
||||||
|
|
||||||
|
private Builder() {}
|
||||||
|
|
||||||
|
/** Dot-separated path to the roles list — e.g. {@code realm_access.roles}, {@code groups}. */
|
||||||
|
public Builder rolesClaimPath(String path) { this.rolesClaimPath = path; return this; }
|
||||||
|
/** Comma-separated claim paths scopes are read from, tried in order. */
|
||||||
|
public Builder scopeClaimPaths(String paths) { this.scopeClaimPaths = paths; return this; }
|
||||||
|
|
||||||
|
public AuthConfig build() { return new AuthConfig(this); }
|
||||||
|
}
|
||||||
|
}
|
||||||
+309
@@ -0,0 +1,309 @@
|
|||||||
|
package dev.relism.flash.ext.auth;
|
||||||
|
|
||||||
|
import dev.relism.flash.exceptions.HttpException;
|
||||||
|
import dev.relism.flash.extension.FlashContext;
|
||||||
|
import dev.relism.flash.models.Request;
|
||||||
|
import dev.relism.flash.models.Response;
|
||||||
|
import dev.relism.flash.routing.Middleware;
|
||||||
|
import dev.relism.flash.routing.MiddlewareKey;
|
||||||
|
import dev.relism.flash.routing.MiddlewareNode;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns claims into a yes or a no. Exposed in the {@link FlashContext} for manual use on lambda
|
||||||
|
* routes, and injected automatically for handlers annotated with {@link Authenticated},
|
||||||
|
* {@link RolesAllowed} or {@link ScopesAllowed}.
|
||||||
|
*
|
||||||
|
* <p>It knows nothing about how the caller proved who they are — that is the
|
||||||
|
* {@link CredentialSource} it is built with. What lives here is the half that is the same for
|
||||||
|
* every mechanism: publish the claims for the request, match roles and scopes against them, clear
|
||||||
|
* up afterwards.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* AuthMiddleware auth = app.ctx().require(AuthMiddleware.class);
|
||||||
|
* app.get("/api/me", (req, res) -> ClaimsHolder.claim("sub"), auth.protect());
|
||||||
|
* app.delete("/admin/users/{id}", handler, auth.requireRole("admin"));
|
||||||
|
* }</pre>
|
||||||
|
*/
|
||||||
|
public class AuthMiddleware {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Boot-time identity of the node annotation-driven authorization mounts under. Public so an
|
||||||
|
* extension that contributes its own middleware can order itself around authentication —
|
||||||
|
* {@code MiddlewareNode.of(...).afterIfPresent(AuthMiddleware.POLICY)}.
|
||||||
|
*/
|
||||||
|
public static final MiddlewareKey POLICY = MiddlewareKey.of("flash.auth.policy");
|
||||||
|
|
||||||
|
private final AuthConfig config;
|
||||||
|
private final CredentialSource source;
|
||||||
|
private final String[] roleClaimPathParts;
|
||||||
|
private final String[][] scopeClaimPathParts;
|
||||||
|
|
||||||
|
public AuthMiddleware(AuthConfig config, CredentialSource source) {
|
||||||
|
this.config = config;
|
||||||
|
this.source = source;
|
||||||
|
this.roleClaimPathParts = splitClaimPath(config.rolesClaimPath());
|
||||||
|
this.scopeClaimPathParts = splitClaimPaths(config.scopeClaimPaths());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the middleware for {@code source}, publishes it in the context and registers the
|
||||||
|
* annotation processor that mounts {@link Authenticated}, {@link RolesAllowed} and
|
||||||
|
* {@link ScopesAllowed} on scanned handlers.
|
||||||
|
*
|
||||||
|
* <p>Every extension that contributes a {@link CredentialSource} calls this rather than
|
||||||
|
* repeating the wiring — the processor and the {@link #POLICY} key belong to one place.
|
||||||
|
*/
|
||||||
|
public static AuthMiddleware install(FlashContext ctx, AuthConfig config, CredentialSource source) {
|
||||||
|
AuthMiddleware middleware = new AuthMiddleware(config, source);
|
||||||
|
ctx.provide(AuthMiddleware.class, middleware);
|
||||||
|
ctx.addAnnotationProcessor(handlerClass -> {
|
||||||
|
AuthPolicy policy = AuthPolicy.compileFromAnnotations(handlerClass);
|
||||||
|
return policy != null
|
||||||
|
? List.of(MiddlewareNode.of(POLICY, middleware.authorize(policy)))
|
||||||
|
: List.of();
|
||||||
|
});
|
||||||
|
return middleware;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Public API -----------------------------------------------------------
|
||||||
|
|
||||||
|
/** The single configured claim path used by every transport for role checks. */
|
||||||
|
public String rolesClaimPath() { return config.rolesClaimPath(); }
|
||||||
|
|
||||||
|
/** The source this middleware authenticates with. */
|
||||||
|
public CredentialSource source() { return source; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same authorization rules against a different credential source. Used where one route
|
||||||
|
* needs a variant of an installed source — {@code flash-ext-mcp} protects {@code /mcp} with an
|
||||||
|
* OIDC source whose challenges carry RFC 9728 resource metadata, while every other route keeps
|
||||||
|
* the plain one.
|
||||||
|
*/
|
||||||
|
public AuthMiddleware withSource(CredentialSource source) {
|
||||||
|
return new AuthMiddleware(config, source);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rejects the request unless the caller is authenticated. How it is rejected — a 401 with a
|
||||||
|
* challenge, a redirect into a sign-in flow — is the source's decision, not this one's.
|
||||||
|
*/
|
||||||
|
public Middleware protect() {
|
||||||
|
return next -> (req, res) -> {
|
||||||
|
Map<String, Object> claims = source.authenticate(req, res);
|
||||||
|
if (claims == null) return null; // the source already answered the request
|
||||||
|
ClaimsHolder.set(claims);
|
||||||
|
try {
|
||||||
|
return next.handle(req, res);
|
||||||
|
} finally {
|
||||||
|
ClaimsHolder.clear();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishes claims when the caller happens to be authenticated and never rejects anyone. Use
|
||||||
|
* it on public routes that personalise their response for signed-in callers.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* app.get("/", handler, auth.optional());
|
||||||
|
* // Inside handler: ClaimsHolder.current() is non-null iff the caller is signed in.
|
||||||
|
* }</pre>
|
||||||
|
*/
|
||||||
|
public Middleware optional() {
|
||||||
|
return next -> (req, res) -> {
|
||||||
|
Map<String, Object> claims = source.peek(req);
|
||||||
|
if (claims != null) ClaimsHolder.set(claims);
|
||||||
|
try {
|
||||||
|
return next.handle(req, res);
|
||||||
|
} finally {
|
||||||
|
ClaimsHolder.clear();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a policy compiled once at boot from a handler's annotations. This is the path
|
||||||
|
* annotation-driven mounting takes.
|
||||||
|
*/
|
||||||
|
public Middleware authorize(AuthPolicy policy) {
|
||||||
|
if (policy.optionalAuth()) return optional();
|
||||||
|
return next -> (req, res) -> {
|
||||||
|
Map<String, Object> claims = source.authenticate(req, res);
|
||||||
|
if (claims == null) return null;
|
||||||
|
enforcePolicy(claims, policy, res);
|
||||||
|
ClaimsHolder.set(claims);
|
||||||
|
try {
|
||||||
|
return next.handle(req, res);
|
||||||
|
} finally {
|
||||||
|
ClaimsHolder.clear();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@link #protect()} plus at least one of the given roles (OR semantics). */
|
||||||
|
public Middleware requireRole(String... roles) {
|
||||||
|
return authorize(AuthPolicy.rolesAny(roles));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@link #protect()} plus every one of the given scopes. */
|
||||||
|
public Middleware requireScopes(String... scopes) {
|
||||||
|
return authorize(AuthPolicy.scopes(scopes, ScopesAllowed.Match.ALL));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@link #protect()} plus at least one of the given scopes. */
|
||||||
|
public Middleware requireAnyScope(String... scopes) {
|
||||||
|
return authorize(AuthPolicy.scopes(scopes, ScopesAllowed.Match.ANY));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Policy enforcement ---------------------------------------------------
|
||||||
|
|
||||||
|
private void enforcePolicy(Map<String, Object> claims, AuthPolicy 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;
|
||||||
|
String challenge = source.insufficientScopeChallenge(required);
|
||||||
|
if (challenge != null) res.header("WWW-Authenticate", challenge);
|
||||||
|
throw HttpException.forbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Claim matching -------------------------------------------------------
|
||||||
|
|
||||||
|
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("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("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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-18
@@ -1,4 +1,4 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
package dev.relism.flash.ext.auth;
|
||||||
|
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -7,13 +7,13 @@ import java.util.List;
|
|||||||
* Compiled authorization policy derived from handler annotations at mount time.
|
* Compiled authorization policy derived from handler annotations at mount time.
|
||||||
* Immutable and allocation-free on the request hot path.
|
* Immutable and allocation-free on the request hot path.
|
||||||
*/
|
*/
|
||||||
final class OidcAuthPolicy {
|
public final class AuthPolicy {
|
||||||
|
|
||||||
private static final String[] EMPTY = new String[0];
|
private static final String[] EMPTY = new String[0];
|
||||||
|
|
||||||
private static final OidcAuthPolicy AUTH_REQUIRED = new OidcAuthPolicy(
|
private static final AuthPolicy AUTH_REQUIRED = new AuthPolicy(
|
||||||
false, EMPTY, EMPTY, ScopesAllowed.Match.ALL);
|
false, EMPTY, EMPTY, ScopesAllowed.Match.ALL);
|
||||||
private static final OidcAuthPolicy AUTH_OPTIONAL = new OidcAuthPolicy(
|
private static final AuthPolicy AUTH_OPTIONAL = new AuthPolicy(
|
||||||
true, EMPTY, EMPTY, ScopesAllowed.Match.ALL);
|
true, EMPTY, EMPTY, ScopesAllowed.Match.ALL);
|
||||||
|
|
||||||
private final boolean optionalAuth;
|
private final boolean optionalAuth;
|
||||||
@@ -21,7 +21,7 @@ final class OidcAuthPolicy {
|
|||||||
private final String[] requiredScopes;
|
private final String[] requiredScopes;
|
||||||
private final ScopesAllowed.Match scopeMatch;
|
private final ScopesAllowed.Match scopeMatch;
|
||||||
|
|
||||||
private OidcAuthPolicy(boolean optionalAuth,
|
private AuthPolicy(boolean optionalAuth,
|
||||||
String[] requiredRoles,
|
String[] requiredRoles,
|
||||||
String[] requiredScopes,
|
String[] requiredScopes,
|
||||||
ScopesAllowed.Match scopeMatch) {
|
ScopesAllowed.Match scopeMatch) {
|
||||||
@@ -31,19 +31,19 @@ final class OidcAuthPolicy {
|
|||||||
this.scopeMatch = scopeMatch;
|
this.scopeMatch = scopeMatch;
|
||||||
}
|
}
|
||||||
|
|
||||||
static OidcAuthPolicy authenticated() { return AUTH_REQUIRED; }
|
public static AuthPolicy authenticated() { return AUTH_REQUIRED; }
|
||||||
|
|
||||||
static OidcAuthPolicy optional() { return AUTH_OPTIONAL; }
|
public static AuthPolicy optional() { return AUTH_OPTIONAL; }
|
||||||
|
|
||||||
static OidcAuthPolicy rolesAny(String... roles) {
|
public static AuthPolicy rolesAny(String... roles) {
|
||||||
return new OidcAuthPolicy(false, normalizeRequired("RolesAllowed", roles), EMPTY, ScopesAllowed.Match.ALL);
|
return new AuthPolicy(false, normalizeRequired("RolesAllowed", roles), EMPTY, ScopesAllowed.Match.ALL);
|
||||||
}
|
}
|
||||||
|
|
||||||
static OidcAuthPolicy scopes(String[] scopes, ScopesAllowed.Match match) {
|
public static AuthPolicy scopes(String[] scopes, ScopesAllowed.Match match) {
|
||||||
return new OidcAuthPolicy(false, EMPTY, normalizeRequired("ScopesAllowed", scopes), match);
|
return new AuthPolicy(false, EMPTY, normalizeRequired("ScopesAllowed", scopes), match);
|
||||||
}
|
}
|
||||||
|
|
||||||
static OidcAuthPolicy compileFromAnnotations(Class<?> handlerClass) {
|
public static AuthPolicy compileFromAnnotations(Class<?> handlerClass) {
|
||||||
Authenticated auth = handlerClass.getAnnotation(Authenticated.class);
|
Authenticated auth = handlerClass.getAnnotation(Authenticated.class);
|
||||||
RolesAllowed roles = handlerClass.getAnnotation(RolesAllowed.class);
|
RolesAllowed roles = handlerClass.getAnnotation(RolesAllowed.class);
|
||||||
ScopesAllowed scopes = handlerClass.getAnnotation(ScopesAllowed.class);
|
ScopesAllowed scopes = handlerClass.getAnnotation(ScopesAllowed.class);
|
||||||
@@ -60,10 +60,10 @@ final class OidcAuthPolicy {
|
|||||||
+ handlerClass.getName());
|
+ handlerClass.getName());
|
||||||
}
|
}
|
||||||
|
|
||||||
return new OidcAuthPolicy(optionalAuth, requiredRoles, requiredScopes, scopeMatch);
|
return new AuthPolicy(optionalAuth, requiredRoles, requiredScopes, scopeMatch);
|
||||||
}
|
}
|
||||||
|
|
||||||
static List<String> openApiScopesFor(Class<?> handlerClass) {
|
public static List<String> openApiScopesFor(Class<?> handlerClass) {
|
||||||
Authenticated auth = handlerClass.getAnnotation(Authenticated.class);
|
Authenticated auth = handlerClass.getAnnotation(Authenticated.class);
|
||||||
RolesAllowed roles = handlerClass.getAnnotation(RolesAllowed.class);
|
RolesAllowed roles = handlerClass.getAnnotation(RolesAllowed.class);
|
||||||
ScopesAllowed scopes = handlerClass.getAnnotation(ScopesAllowed.class);
|
ScopesAllowed scopes = handlerClass.getAnnotation(ScopesAllowed.class);
|
||||||
@@ -72,13 +72,13 @@ final class OidcAuthPolicy {
|
|||||||
return List.of(normalizeRequired("ScopesAllowed", scopes.value()));
|
return List.of(normalizeRequired("ScopesAllowed", scopes.value()));
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean optionalAuth() { return optionalAuth; }
|
public boolean optionalAuth() { return optionalAuth; }
|
||||||
|
|
||||||
String[] requiredRoles() { return requiredRoles; }
|
public String[] requiredRoles() { return requiredRoles; }
|
||||||
|
|
||||||
String[] requiredScopes() { return requiredScopes; }
|
public String[] requiredScopes() { return requiredScopes; }
|
||||||
|
|
||||||
ScopesAllowed.Match scopeMatch() { return scopeMatch; }
|
public ScopesAllowed.Match scopeMatch() { return scopeMatch; }
|
||||||
|
|
||||||
private static String[] normalizeRequired(String annotation, String[] values) {
|
private static String[] normalizeRequired(String annotation, String[] values) {
|
||||||
if (values == null || values.length == 0)
|
if (values == null || values.length == 0)
|
||||||
+9
-9
@@ -1,4 +1,4 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
package dev.relism.flash.ext.auth;
|
||||||
|
|
||||||
import java.lang.annotation.ElementType;
|
import java.lang.annotation.ElementType;
|
||||||
import java.lang.annotation.Retention;
|
import java.lang.annotation.Retention;
|
||||||
@@ -6,23 +6,23 @@ import java.lang.annotation.RetentionPolicy;
|
|||||||
import java.lang.annotation.Target;
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Marks a handler as requiring a valid JWT. Any bearer token that passes
|
* Marks a handler as requiring an authenticated caller. Any credential a registered source
|
||||||
* signature + expiry + issuer validation is accepted — no role check is performed.
|
* accepts is enough — no role or scope check is performed.
|
||||||
*
|
*
|
||||||
* <p>For role-based access use {@link RolesAllowed} instead (it implies authentication).
|
* <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
|
* <p>Set {@code optional = true} on public routes that personalise their response when the caller
|
||||||
* the user happens to be logged in but should remain accessible to guests. The middleware
|
* happens to be signed in but should remain reachable by guests. The middleware populates
|
||||||
* will populate {@link ClaimsHolder} if credentials are present and silently skip it
|
* {@link ClaimsHolder} when a credential is present and silently skips it otherwise — the request
|
||||||
* otherwise — the request is never rejected.
|
* is never rejected.
|
||||||
*
|
*
|
||||||
* <pre>{@code
|
* <pre>{@code
|
||||||
* // Hard auth — redirects / 401 when unauthenticated:
|
* // Hard auth — 401 or a redirect when unauthenticated:
|
||||||
* @Route(method = HttpMethod.GET, path = "/api/profile")
|
* @Route(method = HttpMethod.GET, path = "/api/profile")
|
||||||
* @Authenticated
|
* @Authenticated
|
||||||
* public class GetProfile extends JacksonHandler { ... }
|
* public class GetProfile extends JacksonHandler { ... }
|
||||||
*
|
*
|
||||||
* // Soft auth — guest-friendly, ClaimsHolder populated only when logged in:
|
* // Soft auth — guest-friendly, ClaimsHolder populated only when signed in:
|
||||||
* @Route(method = HttpMethod.GET, path = "/")
|
* @Route(method = HttpMethod.GET, path = "/")
|
||||||
* @Authenticated(optional = true)
|
* @Authenticated(optional = true)
|
||||||
* public class HomePage extends HtmlHandler { ... }
|
* public class HomePage extends HtmlHandler { ... }
|
||||||
+15
-22
@@ -1,43 +1,36 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
package dev.relism.flash.ext.auth;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type-safe view over the JWT claims stored in {@link ClaimsHolder}.
|
* A typed view over one request's claims — whatever the {@link CredentialSource} that
|
||||||
|
* authenticated it produced. Obtained from {@link ClaimsHolder#current()}.
|
||||||
*
|
*
|
||||||
* <p>Obtainable from any protected context via {@link ClaimsHolder#user()}.
|
* <p>The accessors name claim <em>keys</em>, not a protocol: {@code sub} is RFC 7519, and
|
||||||
* Class-based handlers that extend the {@code SessionHandler} hierarchy already
|
* {@code email}, {@code name} and {@code preferred_username} are spelled the same way by every
|
||||||
* have a provisioned DB user in {@code currentUser}; {@code OidcUser} complements
|
* token issuer worth integrating. A source that uses different keys exposes them through
|
||||||
* that by giving access to the raw OIDC claims when needed, and is the primary
|
* {@link #claim(String)} or {@link #roles(String)}.
|
||||||
* API for lambda routes.
|
|
||||||
*
|
*
|
||||||
* <pre>{@code
|
* <pre>{@code
|
||||||
* // Lambda route (OidcMiddleware injected):
|
|
||||||
* app.get("/api/whoami", (req, res) -> {
|
* app.get("/api/whoami", (req, res) -> {
|
||||||
* OidcUser u = ClaimsHolder.user();
|
* Claims c = ClaimsHolder.current();
|
||||||
* return Map.of("sub", u.sub(), "email", u.email(), "roles", u.roles("realm_access.roles"), "scopes", u.scopes());
|
* return Map.of("sub", c.sub(), "email", c.email(), "roles", c.roles("realm_access.roles"));
|
||||||
* }, oidcMw.protect());
|
* }, auth.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>
|
* }</pre>
|
||||||
*/
|
*/
|
||||||
public final class OidcUser {
|
public final class Claims {
|
||||||
|
|
||||||
private final Map<String, Object> claims;
|
private final Map<String, Object> claims;
|
||||||
|
|
||||||
OidcUser(Map<String, Object> claims) {
|
Claims(Map<String, Object> claims) {
|
||||||
this.claims = claims;
|
this.claims = claims;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Common OIDC standard claims ───────────────────────────────────────────
|
// ── Common claims ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Subject identifier — unique, stable user ID issued by the provider. */
|
/** Subject identifier — the stable, unique id of the caller. */
|
||||||
public String sub() { return str("sub"); }
|
public String sub() { return str("sub"); }
|
||||||
|
|
||||||
/** User's email address ({@code email} claim). */
|
/** User's email address ({@code email} claim). */
|
||||||
@@ -84,7 +77,7 @@ public final class OidcUser {
|
|||||||
// -- Scopes ---------------------------------------------------------------
|
// -- Scopes ---------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves OAuth2 scopes from standard OIDC/OAuth claims using fallback order:
|
* Resolves scopes using the conventional fallback order:
|
||||||
* {@code scope} then {@code scp}. Supports both space-separated string and list forms.
|
* {@code scope} then {@code scp}. Supports both space-separated string and list forms.
|
||||||
*/
|
*/
|
||||||
public List<String> scopes() {
|
public List<String> scopes() {
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
package dev.relism.flash.ext.auth;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current request's claims, published by {@link AuthMiddleware} before the handler runs and
|
||||||
|
* cleared in a {@code finally} afterwards.
|
||||||
|
*
|
||||||
|
* <p>Safe with virtual threads: each request gets its own, so a {@link ThreadLocal} is naturally
|
||||||
|
* isolated per request.
|
||||||
|
*
|
||||||
|
* <p>Writing is deliberately not public. A {@link CredentialSource} returns claims and the
|
||||||
|
* middleware publishes them, so no code outside this module can put claims on a request that did
|
||||||
|
* not carry them.
|
||||||
|
*
|
||||||
|
* <pre>{@code
|
||||||
|
* // Inside any handler behind @Authenticated or @RolesAllowed:
|
||||||
|
* Claims caller = ClaimsHolder.current();
|
||||||
|
* String email = caller.email();
|
||||||
|
* List<String> roles = caller.roles("realm_access.roles");
|
||||||
|
*
|
||||||
|
* // Raw escape hatch:
|
||||||
|
* Map<String, Object> all = ClaimsHolder.map();
|
||||||
|
* }</pre>
|
||||||
|
*/
|
||||||
|
public final class ClaimsHolder {
|
||||||
|
|
||||||
|
private static final ThreadLocal<Map<String, Object>> HOLDER = new ThreadLocal<>();
|
||||||
|
|
||||||
|
private ClaimsHolder() {}
|
||||||
|
|
||||||
|
/** Called by {@link AuthMiddleware} once a source has authenticated the request. */
|
||||||
|
static void set(Map<String, Object> claims) {
|
||||||
|
HOLDER.set(claims);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called by {@link AuthMiddleware} in the {@code finally} block. */
|
||||||
|
static void clear() {
|
||||||
|
HOLDER.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A typed view of the current request's claims, or {@code null} when the route carries no
|
||||||
|
* authentication middleware or the caller is anonymous under
|
||||||
|
* {@link Authenticated}{@code (optional = true)}.
|
||||||
|
*/
|
||||||
|
public static Claims current() {
|
||||||
|
Map<String, Object> claims = HOLDER.get();
|
||||||
|
return claims != null ? new Claims(claims) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The raw claims map for the current request, or {@code null}. @see #current() */
|
||||||
|
public static Map<String, Object> map() {
|
||||||
|
return HOLDER.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single claim as a String, or {@code null} when absent or the caller is anonymous. */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
package dev.relism.flash.ext.auth;
|
||||||
|
|
||||||
|
import dev.relism.flash.exceptions.HttpException;
|
||||||
|
import dev.relism.flash.models.Request;
|
||||||
|
import dev.relism.flash.models.Response;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns whatever a request carries — a bearer token, a session cookie, an API key — into the
|
||||||
|
* claims authorization runs on. One is installed per authentication mechanism;
|
||||||
|
* {@code flash-ext-auth-oidc} contributes the OpenID Connect one.
|
||||||
|
*
|
||||||
|
* <p>Implementations never touch {@link ClaimsHolder}: they produce claims and {@link
|
||||||
|
* AuthMiddleware} publishes them for the duration of the request. Nothing outside this module can
|
||||||
|
* inject claims into a request, which is the point.
|
||||||
|
*/
|
||||||
|
public interface CredentialSource {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the caller's claims, rejecting the request when it cannot.
|
||||||
|
*
|
||||||
|
* <p>Three outcomes, and the difference between the last two matters:
|
||||||
|
* <ul>
|
||||||
|
* <li>claims — the caller presented a valid credential;</li>
|
||||||
|
* <li>{@code null} — no credential was presented and this source has already answered the
|
||||||
|
* request itself (typically a redirect into a sign-in flow). The middleware stops and
|
||||||
|
* writes nothing more;</li>
|
||||||
|
* <li>{@link HttpException} — a credential <em>was</em> presented and is invalid. The source
|
||||||
|
* sets any challenge header it owes the caller before throwing.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
Map<String, Object> authenticate(Request req, Response res);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves claims without ever rejecting: {@code null} when no valid credential is present.
|
||||||
|
* Backs {@link Authenticated}{@code (optional = true)}, where an anonymous caller is a normal
|
||||||
|
* outcome rather than a failure.
|
||||||
|
*/
|
||||||
|
Map<String, Object> peek(Request req);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The {@code WWW-Authenticate} value to send with a 403 caused by missing scopes, or
|
||||||
|
* {@code null} when this source has no such concept. Only consulted after authentication has
|
||||||
|
* already succeeded.
|
||||||
|
*/
|
||||||
|
default String insufficientScopeChallenge(String[] requiredScopes) { return null; }
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package dev.relism.flash.ext.auth;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thread-safe in-memory {@link SessionStore}.
|
||||||
|
*
|
||||||
|
* <p>Sessions are lost on restart and not shared across instances. For
|
||||||
|
* production deployments with multiple nodes or restart-persistence requirements,
|
||||||
|
* supply another implementation to whichever {@link CredentialSource} owns the session.
|
||||||
|
*/
|
||||||
|
public final class InMemorySessionStore implements SessionStore {
|
||||||
|
|
||||||
|
private final ConcurrentHashMap<String, Session> store = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
@Override public void save(Session s) { store.put(s.id(), s); }
|
||||||
|
@Override public Optional<Session> find(String id) { return Optional.ofNullable(store.get(id)); }
|
||||||
|
@Override public void delete(String id) { store.remove(id); }
|
||||||
|
}
|
||||||
+7
-8
@@ -1,4 +1,4 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
package dev.relism.flash.ext.auth;
|
||||||
|
|
||||||
import java.lang.annotation.ElementType;
|
import java.lang.annotation.ElementType;
|
||||||
import java.lang.annotation.Retention;
|
import java.lang.annotation.Retention;
|
||||||
@@ -6,20 +6,19 @@ import java.lang.annotation.RetentionPolicy;
|
|||||||
import java.lang.annotation.Target;
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restricts a handler to callers whose JWT contains at least one of the
|
* Restricts a handler to callers holding at least one of the named roles. Authentication is
|
||||||
* specified roles. Authentication is implicitly required — no need to combine
|
* implied — there is no need to combine it with {@link Authenticated}.
|
||||||
* with {@link Authenticated}.
|
|
||||||
*
|
*
|
||||||
* <p>Roles are read from the claim configured in {@link OidcConfig#rolesClaimPath()}
|
* <p>Roles are read from the claim path the installed credential source is configured with
|
||||||
* (default: {@code "roles"}). Nested paths like {@code "realm_access.roles"} are
|
* (Keycloak's is {@code realm_access.roles}; many providers use a flat {@code roles} or
|
||||||
* supported with dot notation.
|
* {@code groups}). Nested paths use dot notation.
|
||||||
*
|
*
|
||||||
* <pre>{@code
|
* <pre>{@code
|
||||||
* @Route(method = HttpMethod.DELETE, path = "/api/admin/blogs/{id}")
|
* @Route(method = HttpMethod.DELETE, path = "/api/admin/blogs/{id}")
|
||||||
* @RolesAllowed("admin")
|
* @RolesAllowed("admin")
|
||||||
* public class DeleteBlog extends JacksonHandler { ... }
|
* public class DeleteBlog extends JacksonHandler { ... }
|
||||||
*
|
*
|
||||||
* // Multiple accepted roles (OR semantics — any one role is sufficient):
|
* // Multiple accepted roles (OR semantics — any one is sufficient):
|
||||||
* @RolesAllowed({"admin", "editor"})
|
* @RolesAllowed({"admin", "editor"})
|
||||||
* public class UpdateBlog extends JacksonHandler { ... }
|
* public class UpdateBlog extends JacksonHandler { ... }
|
||||||
* }</pre>
|
* }</pre>
|
||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
package dev.relism.flash.ext.auth;
|
||||||
|
|
||||||
import java.lang.annotation.ElementType;
|
import java.lang.annotation.ElementType;
|
||||||
import java.lang.annotation.Retention;
|
import java.lang.annotation.Retention;
|
||||||
@@ -6,11 +6,11 @@ import java.lang.annotation.RetentionPolicy;
|
|||||||
import java.lang.annotation.Target;
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restricts a handler to callers whose token carries the required OAuth2 scopes.
|
* Restricts a handler to callers whose credential carries the required scopes.
|
||||||
* Authentication is implicitly required.
|
* Authentication is implicitly required.
|
||||||
*
|
*
|
||||||
* <p>Scopes are resolved from the configured claim paths in
|
* <p>Scopes are resolved from the configured claim paths in
|
||||||
* {@link OidcConfig#scopeClaimPaths()} (default: {@code "scope,scp"}) and support
|
* {@link AuthConfig#scopeClaimPaths()} (default: {@code "scope,scp"}) and support
|
||||||
* both standard formats:
|
* both standard formats:
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>{@code scope}: space-separated string</li>
|
* <li>{@code scope}: space-separated string</li>
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
package dev.relism.flash.ext.auth;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A signed-in caller's server-side session — saved in a {@link SessionStore} and looked up by a
|
||||||
|
* cookie on every request.
|
||||||
|
*
|
||||||
|
* <p>Immutable: renewing one produces a new instance that replaces the old under the same
|
||||||
|
* {@link #id()}.
|
||||||
|
*
|
||||||
|
* <p>{@link #attributes()} is whatever the {@link CredentialSource} needs to keep alongside the
|
||||||
|
* claims and nothing this module interprets — OpenID Connect stores its access, id and refresh
|
||||||
|
* tokens there so that renewal is its business rather than core's.
|
||||||
|
*/
|
||||||
|
public final class Session {
|
||||||
|
|
||||||
|
/** Renew this far before the real expiry, so a session cannot lapse mid-request. */
|
||||||
|
private static final long EAGER_RENEWAL_SECONDS = 30;
|
||||||
|
|
||||||
|
private final String id;
|
||||||
|
private final Map<String, Object> claims;
|
||||||
|
private final Instant expiresAt;
|
||||||
|
private final Map<String, Object> attributes;
|
||||||
|
|
||||||
|
public Session(String id, Map<String, Object> claims, Instant expiresAt,
|
||||||
|
Map<String, Object> attributes) {
|
||||||
|
this.id = id;
|
||||||
|
this.claims = Map.copyOf(claims);
|
||||||
|
this.expiresAt = expiresAt;
|
||||||
|
this.attributes = attributes == null ? Map.of() : Map.copyOf(attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True once the session is within {@value #EAGER_RENEWAL_SECONDS} seconds of expiring. */
|
||||||
|
public boolean isExpired() {
|
||||||
|
return Instant.now().isAfter(expiresAt.minusSeconds(EAGER_RENEWAL_SECONDS));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One attribute, or {@code null} when the source never stored it. */
|
||||||
|
public Object attribute(String key) { return attributes.get(key); }
|
||||||
|
|
||||||
|
/** One attribute as a String, or {@code null}. */
|
||||||
|
public String attributeAsString(String key) {
|
||||||
|
Object v = attributes.get(key);
|
||||||
|
return v != null ? v.toString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String id() { return id; }
|
||||||
|
public Map<String, Object> claims() { return claims; }
|
||||||
|
public Instant expiresAt() { return expiresAt; }
|
||||||
|
public Map<String, Object> attributes() { return attributes; }
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
package dev.relism.flash.ext.auth;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where {@link Session}s live between requests. {@link InMemorySessionStore} is the default;
|
||||||
|
* supply another for Redis, JDBC, or anything that survives a restart or spans instances.
|
||||||
|
*/
|
||||||
|
public interface SessionStore {
|
||||||
|
void save(Session session);
|
||||||
|
Optional<Session> find(String sessionId);
|
||||||
|
void delete(String sessionId);
|
||||||
|
}
|
||||||
+11
-11
@@ -1,4 +1,4 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
package dev.relism.flash.ext.auth;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@ import java.util.List;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
class OidcAuthPolicyTest {
|
class AuthPolicyTest {
|
||||||
|
|
||||||
static class PlainHandler {}
|
static class PlainHandler {}
|
||||||
|
|
||||||
@@ -33,12 +33,12 @@ class OidcAuthPolicyTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void compileFromAnnotations_noSecurityAnnotations_returnsNull() {
|
void compileFromAnnotations_noSecurityAnnotations_returnsNull() {
|
||||||
assertNull(OidcAuthPolicy.compileFromAnnotations(PlainHandler.class));
|
assertNull(AuthPolicy.compileFromAnnotations(PlainHandler.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void compileFromAnnotations_authenticated_createsRequiredAuthPolicy() {
|
void compileFromAnnotations_authenticated_createsRequiredAuthPolicy() {
|
||||||
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(AuthenticatedHandler.class);
|
AuthPolicy policy = AuthPolicy.compileFromAnnotations(AuthenticatedHandler.class);
|
||||||
assertNotNull(policy);
|
assertNotNull(policy);
|
||||||
assertFalse(policy.optionalAuth());
|
assertFalse(policy.optionalAuth());
|
||||||
assertEquals(0, policy.requiredRoles().length);
|
assertEquals(0, policy.requiredRoles().length);
|
||||||
@@ -47,14 +47,14 @@ class OidcAuthPolicyTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void compileFromAnnotations_optionalAuth_createsOptionalPolicy() {
|
void compileFromAnnotations_optionalAuth_createsOptionalPolicy() {
|
||||||
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(OptionalHandler.class);
|
AuthPolicy policy = AuthPolicy.compileFromAnnotations(OptionalHandler.class);
|
||||||
assertNotNull(policy);
|
assertNotNull(policy);
|
||||||
assertTrue(policy.optionalAuth());
|
assertTrue(policy.optionalAuth());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void compileFromAnnotations_rolesAndScopes_areNormalizedAndMerged() {
|
void compileFromAnnotations_rolesAndScopes_areNormalizedAndMerged() {
|
||||||
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(CombinedHandler.class);
|
AuthPolicy policy = AuthPolicy.compileFromAnnotations(CombinedHandler.class);
|
||||||
assertNotNull(policy);
|
assertNotNull(policy);
|
||||||
assertFalse(policy.optionalAuth());
|
assertFalse(policy.optionalAuth());
|
||||||
assertArrayEquals(new String[]{"admin"}, policy.requiredRoles());
|
assertArrayEquals(new String[]{"admin"}, policy.requiredRoles());
|
||||||
@@ -64,7 +64,7 @@ class OidcAuthPolicyTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void compileFromAnnotations_scopesAny_preservesMatchModeAndDedupes() {
|
void compileFromAnnotations_scopesAny_preservesMatchModeAndDedupes() {
|
||||||
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(ScopesHandler.class);
|
AuthPolicy policy = AuthPolicy.compileFromAnnotations(ScopesHandler.class);
|
||||||
assertNotNull(policy);
|
assertNotNull(policy);
|
||||||
assertArrayEquals(new String[]{"orders:write", "payments:write"}, policy.requiredScopes());
|
assertArrayEquals(new String[]{"orders:write", "payments:write"}, policy.requiredScopes());
|
||||||
assertEquals(ScopesAllowed.Match.ANY, policy.scopeMatch());
|
assertEquals(ScopesAllowed.Match.ANY, policy.scopeMatch());
|
||||||
@@ -73,22 +73,22 @@ class OidcAuthPolicyTest {
|
|||||||
@Test
|
@Test
|
||||||
void compileFromAnnotations_optionalCannotBeCombinedWithConstraints() {
|
void compileFromAnnotations_optionalCannotBeCombinedWithConstraints() {
|
||||||
assertThrows(IllegalStateException.class,
|
assertThrows(IllegalStateException.class,
|
||||||
() -> OidcAuthPolicy.compileFromAnnotations(InvalidOptionalHandler.class));
|
() -> AuthPolicy.compileFromAnnotations(InvalidOptionalHandler.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void openApiScopesFor_returnsScopesWhenPresent() {
|
void openApiScopesFor_returnsScopesWhenPresent() {
|
||||||
assertEquals(List.of("orders:write", "payments:write"),
|
assertEquals(List.of("orders:write", "payments:write"),
|
||||||
OidcAuthPolicy.openApiScopesFor(ScopesHandler.class));
|
AuthPolicy.openApiScopesFor(ScopesHandler.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void openApiScopesFor_rolesOnly_returnsEmptyList() {
|
void openApiScopesFor_rolesOnly_returnsEmptyList() {
|
||||||
assertEquals(List.of(), OidcAuthPolicy.openApiScopesFor(RolesHandler.class));
|
assertEquals(List.of(), AuthPolicy.openApiScopesFor(RolesHandler.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void openApiScopesFor_noSecurity_returnsNull() {
|
void openApiScopesFor_noSecurity_returnsNull() {
|
||||||
assertNull(OidcAuthPolicy.openApiScopesFor(PlainHandler.class));
|
assertNull(AuthPolicy.openApiScopesFor(PlainHandler.class));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+209
@@ -0,0 +1,209 @@
|
|||||||
|
package dev.relism.flash.ext.auth;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Characterisation tests for claim matching — the part of authorization that has nothing to do
|
||||||
|
* with OIDC: given a claims map, does the caller hold a role or a scope.
|
||||||
|
*
|
||||||
|
* <p>Written to pin the <em>current</em> behaviour, including the edges that are easy to change by
|
||||||
|
* accident: which characters separate scopes in a string claim, whether a list entry is trimmed
|
||||||
|
* before comparison, what an empty requirement means under each match mode, and how a claim path
|
||||||
|
* that walks into a non-map resolves. Every assertion here reflects what the code does today, not
|
||||||
|
* what it arguably should do.
|
||||||
|
*/
|
||||||
|
class ClaimMatchingTest {
|
||||||
|
|
||||||
|
/** No credential source: every assertion here is about claims that are already resolved. */
|
||||||
|
private static AuthMiddleware middleware(String rolesPath, String scopePaths) {
|
||||||
|
return new AuthMiddleware(AuthConfig.builder()
|
||||||
|
.rolesClaimPath(rolesPath)
|
||||||
|
.scopeClaimPaths(scopePaths)
|
||||||
|
.build(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AuthMiddleware middleware() {
|
||||||
|
return middleware("realm_access.roles", "scope,scp");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Claim path traversal ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aPathWalksNestedMaps() {
|
||||||
|
Map<String, Object> claims = Map.of("a", Map.of("b", Map.of("c", List.of("x"))));
|
||||||
|
assertTrue(middleware("a.b.c", "scope").rolesAllowed(claims, new String[]{"x"}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aPathThatWalksIntoANonMapResolvesToNothing() {
|
||||||
|
// "a" is a string, so "a.b" has nowhere to go — not an error, just no match.
|
||||||
|
Map<String, Object> claims = Map.of("a", "not-a-map");
|
||||||
|
assertFalse(middleware("a.b", "scope").rolesAllowed(claims, new String[]{"anything"}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aMissingPathResolvesToNothing() {
|
||||||
|
assertFalse(middleware().rolesAllowed(Map.of("other", "value"), new String[]{"admin"}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void emptySegmentsInAPathAreSkipped() {
|
||||||
|
// "realm_access..roles" collapses to the same two segments.
|
||||||
|
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", List.of("admin")));
|
||||||
|
assertTrue(middleware("realm_access..roles", "scope").rolesAllowed(claims, new String[]{"admin"}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void segmentsAreTrimmed() {
|
||||||
|
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", List.of("admin")));
|
||||||
|
assertTrue(middleware(" realm_access . roles ", "scope").rolesAllowed(claims, new String[]{"admin"}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aBlankRolesPathIsRejectedAtConstruction() {
|
||||||
|
assertThrows(IllegalStateException.class, () -> middleware(" ", "scope"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aNullClaimValueResolvesToNothing() {
|
||||||
|
Map<String, Object> nested = new HashMap<>();
|
||||||
|
nested.put("roles", null);
|
||||||
|
Map<String, Object> claims = Map.of("realm_access", nested);
|
||||||
|
assertFalse(middleware().rolesAllowed(claims, new String[]{"admin"}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Roles: ANY semantics ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void anyOneOfTheRequiredRolesIsEnough() {
|
||||||
|
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", List.of("user")));
|
||||||
|
assertTrue(middleware().rolesAllowed(claims, new String[]{"admin", "user"}));
|
||||||
|
assertFalse(middleware().rolesAllowed(claims, new String[]{"admin", "ops"}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void requiringNoRoleAtAllMatchesNothing() {
|
||||||
|
// The loop never runs, so the answer is false even when the claim is present.
|
||||||
|
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", List.of("admin")));
|
||||||
|
assertFalse(middleware().rolesAllowed(claims, new String[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── What counts as "contains" ────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aListClaimMatchesEntrywiseAndTrimsEachEntry() {
|
||||||
|
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", List.of(" admin ", "user")));
|
||||||
|
assertTrue(middleware().rolesAllowed(claims, new String[]{"admin"}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aListEntryIsNeverSplitOnDelimiters() {
|
||||||
|
// Unlike a string claim, a list entry is compared whole: "a b" is one role named "a b".
|
||||||
|
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", List.of("a b")));
|
||||||
|
assertFalse(middleware().rolesAllowed(claims, new String[]{"a"}));
|
||||||
|
assertTrue(middleware().rolesAllowed(claims, new String[]{"a b"}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nullEntriesInAListAreSkipped() {
|
||||||
|
Map<String, Object> claims = Map.of("realm_access",
|
||||||
|
Map.of("roles", Arrays.asList(null, "admin")));
|
||||||
|
assertTrue(middleware().rolesAllowed(claims, new String[]{"admin"}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void anArrayClaimBehavesLikeAList() {
|
||||||
|
Map<String, Object> claims = Map.of("realm_access",
|
||||||
|
Map.of("roles", (Object) new String[]{"admin", "user"}));
|
||||||
|
assertTrue(middleware().rolesAllowed(claims, new String[]{"user"}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aScalarClaimIsComparedWhole() {
|
||||||
|
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", 42));
|
||||||
|
assertTrue(middleware().rolesAllowed(claims, new String[]{"42"}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aStringClaimIsSplitOnSpacesTabsNewlinesAndCommas() {
|
||||||
|
for (String separator : List.of(" ", "\t", "\n", "\r", ",")) {
|
||||||
|
Map<String, Object> claims = Map.of("realm_access",
|
||||||
|
Map.of("roles", "admin" + separator + "user"));
|
||||||
|
assertTrue(middleware().rolesAllowed(claims, new String[]{"user"}),
|
||||||
|
"separator " + separator.strip().isEmpty() + " should split the claim");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aStringClaimDoesNotMatchAPrefixOrASubstring() {
|
||||||
|
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", "administrator"));
|
||||||
|
assertFalse(middleware().rolesAllowed(claims, new String[]{"admin"}));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void repeatedDelimitersProduceNoEmptyTokens() {
|
||||||
|
Map<String, Object> claims = Map.of("realm_access", Map.of("roles", " ,, admin ,, "));
|
||||||
|
assertTrue(middleware().rolesAllowed(claims, new String[]{"admin"}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scopes: ALL vs ANY, across several claim paths ───────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allRequiresEveryScope() {
|
||||||
|
Map<String, Object> claims = Map.of("scope", "openid orders:read");
|
||||||
|
assertTrue(middleware().scopesAllowed(claims, new String[]{"openid", "orders:read"}, ScopesAllowed.Match.ALL));
|
||||||
|
assertFalse(middleware().scopesAllowed(claims, new String[]{"openid", "orders:write"}, ScopesAllowed.Match.ALL));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void anyRequiresOne() {
|
||||||
|
Map<String, Object> claims = Map.of("scope", "openid");
|
||||||
|
assertTrue(middleware().scopesAllowed(claims, new String[]{"nope", "openid"}, ScopesAllowed.Match.ANY));
|
||||||
|
assertFalse(middleware().scopesAllowed(claims, new String[]{"nope", "neither"}, ScopesAllowed.Match.ANY));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void requiringNoScopeIsVacuouslyTrueUnderAllAndFalseUnderAny() {
|
||||||
|
// The asymmetry falls out of the loops and is load-bearing for @ScopesAllowed's validation,
|
||||||
|
// which rejects an empty value list before it can ever reach here.
|
||||||
|
Map<String, Object> claims = Map.of("scope", "openid");
|
||||||
|
assertTrue(middleware().scopesAllowed(claims, new String[0], ScopesAllowed.Match.ALL));
|
||||||
|
assertFalse(middleware().scopesAllowed(claims, new String[0], ScopesAllowed.Match.ANY));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void scopesAreLookedForInEveryConfiguredPathUntilOneMatches() {
|
||||||
|
AuthMiddleware 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[]{"payments:write"}, ScopesAllowed.Match.ALL));
|
||||||
|
assertTrue(mw.scopesAllowed(claims, new String[]{"orders:approve"}, ScopesAllowed.Match.ALL));
|
||||||
|
// ALL is satisfied even when the two scopes come from different claims.
|
||||||
|
assertTrue(mw.scopesAllowed(claims,
|
||||||
|
new String[]{"payments:write", "orders:approve"}, ScopesAllowed.Match.ALL));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void blankScopePathsFallBackToScopeAndScp() {
|
||||||
|
AuthMiddleware mw = middleware("roles", " ");
|
||||||
|
assertTrue(mw.scopesAllowed(Map.of("scope", "a"), new String[]{"a"}, ScopesAllowed.Match.ALL));
|
||||||
|
assertTrue(mw.scopesAllowed(Map.of("scp", "b"), new String[]{"b"}, ScopesAllowed.Match.ALL));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aScopePathListOfOnlySeparatorsFallsBackToScopeAndScp() {
|
||||||
|
AuthMiddleware mw = middleware("roles", " , , ");
|
||||||
|
assertTrue(mw.scopesAllowed(Map.of("scp", "b"), new String[]{"b"}, ScopesAllowed.Match.ALL));
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-6
@@ -1,4 +1,4 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
package dev.relism.flash.ext.auth;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
@@ -7,11 +7,11 @@ import java.util.Map;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
class OidcUserScopesTest {
|
class ClaimsScopesTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void scopes_readsStandardScopeString() {
|
void scopes_readsStandardScopeString() {
|
||||||
OidcUser user = new OidcUser(Map.of("scope", "openid profile orders:read"));
|
Claims user = new Claims(Map.of("scope", "openid profile orders:read"));
|
||||||
|
|
||||||
assertEquals(List.of("openid", "profile", "orders:read"), user.scopes());
|
assertEquals(List.of("openid", "profile", "orders:read"), user.scopes());
|
||||||
assertTrue(user.hasScope("orders:read"));
|
assertTrue(user.hasScope("orders:read"));
|
||||||
@@ -20,7 +20,7 @@ class OidcUserScopesTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void scopes_fallsBackToScpArray() {
|
void scopes_fallsBackToScpArray() {
|
||||||
OidcUser user = new OidcUser(Map.of("scp", List.of("orders:write", "payments:write")));
|
Claims user = new Claims(Map.of("scp", List.of("orders:write", "payments:write")));
|
||||||
|
|
||||||
assertEquals(List.of("orders:write", "payments:write"), user.scopes());
|
assertEquals(List.of("orders:write", "payments:write"), user.scopes());
|
||||||
assertTrue(user.hasScope("payments:write"));
|
assertTrue(user.hasScope("payments:write"));
|
||||||
@@ -28,7 +28,7 @@ class OidcUserScopesTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void scopes_supportsCustomClaimPaths() {
|
void scopes_supportsCustomClaimPaths() {
|
||||||
OidcUser user = new OidcUser(Map.of("permissions", Map.of("scopes", List.of("a", "b"))));
|
Claims user = new Claims(Map.of("permissions", Map.of("scopes", List.of("a", "b"))));
|
||||||
|
|
||||||
assertEquals(List.of("a", "b"), user.scopes("permissions.scopes"));
|
assertEquals(List.of("a", "b"), user.scopes("permissions.scopes"));
|
||||||
assertTrue(user.hasScope("permissions.scopes", "a"));
|
assertTrue(user.hasScope("permissions.scopes", "a"));
|
||||||
@@ -37,7 +37,7 @@ class OidcUserScopesTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void scopes_combinesMultipleClaimPathsInOrder() {
|
void scopes_combinesMultipleClaimPathsInOrder() {
|
||||||
OidcUser user = new OidcUser(Map.of(
|
Claims user = new Claims(Map.of(
|
||||||
"scope", "openid",
|
"scope", "openid",
|
||||||
"scp", List.of("profile", "orders:read")
|
"scp", List.of("profile", "orders:read")
|
||||||
));
|
));
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
package dev.relism.flash.ext.auth;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.HashMap;
|
||||||
|
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.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The two things a session has to get right: it reports itself expired early enough that it
|
||||||
|
* cannot lapse midway through a request, and it hands back what a credential source stored on it
|
||||||
|
* without interpreting any of it.
|
||||||
|
*/
|
||||||
|
class SessionTest {
|
||||||
|
|
||||||
|
private static Session at(Instant expiry, Map<String, Object> attributes) {
|
||||||
|
return new Session("s1", Map.of("sub", "u1"), expiry, attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aSessionIsExpiredWellBeforeItsDeadline() {
|
||||||
|
// The eager window is what stops a session from lapsing between the check and the handler.
|
||||||
|
assertFalse(at(Instant.now().plusSeconds(120), Map.of()).isExpired());
|
||||||
|
assertTrue(at(Instant.now().plusSeconds(10), Map.of()).isExpired());
|
||||||
|
assertTrue(at(Instant.now().minusSeconds(1), Map.of()).isExpired());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void attributesAreReturnedUninterpreted() {
|
||||||
|
Session session = at(Instant.now().plusSeconds(60), Map.of("oidc.id_token", "abc", "n", 7));
|
||||||
|
assertEquals("abc", session.attributeAsString("oidc.id_token"));
|
||||||
|
assertEquals("7", session.attributeAsString("n"));
|
||||||
|
assertEquals(7, session.attribute("n"));
|
||||||
|
assertNull(session.attributeAsString("absent"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aSessionWithoutAttributesIsUsableRatherThanNull() {
|
||||||
|
assertNull(at(Instant.now().plusSeconds(60), null).attributeAsString("anything"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void claimsAndAttributesAreCopiedAndImmutable() {
|
||||||
|
Map<String, Object> mutable = new HashMap<>(Map.of("k", "v"));
|
||||||
|
Session session = at(Instant.now().plusSeconds(60), mutable);
|
||||||
|
mutable.put("k", "changed");
|
||||||
|
|
||||||
|
assertEquals("v", session.attributeAsString("k"));
|
||||||
|
assertThrows(UnsupportedOperationException.class, () -> session.attributes().put("x", "y"));
|
||||||
|
assertThrows(UnsupportedOperationException.class, () -> session.claims().put("x", "y"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
-39
@@ -1,4 +1,4 @@
|
|||||||
# flash-ext-oidc
|
# flash-ext-auth-oidc
|
||||||
|
|
||||||
Full OIDC Authorization Code + PKCE flow for the Flash HTTP server.
|
Full OIDC Authorization Code + PKCE flow for the Flash HTTP server.
|
||||||
Supports Keycloak, Authelia, Auth0, Google, and any RFC 8414-compliant provider.
|
Supports Keycloak, Authelia, Auth0, Google, and any RFC 8414-compliant provider.
|
||||||
@@ -6,6 +6,11 @@ Supports Keycloak, Authelia, Auth0, Google, and any RFC 8414-compliant provider.
|
|||||||
Standards alignment focuses on OIDC Core + OAuth2 bearer APIs while preserving Flash's
|
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).
|
hot-path model (middleware compiled at mount time, no heavy runtime work).
|
||||||
|
|
||||||
|
This extension is the OpenID Connect **credential source** for
|
||||||
|
[`flash-ext-auth-core`](../flash-ext-auth-core/docs/README.md), which owns everything downstream of
|
||||||
|
identifying the caller. Shorter guides live in [`docs/`](docs/README.md), including
|
||||||
|
[migration notes](docs/interop.md#migrating-from-flash-ext-oidc) from `flash-ext-oidc`.
|
||||||
|
|
||||||
## What it provides
|
## What it provides
|
||||||
|
|
||||||
| Component | Description |
|
| Component | Description |
|
||||||
@@ -13,24 +18,25 @@ hot-path model (middleware compiled at mount time, no heavy runtime work).
|
|||||||
| `GET {prefix}/login` | Starts the OIDC flow: builds the authorization URL with PKCE + state, redirects |
|
| `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 |
|
| `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` |
|
| `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) |
|
| `OidcCredentialSource` | The `CredentialSource` this extension contributes to `flash-ext-auth-core` |
|
||||||
| `@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) |
|
| `JwtValidator` | JWKS-backed JWT validator (PKCE + key rotation + caching) |
|
||||||
|
|
||||||
|
`@Authenticated`, `@RolesAllowed`, `@ScopesAllowed`, `AuthMiddleware`, `ClaimsHolder` and `Claims`
|
||||||
|
belong to [`flash-ext-auth-core`](../flash-ext-auth-core/docs/README.md) and work the same behind
|
||||||
|
any credential source. Installing this extension brings them in and wires them up — you do not
|
||||||
|
install auth-core yourself.
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>dev.relism</groupId>
|
<groupId>dev.relism</groupId>
|
||||||
<artifactId>flash-ext-oidc</artifactId>
|
<artifactId>flash-ext-auth-oidc</artifactId>
|
||||||
<version>1.0-SNAPSHOT</version>
|
<version>2.1.0-SNAPSHOT</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
```
|
```
|
||||||
|
|
||||||
Transitive: `nimbus-jose-jwt`, `json-smart`.
|
Transitive: `flash-ext-auth-core`, `nimbus-jose-jwt`, `json-smart`.
|
||||||
Optional: `flash-ext-openapi` — if present, OIDC security schemes are added to the OpenAPI spec automatically.
|
Optional: `flash-ext-openapi` — if present, OIDC security schemes are added to the OpenAPI spec automatically.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
@@ -98,7 +104,7 @@ OidcConfig.builder("https://auth.example.com", "my-client", "secret", "/auth/cal
|
|||||||
| `.scopeClaimPaths("scope,scp")` | `"scope,scp"` | Comma-separated claim paths used to resolve OAuth scopes |
|
| `.scopeClaimPaths("scope,scp")` | `"scope,scp"` | Comma-separated claim paths used to resolve OAuth scopes |
|
||||||
| `.algorithm("RS256")` | `"RS256"` | JWS algorithm for token validation |
|
| `.algorithm("RS256")` | `"RS256"` | JWS algorithm for token validation |
|
||||||
| `.postLogoutRedirectUri("/")` | `"/"` | Where to redirect after logout |
|
| `.postLogoutRedirectUri("/")` | `"/"` | Where to redirect after logout |
|
||||||
| `.sessionStore(store)` | `InMemoryOidcSessionStore` | Custom session store (see below) |
|
| `.sessionStore(store)` | `InMemorySessionStore` | Custom session store (see below) |
|
||||||
| `.clientAuthMethod(ClientAuthMethod.POST)` | `POST` | `POST` = credentials in body; `BASIC` = `Authorization: Basic` |
|
| `.clientAuthMethod(ClientAuthMethod.POST)` | `POST` | `POST` = credentials in body; `BASIC` = `Authorization: Basic` |
|
||||||
| `.insecureTls()` | `false` | Disables TLS certificate verification — **development only** |
|
| `.insecureTls()` | `false` | Disables TLS certificate verification — **development only** |
|
||||||
| `.schemeName("myscheme")` | derived from issuer | OpenAPI security scheme name |
|
| `.schemeName("myscheme")` | derived from issuer | OpenAPI security scheme name |
|
||||||
@@ -130,7 +136,7 @@ OIDC_CLIENT_AUTH_METHOD default: POST
|
|||||||
public class MePage extends JacksonHandler {
|
public class MePage extends JacksonHandler {
|
||||||
@Override
|
@Override
|
||||||
public Object handle(Request req, Response res) {
|
public Object handle(Request req, Response res) {
|
||||||
OidcUser u = ClaimsHolder.user();
|
Claims u = ClaimsHolder.current();
|
||||||
return json(res, Map.of("sub", u.sub(), "email", u.email()));
|
return json(res, Map.of("sub", u.sub(), "email", u.email()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,49 +172,50 @@ Annotation composition rules:
|
|||||||
|
|
||||||
### Lambda routes (manual middleware)
|
### Lambda routes (manual middleware)
|
||||||
|
|
||||||
For lambda routes, pass the middleware as a varargs argument. Retrieve `OidcMiddleware`
|
For lambda routes, pass the middleware as a varargs argument. Retrieve `AuthMiddleware`
|
||||||
from the context inside another extension's `routes()` phase, or after `start()`:
|
from the context inside another extension's `routes()` phase, or after `start()`:
|
||||||
|
|
||||||
```java
|
```java
|
||||||
OidcMiddleware oidc = app.ctx().require(OidcMiddleware.class);
|
AuthMiddleware auth = app.ctx().require(AuthMiddleware.class);
|
||||||
|
|
||||||
// Authentication only
|
// Authentication only
|
||||||
app.get("/api/me", (req, res) -> {
|
app.get("/api/me", (req, res) -> {
|
||||||
OidcUser u = ClaimsHolder.user(); // never null here
|
Claims u = ClaimsHolder.current(); // never null here
|
||||||
return Map.of("sub", u.sub(), "email", u.email());
|
return Map.of("sub", u.sub(), "email", u.email());
|
||||||
}, oidc.protect());
|
}, auth.protect());
|
||||||
|
|
||||||
// Authentication + role check
|
// Authentication + role check
|
||||||
app.delete("/api/admin/users/{id}", (req, res) -> {
|
app.delete("/api/admin/users/{id}", (req, res) -> {
|
||||||
OidcUser u = ClaimsHolder.user();
|
Claims u = ClaimsHolder.current();
|
||||||
// ...
|
// ...
|
||||||
}, oidc.requireRole("admin"));
|
}, auth.requireRole("admin"));
|
||||||
|
|
||||||
// Multiple roles (OR): passes if user holds any one of them
|
// Multiple roles (OR): passes if user holds any one of them
|
||||||
app.get("/api/reports", (req, res) -> { ... }, oidc.requireRole("admin", "reports-viewer"));
|
app.get("/api/reports", (req, res) -> { ... }, auth.requireRole("admin", "reports-viewer"));
|
||||||
|
|
||||||
// Require all listed scopes
|
// Require all listed scopes
|
||||||
app.post("/api/orders", (req, res) -> { ... }, oidc.requireScopes("orders:write", "payments:write"));
|
app.post("/api/orders", (req, res) -> { ... }, auth.requireScopes("orders:write", "payments:write"));
|
||||||
|
|
||||||
// Require at least one listed scope
|
// Require at least one listed scope
|
||||||
app.post("/api/payments", (req, res) -> { ... }, oidc.requireAnyScope("payments:write", "payments:admin"));
|
app.post("/api/payments", (req, res) -> { ... }, auth.requireAnyScope("payments:write", "payments:admin"));
|
||||||
```
|
```
|
||||||
|
|
||||||
`oidc.protect()` / `oidc.requireRole(...)` / `oidc.requireScopes(...)` return a `Middleware` — a composable
|
`auth.protect()` / `auth.requireRole(...)` / `auth.requireScopes(...)` return a `Middleware` — a composable
|
||||||
`Handler → Handler` wrapper. Flash applies middleware right-to-left so the OIDC check
|
`Handler → Handler` wrapper. Flash applies middleware right-to-left so the authentication check
|
||||||
runs before your handler.
|
runs before your handler.
|
||||||
|
|
||||||
## Accessing the authenticated user
|
## Accessing the authenticated user
|
||||||
|
|
||||||
`ClaimsHolder` holds the JWT claims for the current request in a `ThreadLocal`.
|
`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
|
It is populated by `AuthMiddleware` — from `flash-ext-auth-core` — once this source has
|
||||||
`finally` block afterward. It is safe with virtual threads (each request gets its
|
authenticated the request, and cleared in the `finally` block afterward. Nothing outside that
|
||||||
|
module can write to it. It is safe with virtual threads (each request gets its
|
||||||
own virtual thread, so `ThreadLocal` values are naturally isolated).
|
own virtual thread, so `ThreadLocal` values are naturally isolated).
|
||||||
|
|
||||||
### OidcUser (preferred)
|
### Claims (preferred)
|
||||||
|
|
||||||
```java
|
```java
|
||||||
OidcUser u = ClaimsHolder.user(); // never null inside a protected handler
|
Claims u = ClaimsHolder.current(); // never null inside a protected handler
|
||||||
|
|
||||||
String sub = u.sub(); // unique user ID
|
String sub = u.sub(); // unique user ID
|
||||||
String email = u.email();
|
String email = u.email();
|
||||||
@@ -241,7 +248,7 @@ Map<String, Object> all = u.claims();
|
|||||||
### Raw access (escape hatch)
|
### Raw access (escape hatch)
|
||||||
|
|
||||||
```java
|
```java
|
||||||
Map<String, Object> claims = ClaimsHolder.get();
|
Map<String, Object> claims = ClaimsHolder.map();
|
||||||
String email = ClaimsHolder.claim("email");
|
String email = ClaimsHolder.claim("email");
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -340,7 +347,7 @@ Quick path to test `@ScopesAllowed` end-to-end:
|
|||||||
- Include it in `OidcConfig.scopes(...)`, e.g. `"openid profile email orders:write"`
|
- Include it in `OidcConfig.scopes(...)`, e.g. `"openid profile email orders:write"`
|
||||||
5. **Protect a handler**
|
5. **Protect a handler**
|
||||||
- `@ScopesAllowed("orders:write")` on class-based handlers
|
- `@ScopesAllowed("orders:write")` on class-based handlers
|
||||||
- or `oidc.requireScopes("orders:write")` for lambda routes
|
- or `auth.requireScopes("orders:write")` for lambda routes
|
||||||
6. **Verify behavior**
|
6. **Verify behavior**
|
||||||
- token with scope -> 200
|
- token with scope -> 200
|
||||||
- token without scope -> 403 + `WWW-Authenticate: ... insufficient_scope`
|
- token without scope -> 403 + `WWW-Authenticate: ... insufficient_scope`
|
||||||
@@ -354,24 +361,29 @@ Useful token inspection flow while testing:
|
|||||||
|
|
||||||
## Session store
|
## Session store
|
||||||
|
|
||||||
The default `InMemoryOidcSessionStore` is sufficient for single-instance deployments.
|
Sessions live in `flash-ext-auth-core`'s `Session`/`SessionStore`; this extension keeps its
|
||||||
For clustered deployments, implement `OidcSessionStore`:
|
access, id and refresh tokens in `Session.attributes()` under its own keys, so renewal stays here
|
||||||
|
and core carries no OAuth2 vocabulary. See
|
||||||
|
[`../flash-ext-auth-core/docs/sessions.md`](../flash-ext-auth-core/docs/sessions.md).
|
||||||
|
|
||||||
|
The default `InMemorySessionStore` is sufficient for single-instance deployments.
|
||||||
|
For clustered deployments, implement `SessionStore`:
|
||||||
|
|
||||||
```java
|
```java
|
||||||
public interface OidcSessionStore {
|
public interface SessionStore {
|
||||||
void save(OidcSession session);
|
void save(Session session);
|
||||||
Optional<OidcSession> find(String sessionId);
|
Optional<Session> find(String sessionId);
|
||||||
void delete(String sessionId);
|
void delete(String sessionId);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
```java
|
```java
|
||||||
OidcConfig.builder(...)
|
OidcConfig.builder(...)
|
||||||
.sessionStore(new RedisOidcSessionStore(redisClient))
|
.sessionStore(new RedisSessionStore(redisClient))
|
||||||
.build()
|
.build()
|
||||||
```
|
```
|
||||||
|
|
||||||
`OidcSession` fields: `id`, `accessToken`, `idToken`, `refreshToken`, `expiresAt` (`Instant`), `claims` (merged map).
|
`Session` fields: `id`, `accessToken`, `idToken`, `refreshToken`, `expiresAt` (`Instant`), `claims` (merged map).
|
||||||
|
|
||||||
## Logout
|
## Logout
|
||||||
|
|
||||||
@@ -401,7 +413,7 @@ Authorization: Bearer <access_token>
|
|||||||
```
|
```
|
||||||
|
|
||||||
The token must be a JWT (opaque tokens are not supported). Claims are available via
|
The token must be a JWT (opaque tokens are not supported). Claims are available via
|
||||||
`ClaimsHolder.user()` as usual.
|
`ClaimsHolder.current()` as usual.
|
||||||
|
|
||||||
## Multi-tenant
|
## Multi-tenant
|
||||||
|
|
||||||
@@ -420,7 +432,7 @@ app.install(new OidcExtension(tenantA))
|
|||||||
```
|
```
|
||||||
|
|
||||||
To reference a specific tenant's middleware on lambda routes, keep the extension instances
|
To reference a specific tenant's middleware on lambda routes, keep the extension instances
|
||||||
and retrieve `OidcMiddleware` from context after `start()`:
|
and retrieve `AuthMiddleware` from context after `start()`:
|
||||||
|
|
||||||
```java
|
```java
|
||||||
OidcExtension extA = new OidcExtension(tenantA);
|
OidcExtension extA = new OidcExtension(tenantA);
|
||||||
@@ -432,10 +444,10 @@ FlashApp app = FlashApp.create(8080)
|
|||||||
.start()
|
.start()
|
||||||
.join(); // wait for bind
|
.join(); // wait for bind
|
||||||
|
|
||||||
OidcMiddleware mwA = app.ctx().require(OidcMiddleware.class); // last registered = tenantB
|
AuthMiddleware mwA = app.ctx().require(AuthMiddleware.class); // last registered = tenantB
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** because both extensions register `OidcMiddleware.class` in the same context,
|
> **Note:** because both extensions register `AuthMiddleware.class` in the same context,
|
||||||
> only the last one wins under that key. For multi-tenant setups, use distinct 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
|
> keys or provide middleware under a wrapper/alias type, or use lambda routes with explicit
|
||||||
> middleware captured from the extension instance before `install()`.
|
> middleware captured from the extension instance before `install()`.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# flash-ext-auth-oidc
|
||||||
|
|
||||||
|
OpenID Connect for Flash: the authorization-code flow with PKCE, JWKS-validated bearer tokens,
|
||||||
|
server-side sessions with silent refresh, and single logout.
|
||||||
|
|
||||||
|
It is a **credential source** for [`flash-ext-auth-core`](../../flash-ext-auth-core/docs/README.md),
|
||||||
|
which owns everything downstream of "who is this caller" — `@Authenticated`, `@RolesAllowed`,
|
||||||
|
`@ScopesAllowed`, `ClaimsHolder`. Installing this extension installs that machinery too; you do not
|
||||||
|
install `flash-ext-auth-core` yourself.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```java
|
||||||
|
app.install(new OidcExtension(
|
||||||
|
OidcConfig.builder(
|
||||||
|
"https://keycloak.example.com/realms/myrealm",
|
||||||
|
"my-app", "secret", "/auth/callback")
|
||||||
|
.rolesClaimPath("realm_access.roles")
|
||||||
|
.https()
|
||||||
|
.build()));
|
||||||
|
```
|
||||||
|
|
||||||
|
That is the whole integration. Discovery runs at boot and fails fast if the issuer is unreachable,
|
||||||
|
so a misconfigured provider is a startup crash rather than a 500 on the first login.
|
||||||
|
|
||||||
|
`OidcConfig.fromEnv()` reads the same settings from `OIDC_*` environment variables, and
|
||||||
|
`OidcConfig.keycloak(serverUrl, realm, ...)` builds the issuer URL for you.
|
||||||
|
|
||||||
|
## What it registers
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| `GET {prefix}/login` | Builds the authorization URL with PKCE + state and redirects |
|
||||||
|
| `GET {prefix}/callback` | Validates state and nonce, exchanges the code, creates the session |
|
||||||
|
| `POST {prefix}/logout` | Ends the session and redirects to the provider's end-session endpoint |
|
||||||
|
|
||||||
|
`{prefix}` is `routePrefix` (default `/auth`). Logout is a `POST` on purpose — a `GET` logout is
|
||||||
|
one `<img>` tag away from being triggered by any page the user visits.
|
||||||
|
|
||||||
|
In the context it provides `AuthMiddleware` (from auth-core), `OidcCredentialSource` and
|
||||||
|
`JwtValidator`.
|
||||||
|
|
||||||
|
## How a request is resolved
|
||||||
|
|
||||||
|
1. `Authorization: Bearer …` — validated against the issuer's JWKS.
|
||||||
|
2. `oidc_session` cookie — looked up in the `SessionStore`; if the access token has expired and a
|
||||||
|
refresh token is present, refreshed transparently and the session replaced.
|
||||||
|
3. Neither, and the client sent `Accept: application/json` → `401` with a
|
||||||
|
`WWW-Authenticate: Bearer` challenge.
|
||||||
|
4. Neither, and it looks like a browser → redirect to `{prefix}/login?redirect={path}`.
|
||||||
|
|
||||||
|
Points 3 and 4 are why the source distinguishes "no credential" from "bad credential": an API
|
||||||
|
client must not be redirected into an HTML sign-in page, and a browser must not be left staring at
|
||||||
|
a bare 401.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Setting | Default | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `issuer`, `clientId`, `clientSecret`, `redirectUri` | — | required |
|
||||||
|
| `scopes` | `openid profile email` | |
|
||||||
|
| `routePrefix` | `/auth` | |
|
||||||
|
| `selfScheme` | `http` | `https()` behind TLS; only used when no `X-Forwarded-Proto` |
|
||||||
|
| `rolesClaimPath` | `realm_access.roles` | Keycloak's spelling; `groups` for Authelia |
|
||||||
|
| `scopeClaimPaths` | `scope,scp` | comma-separated, tried in order |
|
||||||
|
| `algorithm` | `RS256` | |
|
||||||
|
| `postLogoutRedirectUri` | `/` | |
|
||||||
|
| `sessionStore` | `InMemorySessionStore` | swap for Redis/JDBC across instances |
|
||||||
|
| `clientAuthMethod` | `POST` | token endpoint client authentication |
|
||||||
|
| `insecureTls()` | off | dev only, skips certificate validation |
|
||||||
|
| `schemeName` | derived from the issuer | OpenAPI security scheme name |
|
||||||
|
|
||||||
|
A relative `redirectUri` (starting with `/`) is resolved per request against the incoming `Host`,
|
||||||
|
or `X-Forwarded-Host`/`-Proto` when behind a proxy — so one build works in dev and behind TLS
|
||||||
|
without a second configuration.
|
||||||
|
|
||||||
|
## Sessions
|
||||||
|
|
||||||
|
A session holds the claims plus the access, id and refresh tokens, the last three in
|
||||||
|
`Session.attributes()` under this extension's own keys. Core never reads them; renewal happens
|
||||||
|
here. See [`../../flash-ext-auth-core/docs/sessions.md`](../../flash-ext-auth-core/docs/sessions.md).
|
||||||
|
|
||||||
|
## Multiple providers
|
||||||
|
|
||||||
|
Two issuers on one server, each with its own route prefix:
|
||||||
|
|
||||||
|
```java
|
||||||
|
app.install(new OidcExtension(tenantAConfig)) // routePrefix("/tenantA/auth")
|
||||||
|
.install(new OidcExtension(tenantBConfig)); // routePrefix("/tenantB/auth")
|
||||||
|
```
|
||||||
|
|
||||||
|
Both are known at boot. Registering an issuer at runtime — a customer connecting their own IdP from
|
||||||
|
a settings page — is not supported.
|
||||||
|
|
||||||
|
## Interop
|
||||||
|
|
||||||
|
See [`interop.md`](interop.md) for how this extension fits with `flash-ext-auth-core`,
|
||||||
|
`flash-ext-openapi` and `flash-ext-mcp`.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Interop
|
||||||
|
|
||||||
|
## flash-ext-auth-core
|
||||||
|
|
||||||
|
A hard dependency, and the reason this extension is as small as it is. The division:
|
||||||
|
|
||||||
|
| Here | `flash-ext-auth-core` |
|
||||||
|
|---|---|
|
||||||
|
| Discovery, JWKS, PKCE, token endpoint | `@Authenticated`, `@RolesAllowed`, `@ScopesAllowed` |
|
||||||
|
| `/login`, `/callback`, `/logout` | `ClaimsHolder`, `Claims` |
|
||||||
|
| Bearer and cookie resolution, refresh | Role and scope matching |
|
||||||
|
| RFC 6750 `WWW-Authenticate` challenges | `Session`, `SessionStore` |
|
||||||
|
|
||||||
|
`OidcExtension` builds an `OidcCredentialSource`, hands it to `AuthMiddleware.install(...)`, and
|
||||||
|
that publishes the middleware and registers the annotation processor. Everything a handler
|
||||||
|
annotation does is core's code running against claims this extension produced.
|
||||||
|
|
||||||
|
Consequence worth knowing: `@RolesAllowed` is not OIDC-specific and never was. An app that swaps
|
||||||
|
this extension for another credential source keeps every annotation it had.
|
||||||
|
|
||||||
|
## flash-ext-openapi
|
||||||
|
|
||||||
|
Optional, and resolved lazily so this extension runs standalone when openapi is not on the
|
||||||
|
classpath. When it is, an `OpenApiContributor` is registered that emits an `oauth2` security scheme
|
||||||
|
with the `authorizationCode` flow, filled in from the discovery document:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"securitySchemes": {
|
||||||
|
"myrealm": {
|
||||||
|
"type": "oauth2",
|
||||||
|
"flows": { "authorizationCode": { "authorizationUrl": "…", "tokenUrl": "…", "scopes": {…} } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Per-operation security comes from the same annotations the middleware reads, so the spec and the
|
||||||
|
enforcement cannot drift: both call `AuthPolicy.compileFromAnnotations`.
|
||||||
|
|
||||||
|
The scheme name is `schemeName`, derived from the last path segment of the issuer (a Keycloak realm
|
||||||
|
name, usually) unless set explicitly.
|
||||||
|
|
||||||
|
## flash-ext-mcp
|
||||||
|
|
||||||
|
`McpSecurity` asks whether **this** extension is installed — `ctx.find(OidcCredentialSource.class)`
|
||||||
|
— and not merely whether something authenticates:
|
||||||
|
|
||||||
|
| Policy | this extension installed | absent |
|
||||||
|
|---|---|---|
|
||||||
|
| `REQUIRED` | protected | **boot fails** |
|
||||||
|
| `AUTO` | protected | unprotected, warning logged |
|
||||||
|
| `NONE` | never protected | unprotected |
|
||||||
|
|
||||||
|
That distinction is deliberate. `REQUIRED` means "a real OAuth2 authorization server is protecting
|
||||||
|
this endpoint", because everything it turns on — RFC 9728 Protected Resource Metadata, RFC 8707
|
||||||
|
audience binding, `WWW-Authenticate` challenges carrying `resource_metadata` — is meaningless
|
||||||
|
without an issuer. An app that authenticates some other way must not satisfy it by accident.
|
||||||
|
|
||||||
|
When it is installed, `McpOidcIntegration` derives the whole resource-server configuration from the
|
||||||
|
source with no extra `McpConfig` calls:
|
||||||
|
|
||||||
|
- the MCP route is wrapped with `authMw.withSource(source.withResourceMetadata(path)).protect()` —
|
||||||
|
the same validation every other route uses, plus the `resource_metadata` challenge parameter;
|
||||||
|
- an audience guard runs after it and rejects any token whose `aud` does not include this
|
||||||
|
endpoint's resource identifier;
|
||||||
|
- the resource identifier is resolved per request from `X-Forwarded-Host`/`-Proto`, or the `Host`
|
||||||
|
header and `selfScheme`.
|
||||||
|
|
||||||
|
An app that does **not** use OAuth2 can still guard `/mcp`: set `McpSecurity.NONE` and pass its own
|
||||||
|
guard to `McpConfig.middleware(...)`.
|
||||||
|
|
||||||
|
## Migrating from flash-ext-oidc
|
||||||
|
|
||||||
|
The module was renamed and its generic half moved. Mechanically:
|
||||||
|
|
||||||
|
| Was | Now |
|
||||||
|
|---|---|
|
||||||
|
| `flash-ext-oidc` (artifact) | `flash-ext-auth-oidc` |
|
||||||
|
| `dev.relism.flash.ext.oidc.Authenticated` (and `RolesAllowed`, `ScopesAllowed`) | `dev.relism.flash.ext.auth.…` |
|
||||||
|
| `OidcMiddleware` | `AuthMiddleware` (`dev.relism.flash.ext.auth`) |
|
||||||
|
| `ctx.find(OidcMiddleware.class)` | `ctx.find(AuthMiddleware.class)` |
|
||||||
|
| `OidcUser` | `Claims` |
|
||||||
|
| `ClaimsHolder.user()` | `ClaimsHolder.current()` |
|
||||||
|
| `ClaimsHolder.get()` | `ClaimsHolder.map()` |
|
||||||
|
| `OidcSession`, `OidcSessionStore`, `InMemoryOidcSessionStore` | `Session`, `SessionStore`, `InMemorySessionStore` |
|
||||||
|
| `session.isAccessTokenExpired()` | `session.isExpired()` |
|
||||||
|
| `session.idToken()` | `session.attributeAsString(OidcCredentialSource.ID_TOKEN)` |
|
||||||
|
|
||||||
|
`OidcConfig`, `OidcExtension` and every setting on them are unchanged.
|
||||||
+5
-1
@@ -10,9 +10,13 @@
|
|||||||
<version>2.1.0-SNAPSHOT</version>
|
<version>2.1.0-SNAPSHOT</version>
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
<artifactId>flash-ext-oidc</artifactId>
|
<artifactId>flash-ext-auth-oidc</artifactId>
|
||||||
|
|
||||||
<dependencies>
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-auth-core</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>dev.relism</groupId>
|
<groupId>dev.relism</groupId>
|
||||||
<artifactId>flash</artifactId>
|
<artifactId>flash</artifactId>
|
||||||
+9
-6
@@ -1,5 +1,8 @@
|
|||||||
package dev.relism.flash.ext.oidc;
|
package dev.relism.flash.ext.oidc;
|
||||||
|
|
||||||
|
import dev.relism.flash.ext.auth.InMemorySessionStore;
|
||||||
|
import dev.relism.flash.ext.auth.SessionStore;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full OIDC client configuration. Build via
|
* Full OIDC client configuration. Build via
|
||||||
* {@link #builder(String, String, String, String)} or {@link #fromEnv()}.
|
* {@link #builder(String, String, String, String)} or {@link #fromEnv()}.
|
||||||
@@ -50,7 +53,7 @@ public final class OidcConfig {
|
|||||||
private final String scopeClaimPaths;
|
private final String scopeClaimPaths;
|
||||||
private final String algorithm;
|
private final String algorithm;
|
||||||
private final String postLogoutRedirectUri;
|
private final String postLogoutRedirectUri;
|
||||||
private final OidcSessionStore sessionStore;
|
private final SessionStore sessionStore;
|
||||||
private final boolean insecureTls;
|
private final boolean insecureTls;
|
||||||
private final ClientAuthMethod clientAuthMethod;
|
private final ClientAuthMethod clientAuthMethod;
|
||||||
private final String schemeName;
|
private final String schemeName;
|
||||||
@@ -68,7 +71,7 @@ public final class OidcConfig {
|
|||||||
this.algorithm = b.algorithm;
|
this.algorithm = b.algorithm;
|
||||||
this.postLogoutRedirectUri = b.postLogoutRedirectUri;
|
this.postLogoutRedirectUri = b.postLogoutRedirectUri;
|
||||||
this.sessionStore = b.sessionStore != null ? b.sessionStore
|
this.sessionStore = b.sessionStore != null ? b.sessionStore
|
||||||
: new InMemoryOidcSessionStore();
|
: new InMemorySessionStore();
|
||||||
this.insecureTls = b.insecureTls;
|
this.insecureTls = b.insecureTls;
|
||||||
this.clientAuthMethod = b.clientAuthMethod;
|
this.clientAuthMethod = b.clientAuthMethod;
|
||||||
this.schemeName = b.schemeName != null ? b.schemeName : deriveScheme(this.issuer);
|
this.schemeName = b.schemeName != null ? b.schemeName : deriveScheme(this.issuer);
|
||||||
@@ -88,7 +91,7 @@ public final class OidcConfig {
|
|||||||
public String scopeClaimPaths() { return scopeClaimPaths; }
|
public String scopeClaimPaths() { return scopeClaimPaths; }
|
||||||
public String algorithm() { return algorithm; }
|
public String algorithm() { return algorithm; }
|
||||||
public String postLogoutRedirectUri() { return postLogoutRedirectUri; }
|
public String postLogoutRedirectUri() { return postLogoutRedirectUri; }
|
||||||
public OidcSessionStore sessionStore() { return sessionStore; }
|
public SessionStore sessionStore() { return sessionStore; }
|
||||||
/** If {@code true}, TLS certificate validation is skipped. <b>Never use in production.</b> */
|
/** If {@code true}, TLS certificate validation is skipped. <b>Never use in production.</b> */
|
||||||
public boolean insecureTls() { return insecureTls; }
|
public boolean insecureTls() { return insecureTls; }
|
||||||
public ClientAuthMethod clientAuthMethod() { return clientAuthMethod; }
|
public ClientAuthMethod clientAuthMethod() { return clientAuthMethod; }
|
||||||
@@ -190,7 +193,7 @@ public final class OidcConfig {
|
|||||||
private String scopeClaimPaths = "scope,scp";
|
private String scopeClaimPaths = "scope,scp";
|
||||||
private String algorithm = "RS256";
|
private String algorithm = "RS256";
|
||||||
private String postLogoutRedirectUri = "/";
|
private String postLogoutRedirectUri = "/";
|
||||||
private OidcSessionStore sessionStore;
|
private SessionStore sessionStore;
|
||||||
private boolean insecureTls = false;
|
private boolean insecureTls = false;
|
||||||
private ClientAuthMethod clientAuthMethod = ClientAuthMethod.POST;
|
private ClientAuthMethod clientAuthMethod = ClientAuthMethod.POST;
|
||||||
private String schemeName = null;
|
private String schemeName = null;
|
||||||
@@ -218,8 +221,8 @@ public final class OidcConfig {
|
|||||||
public Builder algorithm(String algorithm) { this.algorithm = algorithm; return this; }
|
public Builder algorithm(String algorithm) { this.algorithm = algorithm; return this; }
|
||||||
/** Where to redirect after logout (default: {@code /}). */
|
/** Where to redirect after logout (default: {@code /}). */
|
||||||
public Builder postLogoutRedirectUri(String uri) { this.postLogoutRedirectUri = uri; return this; }
|
public Builder postLogoutRedirectUri(String uri) { this.postLogoutRedirectUri = uri; return this; }
|
||||||
/** Custom session store (default: {@link InMemoryOidcSessionStore}). */
|
/** Custom session store (default: {@link InMemorySessionStore}). */
|
||||||
public Builder sessionStore(OidcSessionStore store) { this.sessionStore = store; return this; }
|
public Builder sessionStore(SessionStore store) { this.sessionStore = store; return this; }
|
||||||
/**
|
/**
|
||||||
* Disables TLS certificate verification for all HTTP calls made by this extension.
|
* 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>
|
* <b>Only use in development with self-signed certificates — never in production.</b>
|
||||||
+333
@@ -0,0 +1,333 @@
|
|||||||
|
package dev.relism.flash.ext.oidc;
|
||||||
|
|
||||||
|
import dev.relism.flash.exceptions.HttpException;
|
||||||
|
import dev.relism.flash.ext.auth.CredentialSource;
|
||||||
|
import dev.relism.flash.ext.auth.Session;
|
||||||
|
import dev.relism.flash.models.Response;
|
||||||
|
import dev.relism.flash.models.Request;
|
||||||
|
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The OpenID Connect {@link CredentialSource}: it turns what a request carries into claims, and
|
||||||
|
* rejects it the way OAuth2 says to when it cannot. Authorization on those claims is
|
||||||
|
* {@code flash-ext-auth-core}'s job, not this class's.
|
||||||
|
*
|
||||||
|
* <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 dev.relism.flash.ext.auth.SessionStore}; 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 with a {@code WWW-Authenticate: Bearer} challenge.</li>
|
||||||
|
* </ol>
|
||||||
|
*/
|
||||||
|
public final class OidcCredentialSource implements CredentialSource {
|
||||||
|
|
||||||
|
private static final String BEARER = "Bearer";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys this source stores its OAuth2 tokens under in {@link Session#attributes()}. Core keeps
|
||||||
|
* the session; the tokens inside it are nobody else's business.
|
||||||
|
*/
|
||||||
|
static final String ACCESS_TOKEN = "oidc.access_token";
|
||||||
|
static final String ID_TOKEN = "oidc.id_token";
|
||||||
|
static final String REFRESH_TOKEN = "oidc.refresh_token";
|
||||||
|
|
||||||
|
/** The one place an OIDC session is built, so its attribute keys stay in one place too. */
|
||||||
|
static Session newSession(String id, String accessToken, String idToken, String refreshToken,
|
||||||
|
Instant expiresAt, Map<String, Object> claims) {
|
||||||
|
Map<String, Object> attributes = new HashMap<>(3);
|
||||||
|
if (accessToken != null) attributes.put(ACCESS_TOKEN, accessToken);
|
||||||
|
if (idToken != null) attributes.put(ID_TOKEN, idToken);
|
||||||
|
if (refreshToken != null) attributes.put(REFRESH_TOKEN, refreshToken);
|
||||||
|
return new Session(id, claims, expiresAt, attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private final JwtValidator validator;
|
||||||
|
private final OidcConfig config;
|
||||||
|
private final OidcProviderMetadata meta;
|
||||||
|
private final TokenClient tokenClient;
|
||||||
|
private final String resourceMetadataPath;
|
||||||
|
|
||||||
|
OidcCredentialSource(JwtValidator validator, OidcConfig config,
|
||||||
|
OidcProviderMetadata meta, TokenClient tokenClient) {
|
||||||
|
this(validator, config, meta, tokenClient, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private OidcCredentialSource(JwtValidator validator, OidcConfig config,
|
||||||
|
OidcProviderMetadata meta, TokenClient tokenClient,
|
||||||
|
String resourceMetadataPath) {
|
||||||
|
this.validator = validator;
|
||||||
|
this.config = config;
|
||||||
|
this.meta = meta;
|
||||||
|
this.tokenClient = tokenClient;
|
||||||
|
this.resourceMetadataPath = resourceMetadataPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- CredentialSource -----------------------------------------------------
|
||||||
|
|
||||||
|
/** OIDC issuer this source 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(); }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A copy of this source whose 401 challenges also carry {@code resource_metadata}
|
||||||
|
* (RFC 9728 §5.1), resolved against the request's own scheme and host exactly like
|
||||||
|
* {@link OidcExtension}'s redirect URIs. {@code path} is absolute, e.g.
|
||||||
|
* {@code "/.well-known/oauth-protected-resource/mcp"}.
|
||||||
|
*
|
||||||
|
* <p>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 OidcCredentialSource withResourceMetadata(String path) {
|
||||||
|
return new OidcCredentialSource(validator, config, meta, tokenClient, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> authenticate(Request req, Response res) {
|
||||||
|
return resolve(req, res, resourceMetadataPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Map<String, Object> peek(Request req) {
|
||||||
|
return resolveQuiet(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String insufficientScopeChallenge(String[] requiredScopes) {
|
||||||
|
return bearerChallenge() + ", error=\"insufficient_scope\", scope=\""
|
||||||
|
+ quoted(spaceDelimited(requiredScopes)) + "\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- 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<Session> found = config.sessionStore().find(sessionId);
|
||||||
|
if (found.isPresent()) {
|
||||||
|
Session session = found.get();
|
||||||
|
if (!session.isExpired())
|
||||||
|
return session.claims();
|
||||||
|
if (session.attributeAsString(REFRESH_TOKEN) != null) {
|
||||||
|
try {
|
||||||
|
Session 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<Session> found = config.sessionStore().find(sessionId);
|
||||||
|
if (found.isPresent()) {
|
||||||
|
Session session = found.get();
|
||||||
|
|
||||||
|
if (!session.isExpired())
|
||||||
|
return session.claims();
|
||||||
|
|
||||||
|
// Access token expired — try silent refresh
|
||||||
|
if (session.attributeAsString(REFRESH_TOKEN) != null) {
|
||||||
|
try {
|
||||||
|
Session 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 Session doRefresh(Session old) throws Exception {
|
||||||
|
OidcTokenResponse tokens = tokenClient.refresh(
|
||||||
|
meta.tokenEndpoint(), old.attributeAsString(REFRESH_TOKEN));
|
||||||
|
|
||||||
|
return newSession(
|
||||||
|
old.id(),
|
||||||
|
tokens.accessToken(),
|
||||||
|
tokens.idToken() != null ? tokens.idToken() : old.attributeAsString(ID_TOKEN),
|
||||||
|
tokens.refreshToken() != null ? tokens.refreshToken() : old.attributeAsString(REFRESH_TOKEN),
|
||||||
|
Instant.now().plusSeconds(tokens.expiresIn()),
|
||||||
|
mergeRefreshedClaims(tokens, old)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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\"";
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Object> mergeRefreshedClaims(OidcTokenResponse tokens, Session 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
-18
@@ -4,11 +4,16 @@ import dev.relism.flash.ext.openapi.OpenApiContributor;
|
|||||||
import dev.relism.flash.ext.openapi.OpenApiContributorRegistry;
|
import dev.relism.flash.ext.openapi.OpenApiContributorRegistry;
|
||||||
import dev.relism.flash.ext.openapi.OpenApiOperationContribution;
|
import dev.relism.flash.ext.openapi.OpenApiOperationContribution;
|
||||||
import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
|
import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
|
||||||
|
import dev.relism.flash.ext.auth.AuthConfig;
|
||||||
|
import dev.relism.flash.ext.auth.Session;
|
||||||
|
import dev.relism.flash.ext.auth.AuthMiddleware;
|
||||||
|
import dev.relism.flash.ext.auth.AuthPolicy;
|
||||||
|
import dev.relism.flash.ext.auth.Authenticated;
|
||||||
|
import dev.relism.flash.ext.auth.RolesAllowed;
|
||||||
|
import dev.relism.flash.ext.auth.ScopesAllowed;
|
||||||
import dev.relism.flash.extension.FlashContext;
|
import dev.relism.flash.extension.FlashContext;
|
||||||
import dev.relism.flash.extension.FlashExtension;
|
import dev.relism.flash.extension.FlashExtension;
|
||||||
import dev.relism.flash.extension.FlashRegistrar;
|
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 dev.relism.flash.models.Request;
|
||||||
|
|
||||||
import javax.net.ssl.SSLContext;
|
import javax.net.ssl.SSLContext;
|
||||||
@@ -28,7 +33,8 @@ import java.util.*;
|
|||||||
* <p>At {@link #provide}, the extension:
|
* <p>At {@link #provide}, the extension:
|
||||||
* <ol>
|
* <ol>
|
||||||
* <li>Fetches the provider discovery document — fail-fast at startup.</li>
|
* <li>Fetches the provider discovery document — fail-fast at startup.</li>
|
||||||
* <li>Provides {@link OidcMiddleware} and {@link JwtValidator} in the context.</li>
|
* <li>Provides {@link AuthMiddleware}, {@link OidcCredentialSource} and {@link JwtValidator}
|
||||||
|
* in the context.</li>
|
||||||
* <li>Registers annotation processors for {@link Authenticated}, {@link RolesAllowed}
|
* <li>Registers annotation processors for {@link Authenticated}, {@link RolesAllowed}
|
||||||
* and {@link ScopesAllowed}.</li>
|
* and {@link ScopesAllowed}.</li>
|
||||||
* </ol>
|
* </ol>
|
||||||
@@ -56,7 +62,6 @@ import java.util.*;
|
|||||||
* }</pre>
|
* }</pre>
|
||||||
*/
|
*/
|
||||||
public class OidcExtension implements FlashExtension {
|
public class OidcExtension implements FlashExtension {
|
||||||
private static final MiddlewareKey POLICY = MiddlewareKey.of("flash.oidc.policy");
|
|
||||||
|
|
||||||
private final OidcConfig config;
|
private final OidcConfig config;
|
||||||
|
|
||||||
@@ -65,7 +70,8 @@ public class OidcExtension implements FlashExtension {
|
|||||||
private OidcStateStore stateStore;
|
private OidcStateStore stateStore;
|
||||||
private TokenClient tokenClient;
|
private TokenClient tokenClient;
|
||||||
private JwtValidator validator;
|
private JwtValidator validator;
|
||||||
private OidcMiddleware oidcMw;
|
private OidcCredentialSource source;
|
||||||
|
private AuthMiddleware authMw;
|
||||||
|
|
||||||
public OidcExtension(OidcConfig config) {
|
public OidcExtension(OidcConfig config) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
@@ -87,15 +93,15 @@ public class OidcExtension implements FlashExtension {
|
|||||||
validator = new JwtValidator(meta.jwksUri(), config.issuer(), config.clientId(), config.algorithm(), http);
|
validator = new JwtValidator(meta.jwksUri(), config.issuer(), config.clientId(), config.algorithm(), http);
|
||||||
stateStore = new OidcStateStore();
|
stateStore = new OidcStateStore();
|
||||||
tokenClient = new TokenClient(http, config);
|
tokenClient = new TokenClient(http, config);
|
||||||
oidcMw = new OidcMiddleware(validator, config, meta, tokenClient);
|
source = new OidcCredentialSource(validator, config, meta, tokenClient);
|
||||||
|
authMw = AuthMiddleware.install(ctx, AuthConfig.builder()
|
||||||
|
.rolesClaimPath(config.rolesClaimPath())
|
||||||
|
.scopeClaimPaths(config.scopeClaimPaths())
|
||||||
|
.build(), source);
|
||||||
|
|
||||||
ctx.provide(OidcMiddleware.class, oidcMw);
|
ctx.provide(OidcCredentialSource.class, source);
|
||||||
ctx.provide(JwtValidator.class, validator);
|
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));
|
ctx.onReady(() -> registerRoutes(app, ctx));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,7 +170,7 @@ public class OidcExtension implements FlashExtension {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Object> claims = mergeClaims(tokens);
|
Map<String, Object> claims = mergeClaims(tokens);
|
||||||
OidcSession session = new OidcSession(
|
Session session = OidcCredentialSource.newSession(
|
||||||
UUID.randomUUID().toString(),
|
UUID.randomUUID().toString(),
|
||||||
tokens.accessToken(), tokens.idToken(), tokens.refreshToken(),
|
tokens.accessToken(), tokens.idToken(), tokens.refreshToken(),
|
||||||
Instant.now().plusSeconds(tokens.expiresIn()), claims);
|
Instant.now().plusSeconds(tokens.expiresIn()), claims);
|
||||||
@@ -178,12 +184,12 @@ public class OidcExtension implements FlashExtension {
|
|||||||
// ── POST {prefix}/logout ──────────────────────────────────────────────
|
// ── POST {prefix}/logout ──────────────────────────────────────────────
|
||||||
// Invalidates the local session and redirects to end_session_endpoint.
|
// Invalidates the local session and redirects to end_session_endpoint.
|
||||||
app.post(prefix + "/logout", (req, res) -> {
|
app.post(prefix + "/logout", (req, res) -> {
|
||||||
String sessionId = OidcMiddleware.cookieValue(req, "oidc_session");
|
String sessionId = OidcCredentialSource.cookieValue(req, "oidc_session");
|
||||||
String idTokenHint = null;
|
String idTokenHint = null;
|
||||||
|
|
||||||
if (sessionId != null) {
|
if (sessionId != null) {
|
||||||
OidcSession session = config.sessionStore().find(sessionId).orElse(null);
|
Session session = config.sessionStore().find(sessionId).orElse(null);
|
||||||
if (session != null) idTokenHint = session.idToken();
|
if (session != null) idTokenHint = session.attributeAsString(OidcCredentialSource.ID_TOKEN);
|
||||||
config.sessionStore().delete(sessionId);
|
config.sessionStore().delete(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,7 +259,7 @@ public class OidcExtension implements FlashExtension {
|
|||||||
|
|
||||||
private String absoluteSelf(Request req, String uri) {
|
private String absoluteSelf(Request req, String uri) {
|
||||||
if (!uri.startsWith("/")) return uri;
|
if (!uri.startsWith("/")) return uri;
|
||||||
return OidcMiddleware.selfOrigin(req, config.selfScheme()) + uri;
|
return OidcCredentialSource.selfOrigin(req, config.selfScheme()) + uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String enc(String v) {
|
private static String enc(String v) {
|
||||||
@@ -299,12 +305,12 @@ public class OidcExtension implements FlashExtension {
|
|||||||
OpenApiOperationContribution.Builder out =
|
OpenApiOperationContribution.Builder out =
|
||||||
OpenApiOperationContribution.builder();
|
OpenApiOperationContribution.builder();
|
||||||
|
|
||||||
List<String> operationScopes = OidcAuthPolicy.openApiScopesFor(handlerClass);
|
List<String> operationScopes = AuthPolicy.openApiScopesFor(handlerClass);
|
||||||
if (operationScopes != null) {
|
if (operationScopes != null) {
|
||||||
out.security(config.schemeName(), operationScopes);
|
out.security(config.schemeName(), operationScopes);
|
||||||
}
|
}
|
||||||
|
|
||||||
OidcAuthPolicy policy = OidcAuthPolicy.compileFromAnnotations(handlerClass);
|
AuthPolicy policy = AuthPolicy.compileFromAnnotations(handlerClass);
|
||||||
if (policy == null || policy.optionalAuth()) return out.build();
|
if (policy == null || policy.optionalAuth()) return out.build();
|
||||||
|
|
||||||
out.response(401, OpenApiResponseContribution.of("Authentication required"));
|
out.response(401, OpenApiResponseContribution.of("Authentication required"));
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
package dev.relism.flash.ext.oidc;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What stayed behind when authorization moved to {@code flash-ext-auth-core}: reading a bearer
|
||||||
|
* token off the wire, and the RFC 6750 challenges this source answers with. The matching of
|
||||||
|
* claims those credentials produce is {@code ClaimMatchingTest}'s job now.
|
||||||
|
*/
|
||||||
|
class OidcCredentialSourceTest {
|
||||||
|
|
||||||
|
private static OidcCredentialSource source() {
|
||||||
|
return new OidcCredentialSource(null, OidcConfig
|
||||||
|
.builder("https://idp.example.com", "client", "secret", "/auth/callback")
|
||||||
|
.build(), null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void extractBearerToken_acceptsCaseInsensitiveBearerAndTrimsSpaces() {
|
||||||
|
assertEquals("abc.def.ghi", OidcCredentialSource.extractBearerToken("Bearer abc.def.ghi"));
|
||||||
|
assertEquals("abc", OidcCredentialSource.extractBearerToken(" bearer abc "));
|
||||||
|
assertNull(OidcCredentialSource.extractBearerToken("Basic Zm9vOmJhcg=="));
|
||||||
|
assertNull(OidcCredentialSource.extractBearerToken("Bearer"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void bearerChallenge_containsRealmAndRfcErrors() {
|
||||||
|
OidcCredentialSource src = source();
|
||||||
|
|
||||||
|
String basic = src.bearerChallenge();
|
||||||
|
String invalid = src.invalidTokenChallenge();
|
||||||
|
String insufficient = src.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\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
@@ -4,6 +4,10 @@ import dev.relism.flash.ext.openapi.OpenApiContributorRegistry;
|
|||||||
import dev.relism.flash.ext.openapi.OpenApiOperationContribution;
|
import dev.relism.flash.ext.openapi.OpenApiOperationContribution;
|
||||||
import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
|
import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
|
||||||
import dev.relism.flash.ext.openapi.OpenApiContributor;
|
import dev.relism.flash.ext.openapi.OpenApiContributor;
|
||||||
|
import dev.relism.flash.ext.auth.AuthPolicy;
|
||||||
|
import dev.relism.flash.ext.auth.Authenticated;
|
||||||
|
import dev.relism.flash.ext.auth.RolesAllowed;
|
||||||
|
import dev.relism.flash.ext.auth.ScopesAllowed;
|
||||||
import dev.relism.flash.extension.FlashContext;
|
import dev.relism.flash.extension.FlashContext;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
`flash-ext-mcp` turns a Flash5 app into an [MCP](https://modelcontextprotocol.io) (Model Context
|
`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
|
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
|
declared as plain classes and discovered at boot, optional OAuth2 protection built on
|
||||||
`flash-ext-oidc`.
|
`flash-ext-auth-oidc`.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ public class GetWeatherTool extends McpTool {
|
|||||||
`tools-resources-prompts.md`.
|
`tools-resources-prompts.md`.
|
||||||
- **Transport**: Streamable HTTP, `POST`-only, stateless in this revision — see `transport.md`
|
- **Transport**: Streamable HTTP, `POST`-only, stateless in this revision — see `transport.md`
|
||||||
for exactly what that means and why.
|
for exactly what that means and why.
|
||||||
- **Security**: optional, policy-driven OAuth2 via `flash-ext-oidc` — see `security.md`.
|
- **Security**: optional, policy-driven OAuth2 via `flash-ext-auth-oidc` — see `security.md`.
|
||||||
- **JSON**: this extension owns its JSON handling independently of `flash-ext-jackson` — see
|
- **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.
|
`jackson-interop.md` for why, and how a future opt-in reuse could work.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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.
|
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-auth-oidc` already handles its own internal JSON needs (`json-smart` for
|
||||||
token-endpoint responses) independently of `flash-ext-jackson` — extensions with protocol-level
|
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
|
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.
|
than routing it through the app's general-purpose JSON extension.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Keycloak cookbook
|
# Keycloak cookbook
|
||||||
|
|
||||||
`security.md` covers the OAuth2 mechanics `McpOidcIntegration` implements against any
|
`security.md` covers the OAuth2 mechanics `McpOidcIntegration` implements against any
|
||||||
`flash-ext-oidc`-compatible provider. This is the Keycloak-specific setup: the exact Admin
|
`flash-ext-auth-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
|
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.
|
(DCR) — no pre-registered clients, any MCP client self-registers on first connect.
|
||||||
|
|
||||||
|
|||||||
@@ -7,32 +7,50 @@ Allowed Client Scopes configuration `scopes_supported` needs to actually work.
|
|||||||
|
|
||||||
## `McpSecurity`
|
## `McpSecurity`
|
||||||
|
|
||||||
`McpConfig.security(...)` controls how the MCP endpoint reacts to `flash-ext-oidc` being
|
`McpConfig.security(...)` controls how the MCP endpoint reacts to `flash-ext-auth-oidc` being
|
||||||
installed (`ctx.find(OidcMiddleware.class)`), resolved once at boot in `McpExtension.routes()`:
|
installed (`ctx.find(OidcMiddleware.class)`), resolved once at boot in `McpExtension.routes()`:
|
||||||
|
|
||||||
| Policy | `flash-ext-oidc` installed | `flash-ext-oidc` absent |
|
| Policy | `flash-ext-auth-oidc` installed | `flash-ext-auth-oidc` absent |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `REQUIRED` | protected | **boot fails** (`IllegalStateException`) |
|
| `REQUIRED` | protected | **boot fails** (`IllegalStateException`) |
|
||||||
| `AUTO` (default) | protected | runs unprotected, logs a warning |
|
| `AUTO` (default) | protected | runs unprotected, logs a warning |
|
||||||
| `NONE` | never protected, even if oidc is installed elsewhere in the app | runs unprotected |
|
| `NONE` | never protected, even if oidc is installed elsewhere in the app | runs unprotected |
|
||||||
|
|
||||||
|
### Guarding `/mcp` without OAuth2
|
||||||
|
|
||||||
|
`McpSecurity` only ever answers "is `flash-ext-auth-oidc` installed". An app that authenticates
|
||||||
|
some other way sets `McpSecurity.NONE` and supplies its own guard:
|
||||||
|
|
||||||
|
```java
|
||||||
|
McpConfig.builder("my-server")
|
||||||
|
.toolsPackage("com.example.mcp")
|
||||||
|
.security(McpSecurity.NONE)
|
||||||
|
.middleware(myAuthMiddleware.protect())
|
||||||
|
.build();
|
||||||
|
```
|
||||||
|
|
||||||
|
`McpConfig.middleware(...)` runs after the transport guards and after whatever `McpSecurity`
|
||||||
|
resolved to, so it composes with OAuth2 protection rather than replacing it — the same hook is how
|
||||||
|
you add rate limiting, audit logging or tracing to the endpoint. It never satisfies `REQUIRED`,
|
||||||
|
which still asks for a real authorization server.
|
||||||
|
|
||||||
Use `REQUIRED` for anything you intend to run in production reachable over the network — it
|
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
|
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
|
endpoint. `AUTO` is meant for local development, where spinning up a real identity provider is
|
||||||
friction you don't want yet.
|
friction you don't want yet.
|
||||||
|
|
||||||
## Why `flash-ext-oidc` is an *optional* Maven dependency, concretely
|
## Why `flash-ext-auth-oidc` is an *optional* Maven dependency, concretely
|
||||||
|
|
||||||
Maven's `<optional>true</optional>` only affects **transitive** propagation: consumers of
|
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.
|
`flash-ext-mcp` don't get `flash-ext-auth-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
|
Within `flash-ext-mcp` itself, `flash-ext-auth-oidc`'s classes are on the compile/test classpath as
|
||||||
normal — this extension can (and does) reference `OidcMiddleware`/`ClaimsHolder` directly in
|
normal — this extension can (and does) reference `OidcMiddleware`/`ClaimsHolder` directly in
|
||||||
source.
|
source.
|
||||||
|
|
||||||
That reference is isolated in its own class, `McpOidcIntegration`, invoked only from inside a
|
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
|
`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
|
`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
|
evaluated — if `flash-ext-auth-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
|
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
|
`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
|
`McpExtension` itself loads and works fine standalone; only the attempt to actually use OIDC
|
||||||
@@ -45,7 +63,7 @@ When oidc is available and `security() != NONE`, `McpOidcIntegration` (an isolat
|
|||||||
lazily-loaded bridge — see its javadoc) derives everything an MCP OAuth2 resource server needs
|
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:
|
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)`
|
1. The MCP route is wrapped with `flash-ext-auth-oidc`'s own `OidcMiddleware.protect(resourceMetadataPath)`
|
||||||
— the same Bearer-token/JWKS validation path used everywhere else in Flash5, plus a
|
— 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
|
`resource_metadata` challenge parameter (see below). No JWT parsing or JWKS handling is
|
||||||
reimplemented here.
|
reimplemented here.
|
||||||
@@ -114,7 +132,7 @@ unaffected — this parameter is additive and MCP-specific.
|
|||||||
|
|
||||||
## Per-tool `@RolesAllowed`/`@ScopesAllowed`
|
## Per-tool `@RolesAllowed`/`@ScopesAllowed`
|
||||||
|
|
||||||
`McpTool` subclasses can carry `flash-ext-oidc`'s `@RolesAllowed`/`@ScopesAllowed`:
|
`McpTool` subclasses can carry `flash-ext-auth-oidc`'s `@RolesAllowed`/`@ScopesAllowed`:
|
||||||
|
|
||||||
```java
|
```java
|
||||||
@Tool(name = "delete_route", description = "Delete a route")
|
@Tool(name = "delete_route", description = "Delete a route")
|
||||||
@@ -124,7 +142,7 @@ public class DeleteRouteTool extends McpTool {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
This does **not** reuse `flash-ext-oidc`'s per-route middleware mechanism (`ctx.addAnnotationProcessor`,
|
This does **not** reuse `flash-ext-auth-oidc`'s per-route middleware mechanism (`ctx.addAnnotationProcessor`,
|
||||||
the thing that makes these annotations work on a `RequestHandler`) — it can't: every tool shares
|
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
|
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,
|
there is no per-tool route to attach a different middleware chain to. Instead,
|
||||||
@@ -156,7 +174,7 @@ at `app.start()`.
|
|||||||
|
|
||||||
## The `HttpException` safety net
|
## The `HttpException` safety net
|
||||||
|
|
||||||
`flash-ext-oidc`'s middleware throws `HttpException.unauthorized()`/`forbidden()` on auth
|
`flash-ext-auth-oidc`'s middleware throws `HttpException.unauthorized()`/`forbidden()` on auth
|
||||||
failure. Flash5's core does **not** special-case `HttpException` in the default exception
|
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
|
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
|
of the thrown exception's embedded status code; only an app that explicitly calls
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>dev.relism</groupId>
|
<groupId>dev.relism</groupId>
|
||||||
<artifactId>flash-ext-oidc</artifactId>
|
<artifactId>flash-ext-auth-oidc</artifactId>
|
||||||
<optional>true</optional>
|
<optional>true</optional>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ import java.util.function.Supplier;
|
|||||||
*
|
*
|
||||||
* <p>{@code check} is a closure, not a raw role/scope list — this is what lets this record (and
|
* <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
|
* 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
|
* flash-ext-auth-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}
|
* 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
|
* signature crosses the boundary; the closure itself, built once inside {@code
|
||||||
* McpOidcIntegration}, is the only place that ever touches {@code OidcUser}/{@code ClaimsHolder}.
|
* McpOidcIntegration}, is the only place that ever touches {@code OidcUser}/{@code ClaimsHolder}.
|
||||||
|
|||||||
+24
-2
@@ -1,5 +1,7 @@
|
|||||||
package dev.relism.flash.ext.mcp;
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
|
import dev.relism.flash.routing.Middleware;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -28,6 +30,7 @@ public final class McpConfig {
|
|||||||
private final String authorizationServerIssuer;
|
private final String authorizationServerIssuer;
|
||||||
private final List<String> allowedOrigins;
|
private final List<String> allowedOrigins;
|
||||||
private final List<String> scopesSupported;
|
private final List<String> scopesSupported;
|
||||||
|
private final List<Middleware> middleware;
|
||||||
|
|
||||||
private McpConfig(Builder b) {
|
private McpConfig(Builder b) {
|
||||||
this.name = b.name;
|
this.name = b.name;
|
||||||
@@ -40,6 +43,7 @@ public final class McpConfig {
|
|||||||
this.authorizationServerIssuer = b.authorizationServerIssuer;
|
this.authorizationServerIssuer = b.authorizationServerIssuer;
|
||||||
this.allowedOrigins = List.copyOf(b.allowedOrigins);
|
this.allowedOrigins = List.copyOf(b.allowedOrigins);
|
||||||
this.scopesSupported = List.copyOf(b.scopesSupported);
|
this.scopesSupported = List.copyOf(b.scopesSupported);
|
||||||
|
this.middleware = List.copyOf(b.middleware);
|
||||||
}
|
}
|
||||||
|
|
||||||
String name() { return name; }
|
String name() { return name; }
|
||||||
@@ -52,6 +56,7 @@ public final class McpConfig {
|
|||||||
String authorizationServerIssuer() { return authorizationServerIssuer; }
|
String authorizationServerIssuer() { return authorizationServerIssuer; }
|
||||||
List<String> allowedOrigins() { return allowedOrigins; }
|
List<String> allowedOrigins() { return allowedOrigins; }
|
||||||
List<String> scopesSupported() { return scopesSupported; }
|
List<String> scopesSupported() { return scopesSupported; }
|
||||||
|
List<Middleware> middleware() { return middleware; }
|
||||||
|
|
||||||
public static Builder builder(String name) { return new Builder(name); }
|
public static Builder builder(String name) { return new Builder(name); }
|
||||||
|
|
||||||
@@ -66,6 +71,7 @@ public final class McpConfig {
|
|||||||
private String authorizationServerIssuer;
|
private String authorizationServerIssuer;
|
||||||
private final List<String> allowedOrigins = new ArrayList<>();
|
private final List<String> allowedOrigins = new ArrayList<>();
|
||||||
private final List<String> scopesSupported = new ArrayList<>();
|
private final List<String> scopesSupported = new ArrayList<>();
|
||||||
|
private final List<Middleware> middleware = new ArrayList<>();
|
||||||
|
|
||||||
private Builder(String name) {
|
private Builder(String name) {
|
||||||
if (name == null || name.isBlank())
|
if (name == null || name.isBlank())
|
||||||
@@ -91,7 +97,7 @@ public final class McpConfig {
|
|||||||
/**
|
/**
|
||||||
* Canonical URI of this MCP endpoint, used for RFC 8707 audience binding: tokens whose
|
* 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 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
|
* {@code flash-ext-auth-oidc} is installed, this is auto-derived per request from the
|
||||||
* forwarded/{@code Host} headers (same resolution {@code OidcExtension} uses for its own
|
* forwarded/{@code Host} headers (same resolution {@code OidcExtension} uses for its own
|
||||||
* redirect URIs) and audience binding is enforced unconditionally. Set this explicitly
|
* redirect URIs) and audience binding is enforced unconditionally. Set this explicitly
|
||||||
* only to override that guess — a reverse proxy that forwards neither
|
* only to override that guess — a reverse proxy that forwards neither
|
||||||
@@ -102,7 +108,7 @@ public final class McpConfig {
|
|||||||
/**
|
/**
|
||||||
* Authorization server issuer URL, published in the RFC 9728 Protected Resource
|
* Authorization server issuer URL, published in the RFC 9728 Protected Resource
|
||||||
* Metadata document at {@code /.well-known/oauth-protected-resource{rootPath}}. Optional
|
* 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
|
* — when {@code flash-ext-auth-oidc} is installed, this is auto-derived from its configured
|
||||||
* issuer. Set this explicitly only to override that (e.g. publishing a different issuer
|
* issuer. Set this explicitly only to override that (e.g. publishing a different issuer
|
||||||
* than the one actually validating tokens).
|
* than the one actually validating tokens).
|
||||||
*/
|
*/
|
||||||
@@ -128,6 +134,22 @@ public final class McpConfig {
|
|||||||
*/
|
*/
|
||||||
public Builder scopesSupported(String... scopes) { this.scopesSupported.addAll(List.of(scopes)); return this; }
|
public Builder scopesSupported(String... scopes) { this.scopesSupported.addAll(List.of(scopes)); return this; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware to run on the MCP route, in the order given, after the transport guards and
|
||||||
|
* after whatever {@link McpSecurity} resolved to. Rate limiting, audit logging, tracing —
|
||||||
|
* anything that is routine on every other Flash route and had no way in here.
|
||||||
|
*
|
||||||
|
* <p>It runs on an authenticated request when OAuth2 protection is active, and is the only
|
||||||
|
* thing standing in front of the endpoint when it is not: {@link McpSecurity#NONE} plus a
|
||||||
|
* middleware of your own is how an app that authenticates some other way guards
|
||||||
|
* {@code /mcp}. It never satisfies {@link McpSecurity#REQUIRED}, which still asks for a
|
||||||
|
* real authorization server.
|
||||||
|
*/
|
||||||
|
public Builder middleware(Middleware... middleware) {
|
||||||
|
this.middleware.addAll(List.of(middleware));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
public McpConfig build() {
|
public McpConfig build() {
|
||||||
if (toolsPackage == null || toolsPackage.isBlank())
|
if (toolsPackage == null || toolsPackage.isBlank())
|
||||||
throw new IllegalStateException(
|
throw new IllegalStateException(
|
||||||
|
|||||||
+7
-6
@@ -25,7 +25,7 @@ import java.util.List;
|
|||||||
* .build()))
|
* .build()))
|
||||||
* .start();
|
* .start();
|
||||||
*
|
*
|
||||||
* // With flash-ext-oidc as the OAuth2 resource server — zero extra config: issuer, canonical
|
* // With flash-ext-auth-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
|
* // resource identifier, RFC 8707 audience binding and RFC 9728 metadata are all derived from
|
||||||
* // the installed OidcExtension.
|
* // the installed OidcExtension.
|
||||||
* FlashApp.create(8080)
|
* FlashApp.create(8080)
|
||||||
@@ -65,10 +65,11 @@ public class McpExtension implements FlashExtension {
|
|||||||
secured == null ? null : secured.rolesClaimPath());
|
secured == null ? null : secured.rolesClaimPath());
|
||||||
McpDispatcher dispatcher = new McpDispatcher(registry, config.name(), config.version(), config.instructions());
|
McpDispatcher dispatcher = new McpDispatcher(registry, config.name(), config.version(), config.instructions());
|
||||||
|
|
||||||
List<Middleware> chain = new ArrayList<>(3);
|
List<Middleware> chain = new ArrayList<>(3 + config.middleware().size());
|
||||||
chain.add(McpTransportGuards.httpExceptionGuard());
|
chain.add(McpTransportGuards.httpExceptionGuard());
|
||||||
chain.add(McpTransportGuards.originGuard(config.allowedOrigins()));
|
chain.add(McpTransportGuards.originGuard(config.allowedOrigins()));
|
||||||
if (secured != null) chain.add(secured.security());
|
if (secured != null) chain.add(secured.security());
|
||||||
|
chain.addAll(config.middleware());
|
||||||
|
|
||||||
app.post(config.rootPath(), (req, res) -> { dispatcher.handle(req, res); return null; },
|
app.post(config.rootPath(), (req, res) -> { dispatcher.handle(req, res); return null; },
|
||||||
chain.toArray(Middleware[]::new));
|
chain.toArray(Middleware[]::new));
|
||||||
@@ -83,20 +84,20 @@ public class McpExtension implements FlashExtension {
|
|||||||
try {
|
try {
|
||||||
resolved = McpOidcIntegration.resolve(ctx, config);
|
resolved = McpOidcIntegration.resolve(ctx, config);
|
||||||
} catch (NoClassDefFoundError e) {
|
} catch (NoClassDefFoundError e) {
|
||||||
resolved = null; // flash-ext-oidc not on the classpath at all
|
resolved = null; // flash-ext-auth-oidc not on the classpath at all
|
||||||
}
|
}
|
||||||
if (resolved != null) return resolved;
|
if (resolved != null) return resolved;
|
||||||
|
|
||||||
if (config.security() == McpSecurity.REQUIRED) {
|
if (config.security() == McpSecurity.REQUIRED) {
|
||||||
throw new IllegalStateException(
|
throw new IllegalStateException(
|
||||||
"McpSecurity.REQUIRED but flash-ext-oidc is not installed for MCP server \"" + config.name() +
|
"McpSecurity.REQUIRED but flash-ext-auth-oidc is not installed for MCP server \"" + config.name() +
|
||||||
"\" — install an OidcExtension before this McpExtension, or relax security to " +
|
"\" — install an OidcExtension before this McpExtension, or relax security to " +
|
||||||
"McpSecurity.AUTO/NONE if this server is meant to be public.");
|
"McpSecurity.AUTO/NONE if this server is meant to be public.");
|
||||||
}
|
}
|
||||||
|
|
||||||
log.warn("[flash-ext-mcp] MCP server \"{}\" is running WITHOUT OAuth2 protection — " +
|
log.warn("[flash-ext-mcp] MCP server \"{}\" is running WITHOUT OAuth2 protection — " +
|
||||||
"flash-ext-oidc is not installed and McpSecurity.AUTO degrades to unprotected. " +
|
"flash-ext-auth-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.",
|
"Install flash-ext-auth-oidc or set McpSecurity.REQUIRED to make this a hard failure instead.",
|
||||||
config.name());
|
config.name());
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
* <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
|
* 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 — same reasoning {@code flash-ext-auth-oidc} applies to its own JSON needs
|
||||||
* (see {@code json-smart} there). See {@code docs/jackson-interop.md} for the full rationale
|
* (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.
|
* and how a future opt-in reuse of a shared {@code ObjectMapper} could work.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+29
-23
@@ -1,11 +1,12 @@
|
|||||||
package dev.relism.flash.ext.mcp;
|
package dev.relism.flash.ext.mcp;
|
||||||
|
|
||||||
import dev.relism.flash.ext.oidc.Authenticated;
|
import dev.relism.flash.ext.auth.AuthMiddleware;
|
||||||
import dev.relism.flash.ext.oidc.ClaimsHolder;
|
import dev.relism.flash.ext.auth.Authenticated;
|
||||||
import dev.relism.flash.ext.oidc.OidcMiddleware;
|
import dev.relism.flash.ext.auth.Claims;
|
||||||
import dev.relism.flash.ext.oidc.OidcUser;
|
import dev.relism.flash.ext.auth.ClaimsHolder;
|
||||||
import dev.relism.flash.ext.oidc.RolesAllowed;
|
import dev.relism.flash.ext.auth.RolesAllowed;
|
||||||
import dev.relism.flash.ext.oidc.ScopesAllowed;
|
import dev.relism.flash.ext.auth.ScopesAllowed;
|
||||||
|
import dev.relism.flash.ext.oidc.OidcCredentialSource;
|
||||||
import dev.relism.flash.exceptions.HttpException;
|
import dev.relism.flash.exceptions.HttpException;
|
||||||
import dev.relism.flash.extension.FlashContext;
|
import dev.relism.flash.extension.FlashContext;
|
||||||
import dev.relism.flash.models.Request;
|
import dev.relism.flash.models.Request;
|
||||||
@@ -19,22 +20,22 @@ import java.util.function.Function;
|
|||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lazy, isolated bridge to {@code flash-ext-oidc}.
|
* Lazy, isolated bridge to {@code flash-ext-auth-oidc} and {@code flash-ext-auth-core}.
|
||||||
*
|
*
|
||||||
* <p>References to OIDC types only ever resolve when {@link #resolve}/{@link #compileToolPolicy}
|
* <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
|
* 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
|
* this separate nested class. The caller wraps the invocation in {@code catch
|
||||||
* (NoClassDefFoundError)}, exactly like {@code OidcExtension}'s own lazy bridge to {@code
|
* (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
|
* 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
|
* OAuth2) when {@code flash-ext-auth-oidc} is not even on the classpath. {@link Resolved}/{@link
|
||||||
* McpAuthPolicy} carry only oidc-free types back out ({@link Middleware}, {@link String}, a
|
* 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
|
* {@link Function}, a {@link Supplier}) so no other class in this package ever has to reference
|
||||||
* an OIDC type.
|
* an OIDC type.
|
||||||
*
|
*
|
||||||
* <p>Zero-config by design: when {@code flash-ext-oidc} is installed, everything an MCP OAuth2
|
* <p>Zero-config by design: when {@code flash-ext-auth-oidc} is installed, everything an MCP OAuth2
|
||||||
* resource server needs — issuer, canonical resource identifier, RFC 8707 audience binding, and
|
* 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
|
* 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.
|
* the installed {@link OidcCredentialSource}, with no additional {@link McpConfig} calls.
|
||||||
* {@link McpConfig#resourceIdentifier(String)}/{@link McpConfig#authorizationServerIssuer(String)}
|
* {@link McpConfig#resourceIdentifier(String)}/{@link McpConfig#authorizationServerIssuer(String)}
|
||||||
* remain as explicit overrides for the rare case where that guess is wrong.
|
* remain as explicit overrides for the rare case where that guess is wrong.
|
||||||
*/
|
*/
|
||||||
@@ -51,20 +52,25 @@ final class McpOidcIntegration {
|
|||||||
|
|
||||||
/** Returns the resolved security bundle, or {@code null} if oidc is not installed. */
|
/** Returns the resolved security bundle, or {@code null} if oidc is not installed. */
|
||||||
static Resolved resolve(FlashContext ctx, McpConfig config) {
|
static Resolved resolve(FlashContext ctx, McpConfig config) {
|
||||||
Optional<OidcMiddleware> oidc = ctx.find(OidcMiddleware.class);
|
// Deliberately keyed on the OIDC source and not on AuthMiddleware: McpSecurity means
|
||||||
if (oidc.isEmpty()) return null;
|
// "a real OAuth2 authorization server is protecting this endpoint", and an app that
|
||||||
|
// authenticates some other way must not satisfy REQUIRED by accident.
|
||||||
|
Optional<OidcCredentialSource> oidc = ctx.find(OidcCredentialSource.class);
|
||||||
|
Optional<AuthMiddleware> auth = ctx.find(AuthMiddleware.class);
|
||||||
|
if (oidc.isEmpty() || auth.isEmpty()) return null;
|
||||||
|
|
||||||
OidcMiddleware oidcMw = oidc.get();
|
OidcCredentialSource source = oidc.get();
|
||||||
|
AuthMiddleware authMw = auth.get();
|
||||||
String resourceMetadataPath = "/.well-known/oauth-protected-resource" + config.rootPath();
|
String resourceMetadataPath = "/.well-known/oauth-protected-resource" + config.rootPath();
|
||||||
String issuer = config.authorizationServerIssuer() != null
|
String issuer = config.authorizationServerIssuer() != null
|
||||||
? config.authorizationServerIssuer() : oidcMw.issuer();
|
? config.authorizationServerIssuer() : source.issuer();
|
||||||
Function<Request, String> resourceId = req -> config.resourceIdentifier() != null
|
Function<Request, String> resourceId = req -> config.resourceIdentifier() != null
|
||||||
? config.resourceIdentifier()
|
? config.resourceIdentifier()
|
||||||
: OidcMiddleware.selfOrigin(req, oidcMw.selfScheme()) + config.rootPath();
|
: OidcCredentialSource.selfOrigin(req, source.selfScheme()) + config.rootPath();
|
||||||
|
|
||||||
Middleware protect = oidcMw.protect(resourceMetadataPath);
|
Middleware protect = authMw.withSource(source.withResourceMetadata(resourceMetadataPath)).protect();
|
||||||
Middleware secured = Middleware.of(protect, audienceGuard(resourceId));
|
Middleware secured = Middleware.of(protect, audienceGuard(resourceId));
|
||||||
return new Resolved(secured, issuer, oidcMw.rolesClaimPath(), resourceId);
|
return new Resolved(secured, issuer, authMw.rolesClaimPath(), resourceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -73,7 +79,7 @@ final class McpOidcIntegration {
|
|||||||
*/
|
*/
|
||||||
private static Middleware audienceGuard(Function<Request, String> resourceIdentifier) {
|
private static Middleware audienceGuard(Function<Request, String> resourceIdentifier) {
|
||||||
return next -> (req, res) -> {
|
return next -> (req, res) -> {
|
||||||
Map<String, Object> claims = ClaimsHolder.get();
|
Map<String, Object> claims = ClaimsHolder.map();
|
||||||
String expected = resourceIdentifier.apply(req);
|
String expected = resourceIdentifier.apply(req);
|
||||||
if (claims != null && !audienceMatches(claims.get("aud"), expected)) {
|
if (claims != null && !audienceMatches(claims.get("aud"), expected)) {
|
||||||
log.warn("[flash-ext-mcp] Rejecting token (RFC 8707): aud={} does not include expected " +
|
log.warn("[flash-ext-mcp] Rejecting token (RFC 8707): aud={} does not include expected " +
|
||||||
@@ -100,7 +106,7 @@ final class McpOidcIntegration {
|
|||||||
* annotations. Called once per tool at boot ({@link McpRegistry#scan}), never on the
|
* 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},
|
* 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
|
* 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.
|
* nothing beyond what {@link Claims#hasRole}/{@link Claims#hasScope} already do.
|
||||||
*
|
*
|
||||||
* <p>Fails fast at boot, not silently at request time, for the two ways this can be
|
* <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
|
* misconfigured: the annotation present without OAuth2 actually protecting this MCP server
|
||||||
@@ -117,7 +123,7 @@ final class McpOidcIntegration {
|
|||||||
if (!oidcActive) {
|
if (!oidcActive) {
|
||||||
throw new IllegalStateException(
|
throw new IllegalStateException(
|
||||||
"MCP tool \"" + toolClass.getSimpleName() + "\" declares @Authenticated/@RolesAllowed/" +
|
"MCP tool \"" + toolClass.getSimpleName() + "\" declares @Authenticated/@RolesAllowed/" +
|
||||||
"@ScopesAllowed, but this MCP server has no active OAuth2 protection — flash-ext-oidc " +
|
"@ScopesAllowed, but this MCP server has no active OAuth2 protection — flash-ext-auth-oidc " +
|
||||||
"is not installed for it, or McpSecurity is NONE. These annotations require " +
|
"is not installed for it, or McpSecurity is NONE. These annotations require " +
|
||||||
"McpSecurity.AUTO/REQUIRED with an OidcExtension installed; install one, or remove the " +
|
"McpSecurity.AUTO/REQUIRED with an OidcExtension installed; install one, or remove the " +
|
||||||
"annotation from " + toolClass.getSimpleName() + ".");
|
"annotation from " + toolClass.getSimpleName() + ".");
|
||||||
@@ -136,7 +142,7 @@ final class McpOidcIntegration {
|
|||||||
ScopesAllowed.Match scopeMatch = scopes != null ? scopes.match() : ScopesAllowed.Match.ALL;
|
ScopesAllowed.Match scopeMatch = scopes != null ? scopes.match() : ScopesAllowed.Match.ALL;
|
||||||
|
|
||||||
Supplier<String> check = () -> {
|
Supplier<String> check = () -> {
|
||||||
OidcUser user = ClaimsHolder.user();
|
Claims user = ClaimsHolder.current();
|
||||||
if (user == null) return "not authenticated";
|
if (user == null) return "not authenticated";
|
||||||
if (requiredRoles.length > 0 && !hasAnyRole(user, rolesClaimPath, requiredRoles))
|
if (requiredRoles.length > 0 && !hasAnyRole(user, rolesClaimPath, requiredRoles))
|
||||||
return "missing required role (any of: " + String.join(", ", requiredRoles) + ")";
|
return "missing required role (any of: " + String.join(", ", requiredRoles) + ")";
|
||||||
@@ -147,12 +153,12 @@ final class McpOidcIntegration {
|
|||||||
return new McpAuthPolicy(check);
|
return new McpAuthPolicy(check);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean hasAnyRole(OidcUser user, String claimPath, String[] roles) {
|
private static boolean hasAnyRole(Claims user, String claimPath, String[] roles) {
|
||||||
for (String role : roles) if (user.hasRole(claimPath, role)) return true;
|
for (String role : roles) if (user.hasRole(claimPath, role)) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean hasScopes(OidcUser user, String[] scopes, ScopesAllowed.Match match) {
|
private static boolean hasScopes(Claims user, String[] scopes, ScopesAllowed.Match match) {
|
||||||
if (match == ScopesAllowed.Match.ALL) {
|
if (match == ScopesAllowed.Match.ALL) {
|
||||||
for (String scope : scopes) if (!user.hasScope(scope)) return false;
|
for (String scope : scopes) if (!user.hasScope(scope)) return false;
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
+1
-1
@@ -175,7 +175,7 @@ final class McpRegistry {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Isolated the same way {@link McpOidcIntegration#resolve} is — {@code
|
* Isolated the same way {@link McpOidcIntegration#resolve} is — {@code
|
||||||
* NoClassDefFoundError} here means {@code flash-ext-oidc} genuinely isn't on the runtime
|
* NoClassDefFoundError} here means {@code flash-ext-auth-oidc} genuinely isn't on the runtime
|
||||||
* classpath, in which case a tool couldn't have been compiled against
|
* 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
|
* {@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
|
* check (and nothing lost: {@code oidcActive} is only ever {@code true} once {@link
|
||||||
|
|||||||
+4
-4
@@ -2,16 +2,16 @@ package dev.relism.flash.ext.mcp;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* OAuth2 requirement policy for the MCP endpoint, resolved against whether
|
* OAuth2 requirement policy for the MCP endpoint, resolved against whether
|
||||||
* {@code flash-ext-oidc} is installed ({@code ctx.find(OidcMiddleware.class)}).
|
* {@code flash-ext-auth-oidc} is installed ({@code ctx.find(OidcMiddleware.class)}).
|
||||||
*/
|
*/
|
||||||
public enum McpSecurity {
|
public enum McpSecurity {
|
||||||
|
|
||||||
/** Fail fast at boot if {@code flash-ext-oidc} is not installed — never expose an unprotected MCP endpoint. */
|
/** Fail fast at boot if {@code flash-ext-auth-oidc} is not installed — never expose an unprotected MCP endpoint. */
|
||||||
REQUIRED,
|
REQUIRED,
|
||||||
|
|
||||||
/** Protect the endpoint if {@code flash-ext-oidc} is installed; otherwise run unprotected and log a warning. */
|
/** Protect the endpoint if {@code flash-ext-auth-oidc} is installed; otherwise run unprotected and log a warning. */
|
||||||
AUTO,
|
AUTO,
|
||||||
|
|
||||||
/** Never protect the endpoint, even if {@code flash-ext-oidc} is installed elsewhere in the app. */
|
/** Never protect the endpoint, even if {@code flash-ext-auth-oidc} is installed elsewhere in the app. */
|
||||||
NONE
|
NONE
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -38,7 +38,7 @@ final class McpTransportGuards {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Safety net around the whole MCP route: translates {@link HttpException} (thrown by
|
* 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-auth-oidc}'s middleware) into a proper HTTP status
|
||||||
* directly, instead of relying on the app's global exception handler — which defaults to a
|
* 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
|
* generic 500 for every exception type unless the app owner overrides it (see
|
||||||
* {@code AbstractRouter}'s default {@code exceptionHandler}). Keeps the MCP endpoint
|
* {@code AbstractRouter}'s default {@code exceptionHandler}). Keeps the MCP endpoint
|
||||||
|
|||||||
+2
-2
@@ -26,7 +26,7 @@ import java.util.UUID;
|
|||||||
/**
|
/**
|
||||||
* Minimal, self-contained fake OIDC provider for tests: real discovery document, real JWKS
|
* 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
|
* 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.
|
* framework. Exercises {@code flash-ext-auth-oidc}'s actual discovery + JWKS + JWT validation path.
|
||||||
*/
|
*/
|
||||||
final class FakeOidcProvider implements AutoCloseable {
|
final class FakeOidcProvider implements AutoCloseable {
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ final class FakeOidcProvider implements AutoCloseable {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Same as {@link #signToken(String, String)}, plus a {@code scope} claim (space-delimited,
|
* 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
|
* matching {@link dev.relism.flash.ext.auth.Claims#hasScope}'s default claim path) and a
|
||||||
* Keycloak-shaped {@code realm_access.roles} claim (matching {@code McpConfig}'s default
|
* Keycloak-shaped {@code realm_access.roles} claim (matching {@code McpConfig}'s default
|
||||||
* {@code rolesClaimPath}) when {@code roles} is non-empty.
|
* {@code rolesClaimPath}) when {@code roles} is non-empty.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -16,7 +16,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
|
|||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exercises the actual OAuth2 resolution rules against a real {@code flash-ext-oidc}
|
* Exercises the actual OAuth2 resolution rules against a real {@code flash-ext-auth-oidc}
|
||||||
* installation backed by {@link FakeOidcProvider} — real discovery, real JWKS, real RS256
|
* installation backed by {@link FakeOidcProvider} — real discovery, real JWKS, real RS256
|
||||||
* tokens — plus the fail-fast/degrade behavior when oidc is absent.
|
* tokens — plus the fail-fast/degrade behavior when oidc is absent.
|
||||||
*
|
*
|
||||||
|
|||||||
+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.Tool;
|
||||||
import dev.relism.flash.ext.mcp.ToolArguments;
|
import dev.relism.flash.ext.mcp.ToolArguments;
|
||||||
import dev.relism.flash.ext.mcp.ToolResponse;
|
import dev.relism.flash.ext.mcp.ToolResponse;
|
||||||
import dev.relism.flash.ext.oidc.Authenticated;
|
import dev.relism.flash.ext.auth.Authenticated;
|
||||||
|
|
||||||
/** Deliberately misconfigured fixture: bare @Authenticated has no effect on an McpTool — see
|
/** 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. */
|
* McpOidcIntegration#compileToolPolicy. Boot must fail with a clear message, not silently no-op. */
|
||||||
|
|||||||
+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.Tool;
|
||||||
import dev.relism.flash.ext.mcp.ToolArguments;
|
import dev.relism.flash.ext.mcp.ToolArguments;
|
||||||
import dev.relism.flash.ext.mcp.ToolResponse;
|
import dev.relism.flash.ext.mcp.ToolResponse;
|
||||||
import dev.relism.flash.ext.oidc.RolesAllowed;
|
import dev.relism.flash.ext.auth.RolesAllowed;
|
||||||
|
|
||||||
@Tool(name = "admin_only", description = "Only callable with the admin role")
|
@Tool(name = "admin_only", description = "Only callable with the admin role")
|
||||||
@RolesAllowed("admin")
|
@RolesAllowed("admin")
|
||||||
|
|||||||
+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.Tool;
|
||||||
import dev.relism.flash.ext.mcp.ToolArguments;
|
import dev.relism.flash.ext.mcp.ToolArguments;
|
||||||
import dev.relism.flash.ext.mcp.ToolResponse;
|
import dev.relism.flash.ext.mcp.ToolResponse;
|
||||||
import dev.relism.flash.ext.oidc.ScopesAllowed;
|
import dev.relism.flash.ext.auth.ScopesAllowed;
|
||||||
|
|
||||||
@Tool(name = "write_only", description = "Only callable with the write scope")
|
@Tool(name = "write_only", description = "Only callable with the write scope")
|
||||||
@ScopesAllowed("write")
|
@ScopesAllowed("write")
|
||||||
|
|||||||
-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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-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); }
|
|
||||||
}
|
|
||||||
-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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-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);
|
|
||||||
}
|
|
||||||
-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\""));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -123,7 +123,7 @@ Merge policy:
|
|||||||
|
|
||||||
## OIDC interop
|
## OIDC interop
|
||||||
|
|
||||||
When `flash-ext-oidc` is installed, OpenAPI integrates automatically:
|
When `flash-ext-auth-oidc` is installed, OpenAPI integrates automatically:
|
||||||
|
|
||||||
- security scheme under `components.securitySchemes`
|
- security scheme under `components.securitySchemes`
|
||||||
- per-operation `security`
|
- per-operation `security`
|
||||||
|
|||||||
@@ -16,7 +16,8 @@
|
|||||||
<modules>
|
<modules>
|
||||||
<module>flash-ext-jackson</module>
|
<module>flash-ext-jackson</module>
|
||||||
<module>flash-ext-openapi</module>
|
<module>flash-ext-openapi</module>
|
||||||
<module>flash-ext-oidc</module>
|
<module>flash-ext-auth-core</module>
|
||||||
|
<module>flash-ext-auth-oidc</module>
|
||||||
<module>flash-ext-routeviewer</module>
|
<module>flash-ext-routeviewer</module>
|
||||||
<module>flash-ext-view-core</module>
|
<module>flash-ext-view-core</module>
|
||||||
<module>flash-ext-view-jte</module>
|
<module>flash-ext-view-jte</module>
|
||||||
@@ -45,6 +46,11 @@
|
|||||||
<artifactId>flash-ext-scheduler</artifactId>
|
<artifactId>flash-ext-scheduler</artifactId>
|
||||||
<version>${project.version}</version>
|
<version>${project.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>dev.relism</groupId>
|
||||||
|
<artifactId>flash-ext-auth-core</artifactId>
|
||||||
|
<version>${project.version}</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>dev.relism</groupId>
|
<groupId>dev.relism</groupId>
|
||||||
<artifactId>flash-ext-cache-core</artifactId>
|
<artifactId>flash-ext-cache-core</artifactId>
|
||||||
|
|||||||
@@ -106,7 +106,7 @@
|
|||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>dev.relism</groupId>
|
<groupId>dev.relism</groupId>
|
||||||
<artifactId>flash-ext-oidc</artifactId>
|
<artifactId>flash-ext-auth-oidc</artifactId>
|
||||||
<version>${project.version}</version>
|
<version>${project.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
|
|||||||
Reference in New Issue
Block a user