refactor(ext-auth): generic sessions, shared annotation wiring, rename to flash-ext-auth-oidc
AuthMiddleware.install(ctx, config, source) now owns the annotation processor and the flash.auth.policy key, so a second credential source gets annotation-driven authorization without copying the wiring. The key is public: an extension that contributes middleware can order itself around authentication. OidcSession becomes Session in auth-core, carrying claims, an expiry and an opaque attribute map. OpenID Connect keeps its access, id and refresh tokens in that map under its own keys, so renewal stays its business and core has no OAuth2 vocabulary in it. isAccessTokenExpired() becomes isExpired(), with the 30s eager-renewal window it always had and now a test for it. flash-ext-oidc is renamed flash-ext-auth-oidc, matching cache-core/cache-caffeine and data-core/data-hibernate.
This commit is contained in:
@@ -0,0 +1,462 @@
|
||||
# flash-ext-auth-oidc
|
||||
|
||||
Full OIDC Authorization Code + PKCE flow for the Flash HTTP server.
|
||||
Supports Keycloak, Authelia, Auth0, Google, and any RFC 8414-compliant provider.
|
||||
|
||||
Standards alignment focuses on OIDC Core + OAuth2 bearer APIs while preserving Flash's
|
||||
hot-path model (middleware compiled at mount time, no heavy runtime work).
|
||||
|
||||
## What it provides
|
||||
|
||||
| Component | Description |
|
||||
|---|---|
|
||||
| `GET {prefix}/login` | Starts the OIDC flow: builds the authorization URL with PKCE + state, redirects |
|
||||
| `GET {prefix}/callback` | Exchanges the code, validates the ID token, creates a session, redirects |
|
||||
| `POST {prefix}/logout` | Invalidates the session, redirects to the provider's `end_session_endpoint` |
|
||||
| `@Authenticated` | Annotation: protects a class-based handler (redirects browsers, 401 for API clients) |
|
||||
| `@RolesAllowed(...)` | Annotation: protects with role check (OR semantics) |
|
||||
| `@ScopesAllowed(...)` | Annotation: protects with scope check (`ALL` default, `ANY` optional) |
|
||||
| `OidcMiddleware` | Programmatic middleware for lambda routes |
|
||||
| `ClaimsHolder` / `OidcUser` | Thread-local user info accessible from any protected handler |
|
||||
| `JwtValidator` | JWKS-backed JWT validator (PKCE + key rotation + caching) |
|
||||
|
||||
## Dependencies
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-auth-oidc</artifactId>
|
||||
<version>1.0-SNAPSHOT</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
Transitive: `nimbus-jose-jwt`, `json-smart`.
|
||||
Optional: `flash-ext-openapi` — if present, OIDC security schemes are added to the OpenAPI spec automatically.
|
||||
|
||||
## Installation
|
||||
|
||||
```java
|
||||
FlashApp.create(8080)
|
||||
.install(new JacksonExtension())
|
||||
.install(new OpenApiExtension(...)) // optional — enables Swagger security
|
||||
.install(new OidcExtension(
|
||||
OidcConfig.builder(
|
||||
"https://idp.example.com",
|
||||
"my-client", "my-secret", "/auth/callback")
|
||||
.build()
|
||||
))
|
||||
.start();
|
||||
```
|
||||
|
||||
Install order is irrelevant. The two-phase extension model guarantees all services
|
||||
(including `OpenApiSecurityRegistry` from `flash-ext-openapi`) are registered before
|
||||
any extension's routes phase runs.
|
||||
|
||||
### Keycloak shortcut
|
||||
|
||||
```java
|
||||
OidcConfig.keycloak(
|
||||
"https://keycloak.example.com", // server URL (no realm)
|
||||
"myrealm", // realm
|
||||
"my-client", "my-secret", // client credentials
|
||||
"/auth/callback") // redirect URI (server-relative)
|
||||
.https() // behind TLS
|
||||
.build()
|
||||
```
|
||||
|
||||
`keycloak()` pre-sets `rolesClaimPath("realm_access.roles")` and constructs the issuer as
|
||||
`{serverUrl}/realms/{realm}`.
|
||||
|
||||
### Authelia / generic IdP
|
||||
|
||||
```java
|
||||
OidcConfig.builder("https://auth.example.com", "my-client", "secret", "/auth/callback")
|
||||
.rolesClaimPath("groups")
|
||||
.build()
|
||||
```
|
||||
|
||||
## OidcConfig reference
|
||||
|
||||
### Required fields
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `issuer` | Provider base URL — also used for OIDC discovery |
|
||||
| `clientId` | OAuth2 client ID |
|
||||
| `clientSecret` | OAuth2 client secret |
|
||||
| `redirectUri` | Callback URI; server-relative paths (starting with `/`) are resolved at request time |
|
||||
|
||||
### Builder options
|
||||
|
||||
| Method | Default | Description |
|
||||
|---|---|---|
|
||||
| `.scopes("openid profile email")` | `"openid profile email"` | Space-separated requested scopes |
|
||||
| `.routePrefix("/auth")` | `"/auth"` | Prefix for login/callback/logout routes |
|
||||
| `.selfScheme("http")` | `"http"` | Scheme used when resolving server-relative redirect URIs |
|
||||
| `.https()` | — | Shorthand for `.selfScheme("https")` |
|
||||
| `.rolesClaimPath("realm_access.roles")` | `"realm_access.roles"` | Dot-path to the roles array in JWT claims |
|
||||
| `.scopeClaimPaths("scope,scp")` | `"scope,scp"` | Comma-separated claim paths used to resolve OAuth scopes |
|
||||
| `.algorithm("RS256")` | `"RS256"` | JWS algorithm for token validation |
|
||||
| `.postLogoutRedirectUri("/")` | `"/"` | Where to redirect after logout |
|
||||
| `.sessionStore(store)` | `InMemoryOidcSessionStore` | Custom session store (see below) |
|
||||
| `.clientAuthMethod(ClientAuthMethod.POST)` | `POST` | `POST` = credentials in body; `BASIC` = `Authorization: Basic` |
|
||||
| `.insecureTls()` | `false` | Disables TLS certificate verification — **development only** |
|
||||
| `.schemeName("myscheme")` | derived from issuer | OpenAPI security scheme name |
|
||||
|
||||
### Environment variables (`OidcConfig.fromEnv()`)
|
||||
|
||||
```
|
||||
OIDC_ISSUER required
|
||||
OIDC_CLIENT_ID required
|
||||
OIDC_CLIENT_SECRET required
|
||||
OIDC_REDIRECT_URI required e.g. /auth/callback
|
||||
OIDC_SCOPES default: openid profile email
|
||||
OIDC_ROUTE_PREFIX default: /auth
|
||||
OIDC_SELF_SCHEME default: http
|
||||
OIDC_ROLES_CLAIM default: realm_access.roles
|
||||
OIDC_SCOPE_CLAIMS default: scope,scp
|
||||
OIDC_ALGORITHM default: RS256
|
||||
OIDC_POST_LOGOUT_REDIRECT default: /
|
||||
OIDC_CLIENT_AUTH_METHOD default: POST
|
||||
```
|
||||
|
||||
## Protecting routes
|
||||
|
||||
### Class-based handlers (annotations)
|
||||
|
||||
```java
|
||||
@Route(method = HttpMethod.GET, path = "/me")
|
||||
@Authenticated
|
||||
public class MePage extends JacksonHandler {
|
||||
@Override
|
||||
public Object handle(Request req, Response res) {
|
||||
OidcUser u = ClaimsHolder.user();
|
||||
return json(res, Map.of("sub", u.sub(), "email", u.email()));
|
||||
}
|
||||
}
|
||||
|
||||
@Route(method = HttpMethod.GET, path = "/admin")
|
||||
@RolesAllowed("admin") // OR semantics: "admin" OR "superuser"
|
||||
// @RolesAllowed({"admin", "superuser"})
|
||||
public class AdminPage extends JacksonHandler { ... }
|
||||
|
||||
@Route(method = HttpMethod.POST, path = "/orders")
|
||||
@ScopesAllowed("orders:write") // default = ALL semantics
|
||||
public class CreateOrder extends JacksonHandler { ... }
|
||||
|
||||
@Route(method = HttpMethod.POST, path = "/payments")
|
||||
@ScopesAllowed(value = {"payments:write", "payments:admin"}, match = ScopesAllowed.Match.ANY)
|
||||
public class PayOrder extends JacksonHandler { ... }
|
||||
|
||||
@Route(method = HttpMethod.DELETE, path = "/admin/users/{id}")
|
||||
@RolesAllowed("admin")
|
||||
@ScopesAllowed("users:delete") // combined with AND semantics
|
||||
public class DeleteUser extends JacksonHandler { ... }
|
||||
```
|
||||
|
||||
The middleware is injected automatically by the annotation processor — no manual wiring needed.
|
||||
|
||||
Annotation composition rules:
|
||||
|
||||
- `@Authenticated` requires auth only
|
||||
- `@RolesAllowed` implies authentication + role OR-check
|
||||
- `@ScopesAllowed` implies authentication + scope check (`ALL`/`ANY`)
|
||||
- combining `@RolesAllowed` + `@ScopesAllowed` uses AND semantics
|
||||
- `@Authenticated(optional = true)` cannot be combined with role/scope constraints
|
||||
|
||||
### Lambda routes (manual middleware)
|
||||
|
||||
For lambda routes, pass the middleware as a varargs argument. Retrieve `OidcMiddleware`
|
||||
from the context inside another extension's `routes()` phase, or after `start()`:
|
||||
|
||||
```java
|
||||
OidcMiddleware oidc = app.ctx().require(OidcMiddleware.class);
|
||||
|
||||
// Authentication only
|
||||
app.get("/api/me", (req, res) -> {
|
||||
OidcUser u = ClaimsHolder.user(); // never null here
|
||||
return Map.of("sub", u.sub(), "email", u.email());
|
||||
}, oidc.protect());
|
||||
|
||||
// Authentication + role check
|
||||
app.delete("/api/admin/users/{id}", (req, res) -> {
|
||||
OidcUser u = ClaimsHolder.user();
|
||||
// ...
|
||||
}, oidc.requireRole("admin"));
|
||||
|
||||
// Multiple roles (OR): passes if user holds any one of them
|
||||
app.get("/api/reports", (req, res) -> { ... }, oidc.requireRole("admin", "reports-viewer"));
|
||||
|
||||
// Require all listed scopes
|
||||
app.post("/api/orders", (req, res) -> { ... }, oidc.requireScopes("orders:write", "payments:write"));
|
||||
|
||||
// Require at least one listed scope
|
||||
app.post("/api/payments", (req, res) -> { ... }, oidc.requireAnyScope("payments:write", "payments:admin"));
|
||||
```
|
||||
|
||||
`oidc.protect()` / `oidc.requireRole(...)` / `oidc.requireScopes(...)` return a `Middleware` — a composable
|
||||
`Handler → Handler` wrapper. Flash applies middleware right-to-left so the OIDC check
|
||||
runs before your handler.
|
||||
|
||||
## Accessing the authenticated user
|
||||
|
||||
`ClaimsHolder` holds the JWT claims for the current request in a `ThreadLocal`.
|
||||
It is populated by the OIDC middleware before your handler runs and cleared in the
|
||||
`finally` block afterward. It is safe with virtual threads (each request gets its
|
||||
own virtual thread, so `ThreadLocal` values are naturally isolated).
|
||||
|
||||
### OidcUser (preferred)
|
||||
|
||||
```java
|
||||
OidcUser u = ClaimsHolder.user(); // never null inside a protected handler
|
||||
|
||||
String sub = u.sub(); // unique user ID
|
||||
String email = u.email();
|
||||
String username = u.username(); // preferred_username
|
||||
String name = u.name(); // full display name
|
||||
|
||||
// Roles — pass the dot-path matching your provider's claim structure
|
||||
List<String> roles = u.roles("realm_access.roles"); // Keycloak realm roles
|
||||
List<String> clientRoles = u.roles("resource_access.my-client.roles"); // Keycloak client roles
|
||||
List<String> groups = u.roles("groups"); // Authelia
|
||||
|
||||
boolean isAdmin = u.hasRole("realm_access.roles", "admin");
|
||||
|
||||
// Scopes (OIDC/OAuth2 generic): checks "scope" then "scp"
|
||||
List<String> scopes = u.scopes();
|
||||
boolean canWrite = u.hasScope("orders:write");
|
||||
|
||||
// Custom claim path resolution (for provider-specific payloads)
|
||||
List<String> customScopes = u.scopes("scope,scp,permissions.scopes");
|
||||
boolean canApprove = u.hasScope("permissions.scopes", "orders:approve");
|
||||
|
||||
// Arbitrary claim
|
||||
String locale = (String) u.claim("locale");
|
||||
Long exp = u.claim("exp", Long.class);
|
||||
|
||||
// Full raw map (escape hatch)
|
||||
Map<String, Object> all = u.claims();
|
||||
```
|
||||
|
||||
### Raw access (escape hatch)
|
||||
|
||||
```java
|
||||
Map<String, Object> claims = ClaimsHolder.get();
|
||||
String email = ClaimsHolder.claim("email");
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
The middleware adds negligible overhead on the hot path for authenticated requests:
|
||||
|
||||
| Step | Cost |
|
||||
|---|---|
|
||||
| `Authorization` header check | `O(1)` map lookup |
|
||||
| Cookie parse | `O(cookie_length)` single pass scan |
|
||||
| Session lookup | `O(1)` `ConcurrentHashMap.get()` |
|
||||
| Token expiry check | `O(1)` `Instant` comparison |
|
||||
| `ClaimsHolder.set()` | `O(1)` `ThreadLocal.set()` |
|
||||
|
||||
No network calls, no cryptography, no JSON parsing on the happy path (valid session).
|
||||
JWKS key fetching only happens for Bearer token validation and is cached + rate-limited by
|
||||
Nimbus's `JWKSourceBuilder`. Silent token refresh only triggers when the access token expires.
|
||||
|
||||
Role/scope claim paths are compiled once during middleware construction (mount time), not per request.
|
||||
|
||||
## Authentication flow details
|
||||
|
||||
On each request the middleware resolves credentials in this order:
|
||||
|
||||
1. **Bearer token** (`Authorization: Bearer <jwt>`) — validated against JWKS.
|
||||
2. **Session cookie** (`oidc_session`) — looked up in the session store; transparently
|
||||
refreshed if the access token is expired (silent refresh via refresh token).
|
||||
3. **No valid credentials**:
|
||||
- Browser clients (no `Accept: application/json`) → redirect to `{prefix}/login?redirect={path}`
|
||||
- API clients → `401 Unauthorized`
|
||||
|
||||
### API error semantics (RFC 6750)
|
||||
|
||||
For API clients (`Accept: application/json`) the middleware includes `WWW-Authenticate`:
|
||||
|
||||
- missing credentials: `Bearer realm="<schemeName>"`
|
||||
- invalid bearer token: `Bearer realm="<schemeName>", error="invalid_token"`
|
||||
- insufficient scopes: `Bearer realm="<schemeName>", error="insufficient_scope", scope="<required scopes>"`
|
||||
|
||||
This enables interoperable client-side handling and proper OAuth2 challenge semantics.
|
||||
|
||||
### Token validation (OIDC Core §3.1.3.7)
|
||||
|
||||
| Check | Access token | ID token |
|
||||
|---|---|---|
|
||||
| Signature (JWKS) | yes | yes |
|
||||
| `iss` | yes | yes |
|
||||
| `aud` = clientId | no (varies by provider) | yes |
|
||||
| `exp`, `iat`, `sub` | yes | yes |
|
||||
| `nonce` | — | yes |
|
||||
|
||||
JWKS keys are cached, rate-limited, and retried on cache-miss (handles key rotation).
|
||||
|
||||
### Claim merge strategy
|
||||
|
||||
At callback time the extension merges access token + ID token claims:
|
||||
|
||||
- Access token claims first (contains provider-specific data like `realm_access.roles`)
|
||||
- ID token claims override (contains verified identity: `sub`, `email`, `name`, …)
|
||||
|
||||
This is provider-agnostic: authorization claims live in the AT per RFC 9068,
|
||||
identity claims live in the IT per OIDC Core.
|
||||
|
||||
## Standards & compliance notes
|
||||
|
||||
This extension is designed to be compliant with the most relevant OIDC/OAuth2 RFCs:
|
||||
|
||||
- RFC 8414 (Authorization Server Metadata): discovery via `/.well-known/openid-configuration`
|
||||
- OpenID Connect Core 1.0: Authorization Code flow + PKCE + `nonce` validation on ID token
|
||||
- RFC 7636 (PKCE): S256 challenge/verifier flow
|
||||
- RFC 6750 (Bearer Token Usage): `WWW-Authenticate` challenges with standard error codes
|
||||
- RFC 9068 (JWT Profile for Access Tokens): JWT bearer access-token validation path
|
||||
- RFC 7519 / RFC 7517 / RFC 7515 family: JWT/JWK/JWS validation via Nimbus + JWKS caching/rotation
|
||||
|
||||
Provider interoperability details:
|
||||
|
||||
- scope extraction supports both standard forms: `scope` (space-delimited string) and `scp` (list/string)
|
||||
- roles remain configurable via `rolesClaimPath` (`realm_access.roles`, `groups`, etc.)
|
||||
- scope claim fallback chain is configurable via `scopeClaimPaths`
|
||||
|
||||
## Testing scopes with Keycloak
|
||||
|
||||
Quick path to test `@ScopesAllowed` end-to-end:
|
||||
|
||||
1. **Create a client scope**
|
||||
- Realm -> Client scopes -> Create
|
||||
- Name: `orders:write` (or any scope name you want to enforce)
|
||||
2. **Attach it to your client**
|
||||
- Clients -> `<your-client>` -> Client scopes
|
||||
- Add the scope as `Default` (always in token) or `Optional` (requested via `scope` param)
|
||||
3. **Ensure scope mapper reaches the token**
|
||||
- For most Keycloak setups this is automatic via built-in `microprofile-jwt`/scope mappers
|
||||
- Verify the access token contains either `scope` string or `scp` list
|
||||
4. **Request the scope in Flash config**
|
||||
- Include it in `OidcConfig.scopes(...)`, e.g. `"openid profile email orders:write"`
|
||||
5. **Protect a handler**
|
||||
- `@ScopesAllowed("orders:write")` on class-based handlers
|
||||
- or `oidc.requireScopes("orders:write")` for lambda routes
|
||||
6. **Verify behavior**
|
||||
- token with scope -> 200
|
||||
- token without scope -> 403 + `WWW-Authenticate: ... insufficient_scope`
|
||||
|
||||
Useful token inspection flow while testing:
|
||||
|
||||
- Obtain a token from Keycloak
|
||||
- Decode payload (`jwt.io` or local tool)
|
||||
- check `scope` / `scp` claims
|
||||
- call your protected endpoint and inspect status + `WWW-Authenticate`
|
||||
|
||||
## Session store
|
||||
|
||||
The default `InMemoryOidcSessionStore` is sufficient for single-instance deployments.
|
||||
For clustered deployments, implement `OidcSessionStore`:
|
||||
|
||||
```java
|
||||
public interface OidcSessionStore {
|
||||
void save(OidcSession session);
|
||||
Optional<OidcSession> find(String sessionId);
|
||||
void delete(String sessionId);
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
OidcConfig.builder(...)
|
||||
.sessionStore(new RedisOidcSessionStore(redisClient))
|
||||
.build()
|
||||
```
|
||||
|
||||
`OidcSession` fields: `id`, `accessToken`, `idToken`, `refreshToken`, `expiresAt` (`Instant`), `claims` (merged map).
|
||||
|
||||
## Logout
|
||||
|
||||
Add a logout button anywhere in your UI — a `<form>` is sufficient (no JavaScript needed):
|
||||
|
||||
```html
|
||||
<form method="POST" action="/auth/logout">
|
||||
<button type="submit">Logout</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
The `POST {prefix}/logout` handler:
|
||||
1. Reads the `oidc_session` cookie, looks up the session, retrieves the `id_token`.
|
||||
2. Deletes the local session and clears the cookie (`Max-Age=0`).
|
||||
3. If the provider has an `end_session_endpoint` (standard IdPs do), redirects there with
|
||||
`?id_token_hint=<idToken>&post_logout_redirect_uri=<postLogoutRedirectUri>` — this logs
|
||||
the user out of the IdP as well.
|
||||
4. Otherwise redirects to `postLogoutRedirectUri` (default: `/`).
|
||||
|
||||
## Bearer token (API clients)
|
||||
|
||||
For API-to-API or SPA-to-API calls, pass a Bearer access token directly. The middleware
|
||||
validates the JWT signature against JWKS and extracts the claims — no session involved:
|
||||
|
||||
```
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
The token must be a JWT (opaque tokens are not supported). Claims are available via
|
||||
`ClaimsHolder.user()` as usual.
|
||||
|
||||
## Multi-tenant
|
||||
|
||||
Multiple OIDC providers on one server — each `OidcExtension` instance is fully independent
|
||||
(its own PKCE state store, session store, validator, and middleware):
|
||||
|
||||
```java
|
||||
OidcConfig tenantA = OidcConfig.builder("https://idp/realms/a", "clientA", "secretA", "/a/auth/callback")
|
||||
.routePrefix("/a/auth").schemeName("tenantA").build();
|
||||
|
||||
OidcConfig tenantB = OidcConfig.builder("https://idp/realms/b", "clientB", "secretB", "/b/auth/callback")
|
||||
.routePrefix("/b/auth").schemeName("tenantB").build();
|
||||
|
||||
app.install(new OidcExtension(tenantA))
|
||||
.install(new OidcExtension(tenantB));
|
||||
```
|
||||
|
||||
To reference a specific tenant's middleware on lambda routes, keep the extension instances
|
||||
and retrieve `OidcMiddleware` from context after `start()`:
|
||||
|
||||
```java
|
||||
OidcExtension extA = new OidcExtension(tenantA);
|
||||
OidcExtension extB = new OidcExtension(tenantB);
|
||||
|
||||
FlashApp app = FlashApp.create(8080)
|
||||
.install(extA)
|
||||
.install(extB)
|
||||
.start()
|
||||
.join(); // wait for bind
|
||||
|
||||
OidcMiddleware mwA = app.ctx().require(OidcMiddleware.class); // last registered = tenantB
|
||||
```
|
||||
|
||||
> **Note:** because both extensions register `OidcMiddleware.class` in the same context,
|
||||
> only the last one wins under that key. For multi-tenant setups, use distinct context
|
||||
> keys or provide middleware under a wrapper/alias type, or use lambda routes with explicit
|
||||
> middleware captured from the extension instance before `install()`.
|
||||
|
||||
Class-based handlers annotated with `@Authenticated` / `@RolesAllowed` get the last
|
||||
registered processor's middleware. For true multi-tenant class-based routing, install
|
||||
tenant-specific annotation processors with different annotations.
|
||||
|
||||
## OpenAPI integration
|
||||
|
||||
If `flash-ext-openapi` is on the classpath and installed (order irrelevant),
|
||||
the extension automatically:
|
||||
|
||||
- Adds a `components.securitySchemes` entry for the provider (OAuth2, authorizationCode flow)
|
||||
- Adds `security` requirements to every operation whose handler carries `@Authenticated`
|
||||
, `@RolesAllowed`, or `@ScopesAllowed`
|
||||
|
||||
No extra code needed. To customize the scheme name:
|
||||
|
||||
```java
|
||||
OidcConfig.builder(...).schemeName("keycloak").build()
|
||||
```
|
||||
|
||||
If `flash-ext-openapi` is absent the integration is silently skipped.
|
||||
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
<version>2.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-auth-oidc</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-auth-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-openapi</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.nimbusds</groupId>
|
||||
<artifactId>nimbus-jose-jwt</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>net.minidev</groupId>
|
||||
<artifactId>json-smart</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package dev.relism.flash.ext.oidc;
|
||||
|
||||
/**
|
||||
* OAuth2 client authentication method for the token endpoint (RFC 6749 §2.3).
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link #POST} — credentials sent as {@code client_id} / {@code client_secret}
|
||||
* form fields (default; most providers).</li>
|
||||
* <li>{@link #BASIC} — credentials sent as an {@code Authorization: Basic} header;
|
||||
* body contains only grant-specific parameters.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public enum ClientAuthMethod {
|
||||
/** {@code client_secret_post} — credentials in the request body. */
|
||||
POST,
|
||||
/** {@code client_secret_basic} — credentials in the {@code Authorization} header. */
|
||||
BASIC
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package dev.relism.flash.ext.oidc;
|
||||
|
||||
import net.minidev.json.JSONValue;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Fetches and parses the OIDC provider discovery document at
|
||||
* {@code {issuer}/.well-known/openid-configuration}.
|
||||
*/
|
||||
final class DiscoveryClient {
|
||||
|
||||
private DiscoveryClient() {}
|
||||
|
||||
static OidcProviderMetadata fetch(String issuer, HttpClient http) throws Exception {
|
||||
String url = issuer.endsWith("/")
|
||||
? issuer + ".well-known/openid-configuration"
|
||||
: issuer + "/.well-known/openid-configuration";
|
||||
|
||||
HttpResponse<String> resp = http.send(
|
||||
HttpRequest.newBuilder().uri(URI.create(url)).GET().build(),
|
||||
HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (resp.statusCode() != 200)
|
||||
throw new IllegalStateException(
|
||||
"OIDC discovery failed [" + resp.statusCode() + "]: " + url);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> doc = (Map<String, Object>) JSONValue.parse(resp.body());
|
||||
|
||||
return new OidcProviderMetadata(
|
||||
require(doc, "authorization_endpoint"),
|
||||
require(doc, "token_endpoint"),
|
||||
(String) doc.get("userinfo_endpoint"), // optional
|
||||
require(doc, "jwks_uri"),
|
||||
(String) doc.get("end_session_endpoint") // optional
|
||||
);
|
||||
}
|
||||
|
||||
private static String require(Map<String, Object> doc, String key) {
|
||||
Object v = doc.get(key);
|
||||
if (v == null) throw new IllegalStateException(
|
||||
"Discovery doc missing required field: " + key);
|
||||
return v.toString();
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package dev.relism.flash.ext.oidc;
|
||||
|
||||
import net.minidev.json.JSONValue;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Low-level JWT payload extraction — no signature or expiry validation.
|
||||
*
|
||||
* <p>Use only for tokens received directly from the provider over a trusted TLS
|
||||
* connection (e.g. {@code id_token} from the token endpoint). Bearer tokens on
|
||||
* incoming requests must go through {@link JwtValidator#validate(String)} instead.
|
||||
*/
|
||||
final class JwtUtils {
|
||||
|
||||
private JwtUtils() {}
|
||||
|
||||
/**
|
||||
* Base64URL-decodes the JWT payload and returns the claims as a map.
|
||||
* Signature, expiry, and issuer are NOT checked.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
static Map<String, Object> parseClaims(String jwt) {
|
||||
String[] parts = jwt.split("\\.");
|
||||
if (parts.length < 2) throw new IllegalArgumentException("Malformed JWT: " + jwt);
|
||||
// Pad to a multiple of 4 for the standard decoder
|
||||
String padded = parts[1];
|
||||
switch (padded.length() % 4) {
|
||||
case 2 -> padded += "==";
|
||||
case 3 -> padded += "=";
|
||||
}
|
||||
byte[] payload = Base64.getUrlDecoder().decode(padded);
|
||||
return (Map<String, Object>) JSONValue.parse(
|
||||
new String(payload, StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
package dev.relism.flash.ext.oidc;
|
||||
|
||||
import com.nimbusds.jose.JWSAlgorithm;
|
||||
import com.nimbusds.jose.jwk.source.JWKSource;
|
||||
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
|
||||
import com.nimbusds.jose.proc.JWSKeySelector;
|
||||
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
|
||||
import com.nimbusds.jose.proc.SecurityContext;
|
||||
import com.nimbusds.jose.util.Resource;
|
||||
import com.nimbusds.jose.util.ResourceRetriever;
|
||||
import com.nimbusds.jwt.JWTClaimsSet;
|
||||
import com.nimbusds.jwt.proc.ConfigurableJWTProcessor;
|
||||
import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier;
|
||||
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
|
||||
import dev.relism.flash.exceptions.HttpException;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URL;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Validates JWTs against a remote JWKS endpoint using Nimbus JOSE+JWT.
|
||||
*
|
||||
* <p>Two validation modes:
|
||||
* <ul>
|
||||
* <li>{@link #validate(String)} — access token bearer validation per request (hot path).
|
||||
* Checks signature, {@code iss}, {@code exp}, {@code iat}, {@code sub}.
|
||||
* Throws {@link HttpException} 401 so the middleware can short-circuit.</li>
|
||||
* <li>{@link #validateIdToken(String, String)} — ID token validation at callback time.
|
||||
* Checks signature, {@code iss}, {@code aud} == clientId, {@code exp}, {@code iat},
|
||||
* {@code sub}, and {@code nonce} (if provided).
|
||||
* Throws {@link OidcValidationException} (not 401 — it is a provider/protocol error).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>JWKS handling: the shared {@link JWKSource} uses caching + rate-limiting + automatic
|
||||
* retry-on-key-miss (key rotation). Both processors share the same source — one JWKS
|
||||
* fetch serves both token types.
|
||||
*/
|
||||
public class JwtValidator {
|
||||
|
||||
private final JWKSource<SecurityContext> jwkSource;
|
||||
private final ConfigurableJWTProcessor<SecurityContext> accessTokenProcessor;
|
||||
private final ConfigurableJWTProcessor<SecurityContext> idTokenProcessor;
|
||||
private final String algorithm;
|
||||
|
||||
/**
|
||||
* @param jwksUri JWKS endpoint URI
|
||||
* @param issuer Expected {@code iss} claim
|
||||
* @param clientId OAuth2 client ID — used as expected {@code aud} in ID tokens
|
||||
* @param algorithm JWS algorithm (e.g. {@code "RS256"})
|
||||
* @param http Shared {@link HttpClient} used for all JWKS fetches — already configured
|
||||
* with the correct TLS policy (trust-all or default trust store).
|
||||
*/
|
||||
public JwtValidator(String jwksUri, String issuer, String clientId,
|
||||
String algorithm, HttpClient http) {
|
||||
try {
|
||||
// Use the caller-supplied HttpClient for JWKS retrieval so that TLS policy
|
||||
// (insecureTls / custom trust store) is applied consistently everywhere.
|
||||
this.jwkSource = JWKSourceBuilder
|
||||
.create(new URL(jwksUri), httpRetriever(http))
|
||||
.cache(true)
|
||||
.rateLimited(true)
|
||||
.retrying(true)
|
||||
.build();
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to init JWKS source: " + jwksUri, e);
|
||||
}
|
||||
this.algorithm = algorithm;
|
||||
this.accessTokenProcessor = buildAccessTokenProcessor(jwkSource, issuer, algorithm);
|
||||
this.idTokenProcessor = buildIdTokenProcessor(jwkSource, issuer, clientId, algorithm);
|
||||
}
|
||||
|
||||
// -- Public API -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Validates a JWT access token (bearer on incoming request).
|
||||
* Returns claims on success; throws {@link HttpException} 401 on any failure.
|
||||
*/
|
||||
public Map<String, Object> validate(String token) {
|
||||
if (!isJwt(token)) throw HttpException.unauthorized(); // opaque token — can't validate
|
||||
try {
|
||||
return accessTokenProcessor.process(token, null).getClaims();
|
||||
} catch (Exception e) {
|
||||
throw HttpException.unauthorized();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an ID token received directly from the token endpoint.
|
||||
*
|
||||
* <p>Checks: signature (JWKS), {@code iss}, {@code aud} == clientId,
|
||||
* {@code exp}, {@code iat}, {@code sub}, and {@code nonce} if provided.
|
||||
*
|
||||
* @param idToken Raw ID token string
|
||||
* @param nonce Nonce sent in the authorization request; {@code null} to skip check
|
||||
* @throws OidcValidationException on any validation failure
|
||||
*/
|
||||
public Map<String, Object> validateIdToken(String idToken, String nonce) {
|
||||
try {
|
||||
Map<String, Object> claims = idTokenProcessor.process(idToken, null).getClaims();
|
||||
if (nonce != null && !nonce.equals(claims.get("nonce")))
|
||||
throw new OidcValidationException("ID token nonce mismatch", null);
|
||||
return claims;
|
||||
} catch (OidcValidationException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new OidcValidationException("ID token validation failed: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns {@code true} if {@code token} is a signed JWT (three dot-separated Base64URL parts).
|
||||
* Used to detect opaque access tokens before attempting JWKS validation.
|
||||
*/
|
||||
public static boolean isJwt(String token) {
|
||||
if (token == null || token.isBlank()) return false;
|
||||
int dots = 0;
|
||||
for (int i = 0; i < token.length(); i++) if (token.charAt(i) == '.') dots++;
|
||||
return dots == 2;
|
||||
}
|
||||
|
||||
// -- Processors -----------------------------------------------------------
|
||||
|
||||
private static ConfigurableJWTProcessor<SecurityContext> buildAccessTokenProcessor(
|
||||
JWKSource<SecurityContext> src, String issuer, String algorithm) {
|
||||
|
||||
ConfigurableJWTProcessor<SecurityContext> p = new DefaultJWTProcessor<>();
|
||||
p.setJWSKeySelector(keySelector(src, algorithm));
|
||||
// iss required; aud not enforced on ATs (varies by provider)
|
||||
if (issuer != null && !issuer.isBlank()) {
|
||||
p.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(
|
||||
new JWTClaimsSet.Builder().issuer(issuer).build(),
|
||||
Set.of("sub", "iat", "exp")));
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
private static ConfigurableJWTProcessor<SecurityContext> buildIdTokenProcessor(
|
||||
JWKSource<SecurityContext> src, String issuer, String clientId, String algorithm) {
|
||||
|
||||
ConfigurableJWTProcessor<SecurityContext> p = new DefaultJWTProcessor<>();
|
||||
p.setJWSKeySelector(keySelector(src, algorithm));
|
||||
// iss + aud = clientId strictly required (OIDC Core §3.1.3.7)
|
||||
JWTClaimsSet.Builder required = new JWTClaimsSet.Builder();
|
||||
if (issuer != null) required.issuer(issuer);
|
||||
if (clientId != null) required.audience(clientId);
|
||||
p.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(
|
||||
required.build(), Set.of("sub", "iat", "exp")));
|
||||
return p;
|
||||
}
|
||||
|
||||
private static JWSKeySelector<SecurityContext> keySelector(
|
||||
JWKSource<SecurityContext> src, String algorithm) {
|
||||
return new JWSVerificationKeySelector<>(JWSAlgorithm.parse(algorithm), src);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a {@link HttpClient} as a Nimbus {@link ResourceRetriever}.
|
||||
* The client already carries the correct TLS policy (trust-all or default),
|
||||
* so JWKS fetches honour the same SSL configuration as discovery and token requests.
|
||||
*/
|
||||
private static ResourceRetriever httpRetriever(HttpClient http) {
|
||||
return url -> {
|
||||
try {
|
||||
HttpResponse<String> resp = http.send(
|
||||
HttpRequest.newBuilder().uri(url.toURI()).GET().build(),
|
||||
HttpResponse.BodyHandlers.ofString());
|
||||
if (resp.statusCode() != 200)
|
||||
throw new IOException("JWKS fetch failed [" + resp.statusCode() + "]: " + url);
|
||||
String contentType = resp.headers()
|
||||
.firstValue("Content-Type").orElse("application/json");
|
||||
return new Resource(resp.body(), contentType);
|
||||
} catch (IOException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new IOException("JWKS retrieval error: " + e.getMessage(), e);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
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
|
||||
* {@link #builder(String, String, String, String)} or {@link #fromEnv()}.
|
||||
*
|
||||
* <p>Required fields: {@code issuer}, {@code clientId}, {@code clientSecret},
|
||||
* {@code redirectUri}. Everything else has a sensible default.
|
||||
*
|
||||
* <p>If {@code redirectUri} starts with {@code /} it is treated as server-relative:
|
||||
* the absolute URL is resolved at request time using {@link #selfScheme()} and the
|
||||
* incoming {@code Host} header. Use {@link Builder#https()} when behind TLS.
|
||||
*
|
||||
* <pre>{@code
|
||||
* // Keycloak
|
||||
* OidcConfig.builder(
|
||||
* "https://keycloak.example.com/realms/myrealm",
|
||||
* "my-app", "secret", "/auth/callback")
|
||||
* .rolesClaimPath("realm_access.roles") // Keycloak default
|
||||
* .scopeClaimPaths("scope,scp") // default; supports many IdPs
|
||||
* .build();
|
||||
*
|
||||
* // Authelia
|
||||
* OidcConfig.builder(
|
||||
* "https://auth.example.com",
|
||||
* "my-app", "secret", "/auth/callback")
|
||||
* .rolesClaimPath("groups")
|
||||
* .scopeClaimPaths("scope,scp")
|
||||
* .build();
|
||||
*
|
||||
* // Two tenants on one server
|
||||
* OidcConfig tenantA = OidcConfig.builder("https://idp/realms/a", ..., "/tenantA/auth/callback")
|
||||
* .routePrefix("/tenantA/auth").build();
|
||||
* OidcConfig tenantB = OidcConfig.builder("https://idp/realms/b", ..., "/tenantB/auth/callback")
|
||||
* .routePrefix("/tenantB/auth").build();
|
||||
* app.install(new OidcExtension(tenantA))
|
||||
* .install(new OidcExtension(tenantB));
|
||||
* }</pre>
|
||||
*/
|
||||
public final class OidcConfig {
|
||||
|
||||
private final String issuer;
|
||||
private final String clientId;
|
||||
private final String clientSecret;
|
||||
private final String redirectUri;
|
||||
private final String scopes;
|
||||
private final String routePrefix;
|
||||
private final String selfScheme;
|
||||
private final String rolesClaimPath;
|
||||
private final String scopeClaimPaths;
|
||||
private final String algorithm;
|
||||
private final String postLogoutRedirectUri;
|
||||
private final SessionStore sessionStore;
|
||||
private final boolean insecureTls;
|
||||
private final ClientAuthMethod clientAuthMethod;
|
||||
private final String schemeName;
|
||||
|
||||
private OidcConfig(Builder b) {
|
||||
this.issuer = require(b.issuer, "issuer");
|
||||
this.clientId = require(b.clientId, "clientId");
|
||||
this.clientSecret = require(b.clientSecret, "clientSecret");
|
||||
this.redirectUri = require(b.redirectUri, "redirectUri");
|
||||
this.scopes = b.scopes;
|
||||
this.routePrefix = b.routePrefix;
|
||||
this.selfScheme = b.selfScheme;
|
||||
this.rolesClaimPath = b.rolesClaimPath;
|
||||
this.scopeClaimPaths = b.scopeClaimPaths;
|
||||
this.algorithm = b.algorithm;
|
||||
this.postLogoutRedirectUri = b.postLogoutRedirectUri;
|
||||
this.sessionStore = b.sessionStore != null ? b.sessionStore
|
||||
: new InMemorySessionStore();
|
||||
this.insecureTls = b.insecureTls;
|
||||
this.clientAuthMethod = b.clientAuthMethod;
|
||||
this.schemeName = b.schemeName != null ? b.schemeName : deriveScheme(this.issuer);
|
||||
}
|
||||
|
||||
// -- Getters --------------------------------------------------------------
|
||||
|
||||
public String issuer() { return issuer; }
|
||||
public String clientId() { return clientId; }
|
||||
public String clientSecret() { return clientSecret; }
|
||||
public String redirectUri() { return redirectUri; }
|
||||
public String scopes() { return scopes; }
|
||||
public String routePrefix() { return routePrefix; }
|
||||
public String selfScheme() { return selfScheme; }
|
||||
public String rolesClaimPath() { return rolesClaimPath; }
|
||||
/** Comma-separated claim paths used to read OAuth2 scopes (default: {@code "scope,scp"}). */
|
||||
public String scopeClaimPaths() { return scopeClaimPaths; }
|
||||
public String algorithm() { return algorithm; }
|
||||
public String postLogoutRedirectUri() { return postLogoutRedirectUri; }
|
||||
public SessionStore sessionStore() { return sessionStore; }
|
||||
/** If {@code true}, TLS certificate validation is skipped. <b>Never use in production.</b> */
|
||||
public boolean insecureTls() { return insecureTls; }
|
||||
public ClientAuthMethod clientAuthMethod() { return clientAuthMethod; }
|
||||
/** OpenAPI security scheme name (derived from issuer if not set explicitly). */
|
||||
public String schemeName() { return schemeName; }
|
||||
|
||||
// -- Factory --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Reads configuration from environment variables:
|
||||
* <pre>
|
||||
* OIDC_ISSUER required
|
||||
* OIDC_CLIENT_ID required
|
||||
* OIDC_CLIENT_SECRET required
|
||||
* OIDC_REDIRECT_URI required (e.g. /auth/callback)
|
||||
* OIDC_SCOPES default: openid profile email
|
||||
* OIDC_ROUTE_PREFIX default: /auth
|
||||
* OIDC_SELF_SCHEME default: http
|
||||
* OIDC_ROLES_CLAIM default: realm_access.roles
|
||||
* OIDC_SCOPE_CLAIMS default: scope,scp
|
||||
* OIDC_ALGORITHM default: RS256
|
||||
* OIDC_POST_LOGOUT_REDIRECT default: /
|
||||
* </pre>
|
||||
*/
|
||||
public static OidcConfig fromEnv() {
|
||||
return builder(env("OIDC_ISSUER"), env("OIDC_CLIENT_ID"),
|
||||
env("OIDC_CLIENT_SECRET"), env("OIDC_REDIRECT_URI"))
|
||||
.scopes (envOr("OIDC_SCOPES", "openid profile email"))
|
||||
.routePrefix (envOr("OIDC_ROUTE_PREFIX", "/auth"))
|
||||
.selfScheme (envOr("OIDC_SELF_SCHEME", "http"))
|
||||
.rolesClaimPath (envOr("OIDC_ROLES_CLAIM", "realm_access.roles"))
|
||||
.scopeClaimPaths (envOr("OIDC_SCOPE_CLAIMS", "scope,scp"))
|
||||
.algorithm (envOr("OIDC_ALGORITHM", "RS256"))
|
||||
.postLogoutRedirectUri(envOr("OIDC_POST_LOGOUT_REDIRECT", "/"))
|
||||
.clientAuthMethod(ClientAuthMethod.valueOf(
|
||||
envOr("OIDC_CLIENT_AUTH_METHOD", "POST").toUpperCase()))
|
||||
.build();
|
||||
}
|
||||
|
||||
public static Builder builder(String issuer, String clientId,
|
||||
String clientSecret, String redirectUri) {
|
||||
return new Builder(issuer, clientId, clientSecret, redirectUri);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience factory for Keycloak: constructs the issuer as
|
||||
* {@code {serverUrl}/realms/{realm}} automatically.
|
||||
*
|
||||
* <pre>{@code
|
||||
* OidcConfig.keycloak(
|
||||
* "https://keycloak.example.com", "flashboard",
|
||||
* "my-app", "secret", "/auth/callback")
|
||||
* .https()
|
||||
* .build();
|
||||
* }</pre>
|
||||
*/
|
||||
public static Builder keycloak(String serverUrl, String realm,
|
||||
String clientId, String clientSecret,
|
||||
String redirectUri) {
|
||||
String base = serverUrl.endsWith("/") ? serverUrl.substring(0, serverUrl.length() - 1) : serverUrl;
|
||||
String issuer = base + "/realms/" + realm;
|
||||
return new Builder(issuer, clientId, clientSecret, redirectUri)
|
||||
.rolesClaimPath("realm_access.roles"); // Keycloak default
|
||||
}
|
||||
|
||||
// -- Helpers --------------------------------------------------------------
|
||||
|
||||
private static String require(String v, String name) {
|
||||
if (v == null || v.isBlank())
|
||||
throw new IllegalArgumentException("OidcConfig: " + name + " is required");
|
||||
return v;
|
||||
}
|
||||
|
||||
private static String env(String key) {
|
||||
String v = System.getenv(key);
|
||||
if (v == null || v.isBlank())
|
||||
throw new IllegalArgumentException("Missing required env var: " + key);
|
||||
return v;
|
||||
}
|
||||
|
||||
private static String envOr(String key, String def) {
|
||||
String v = System.getenv(key);
|
||||
return (v != null && !v.isBlank()) ? v : def;
|
||||
}
|
||||
|
||||
// -- Builder --------------------------------------------------------------
|
||||
|
||||
public static final class Builder {
|
||||
|
||||
private final String issuer;
|
||||
private final String clientId;
|
||||
private final String clientSecret;
|
||||
private final String redirectUri;
|
||||
|
||||
private String scopes = "openid profile email";
|
||||
private String routePrefix = "/auth";
|
||||
private String selfScheme = "http";
|
||||
private String rolesClaimPath = "realm_access.roles";
|
||||
private String scopeClaimPaths = "scope,scp";
|
||||
private String algorithm = "RS256";
|
||||
private String postLogoutRedirectUri = "/";
|
||||
private SessionStore sessionStore;
|
||||
private boolean insecureTls = false;
|
||||
private ClientAuthMethod clientAuthMethod = ClientAuthMethod.POST;
|
||||
private String schemeName = null;
|
||||
|
||||
private Builder(String issuer, String clientId, String clientSecret, String redirectUri) {
|
||||
this.issuer = issuer;
|
||||
this.clientId = clientId;
|
||||
this.clientSecret = clientSecret;
|
||||
this.redirectUri = redirectUri;
|
||||
}
|
||||
|
||||
/** Override requested scopes (default: {@code openid profile email}). */
|
||||
public Builder scopes(String scopes) { this.scopes = scopes; return this; }
|
||||
/** Route prefix for login/callback/logout (default: {@code /auth}). */
|
||||
public Builder routePrefix(String prefix) { this.routePrefix = prefix; return this; }
|
||||
/** Scheme used when resolving self-relative redirect URIs (default: {@code http}). */
|
||||
public Builder selfScheme(String scheme) { this.selfScheme = scheme; return this; }
|
||||
/** Shorthand for {@code selfScheme("https")}. */
|
||||
public Builder https() { return selfScheme("https"); }
|
||||
/** Dot-separated path to the roles array in JWT claims (default: {@code realm_access.roles}). */
|
||||
public Builder rolesClaimPath(String path) { this.rolesClaimPath = path; return this; }
|
||||
/** Comma-separated claim paths used to resolve OAuth2 scopes (default: {@code scope,scp}). */
|
||||
public Builder scopeClaimPaths(String paths) { this.scopeClaimPaths = paths; return this; }
|
||||
/** JWS algorithm (default: {@code RS256}). */
|
||||
public Builder algorithm(String algorithm) { this.algorithm = algorithm; return this; }
|
||||
/** Where to redirect after logout (default: {@code /}). */
|
||||
public Builder postLogoutRedirectUri(String uri) { this.postLogoutRedirectUri = uri; return this; }
|
||||
/** Custom session store (default: {@link InMemorySessionStore}). */
|
||||
public Builder sessionStore(SessionStore store) { this.sessionStore = store; return this; }
|
||||
/**
|
||||
* Disables TLS certificate verification for all HTTP calls made by this extension.
|
||||
* <b>Only use in development with self-signed certificates — never in production.</b>
|
||||
*/
|
||||
public Builder insecureTls() { this.insecureTls = true; return this; }
|
||||
/** Token endpoint client authentication method (default: {@link ClientAuthMethod#POST}). */
|
||||
public Builder clientAuthMethod(ClientAuthMethod method) { this.clientAuthMethod = method; return this; }
|
||||
/** Override the OpenAPI security scheme name (default: derived from the issuer URI). */
|
||||
public Builder schemeName(String name) { this.schemeName = name; return this; }
|
||||
|
||||
public OidcConfig build() { return new OidcConfig(this); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives a short, human-readable scheme name from the issuer URI.
|
||||
* Takes the last non-empty path segment; falls back to the host.
|
||||
*
|
||||
* <p>Examples:
|
||||
* <ul>
|
||||
* <li>{@code https://keycloak.dev.home/realms/flashboard} → {@code "flashboard"}</li>
|
||||
* <li>{@code https://auth.example.com} → {@code "auth.example.com"}</li>
|
||||
* </ul>
|
||||
*/
|
||||
private static String deriveScheme(String issuer) {
|
||||
try {
|
||||
java.net.URI uri = new java.net.URI(issuer);
|
||||
String path = uri.getPath();
|
||||
if (path != null && !path.isEmpty()) {
|
||||
String[] parts = path.split("/");
|
||||
for (int i = parts.length - 1; i >= 0; i--) {
|
||||
if (!parts[i].isEmpty()) return parts[i];
|
||||
}
|
||||
}
|
||||
return uri.getHost();
|
||||
} catch (Exception e) {
|
||||
return "oidc";
|
||||
}
|
||||
}
|
||||
}
|
||||
+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;
|
||||
}
|
||||
}
|
||||
+343
@@ -0,0 +1,343 @@
|
||||
package dev.relism.flash.ext.oidc;
|
||||
|
||||
import dev.relism.flash.ext.openapi.OpenApiContributor;
|
||||
import dev.relism.flash.ext.openapi.OpenApiContributorRegistry;
|
||||
import dev.relism.flash.ext.openapi.OpenApiOperationContribution;
|
||||
import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
|
||||
import dev.relism.flash.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.FlashExtension;
|
||||
import dev.relism.flash.extension.FlashRegistrar;
|
||||
import dev.relism.flash.models.Request;
|
||||
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.TrustManager;
|
||||
import javax.net.ssl.X509TrustManager;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Full OIDC Authorization Code + PKCE flow for Flash.
|
||||
*
|
||||
* <p>At {@link #provide}, the extension:
|
||||
* <ol>
|
||||
* <li>Fetches the provider discovery document — fail-fast at startup.</li>
|
||||
* <li>Provides {@link AuthMiddleware}, {@link OidcCredentialSource} and {@link JwtValidator}
|
||||
* in the context.</li>
|
||||
* <li>Registers annotation processors for {@link Authenticated}, {@link RolesAllowed}
|
||||
* and {@link ScopesAllowed}.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>At {@link #routes}, three routes are registered:
|
||||
* <ul>
|
||||
* <li>{@code GET {prefix}/login} — builds the authorization URL and redirects.</li>
|
||||
* <li>{@code GET {prefix}/callback} — exchanges the code, creates a session, redirects.</li>
|
||||
* <li>{@code POST {prefix}/logout} — invalidates the session, redirects to provider
|
||||
* end-session endpoint (if available) or to {@link OidcConfig#postLogoutRedirectUri()}.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <pre>{@code
|
||||
* // Keycloak
|
||||
* app.install(new OidcExtension(
|
||||
* OidcConfig.builder(
|
||||
* "https://keycloak.example.com/realms/myrealm",
|
||||
* "my-app", "secret", "/auth/callback")
|
||||
* .rolesClaimPath("realm_access.roles")
|
||||
* .build()));
|
||||
*
|
||||
* // Two providers / tenants on one server
|
||||
* app.install(new OidcExtension(tenantAConfig))
|
||||
* .install(new OidcExtension(tenantBConfig));
|
||||
* }</pre>
|
||||
*/
|
||||
public class OidcExtension implements FlashExtension {
|
||||
|
||||
private final OidcConfig config;
|
||||
|
||||
// Initialized in provide(), used in routes() — private to this extension instance.
|
||||
private OidcProviderMetadata meta;
|
||||
private OidcStateStore stateStore;
|
||||
private TokenClient tokenClient;
|
||||
private JwtValidator validator;
|
||||
private OidcCredentialSource source;
|
||||
private AuthMiddleware authMw;
|
||||
|
||||
public OidcExtension(OidcConfig config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
// ── Phase 1: services ─────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
HttpClient http = buildHttpClient(config);
|
||||
|
||||
// Discover provider endpoints (blocking; fail fast at startup).
|
||||
try {
|
||||
meta = DiscoveryClient.fetch(config.issuer(), http);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("OIDC discovery failed for issuer: " + config.issuer(), e);
|
||||
}
|
||||
|
||||
validator = new JwtValidator(meta.jwksUri(), config.issuer(), config.clientId(), config.algorithm(), http);
|
||||
stateStore = new OidcStateStore();
|
||||
tokenClient = new TokenClient(http, config);
|
||||
source = new OidcCredentialSource(validator, config, meta, tokenClient);
|
||||
authMw = AuthMiddleware.install(ctx, AuthConfig.builder()
|
||||
.rolesClaimPath(config.rolesClaimPath())
|
||||
.scopeClaimPaths(config.scopeClaimPaths())
|
||||
.build(), source);
|
||||
|
||||
ctx.provide(OidcCredentialSource.class, source);
|
||||
ctx.provide(JwtValidator.class, validator);
|
||||
|
||||
ctx.onReady(() -> registerRoutes(app, ctx));
|
||||
}
|
||||
|
||||
private void registerRoutes(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
String prefix = config.routePrefix();
|
||||
|
||||
// ── GET {prefix}/login ────────────────────────────────────────────────
|
||||
// Builds the provider authorization URL with PKCE + state and redirects.
|
||||
// Optional query param: ?redirect={relative-url} (default: /)
|
||||
app.get(prefix + "/login", (req, res) -> {
|
||||
String verifier = PkceUtils.generateVerifier();
|
||||
String challenge = PkceUtils.computeChallenge(verifier);
|
||||
String state = UUID.randomUUID().toString();
|
||||
String nonce = UUID.randomUUID().toString();
|
||||
|
||||
String redirect = req.query("redirect");
|
||||
if (redirect == null || !redirect.startsWith("/")) redirect = "/";
|
||||
|
||||
stateStore.put(state, redirect, verifier, nonce);
|
||||
|
||||
String authUrl = meta.authorizationEndpoint()
|
||||
+ "?response_type=code"
|
||||
+ "&client_id=" + enc(config.clientId())
|
||||
+ "&redirect_uri=" + enc(absoluteRedirectUri(req))
|
||||
+ "&scope=" + enc(config.scopes())
|
||||
+ "&state=" + state
|
||||
+ "&nonce=" + enc(nonce)
|
||||
+ "&code_challenge=" + challenge
|
||||
+ "&code_challenge_method=S256";
|
||||
|
||||
res.redirect(authUrl);
|
||||
return null;
|
||||
});
|
||||
|
||||
// ── GET {prefix}/callback ─────────────────────────────────────────────
|
||||
// Validates state, exchanges code for tokens, creates session, redirects.
|
||||
app.get(prefix + "/callback", (req, res) -> {
|
||||
String error = req.query("error");
|
||||
if (error != null) {
|
||||
res.status(400);
|
||||
return "Authentication error: " + error
|
||||
+ (req.query("error_description") != null
|
||||
? " — " + req.query("error_description") : "");
|
||||
}
|
||||
|
||||
String code = req.query("code");
|
||||
String state = req.query("state");
|
||||
|
||||
OidcStateStore.Entry entry = stateStore.consumeAndRemove(state).orElse(null);
|
||||
if (entry == null) {
|
||||
res.status(400);
|
||||
return "Invalid or expired state parameter";
|
||||
}
|
||||
|
||||
OidcTokenResponse tokens = tokenClient.exchangeCode(
|
||||
meta.tokenEndpoint(), code, absoluteRedirectUri(req), entry.codeVerifier());
|
||||
|
||||
// Validate ID token: signature + iss + aud + exp + iat + sub + nonce (OIDC Core §3.1.3.7)
|
||||
if (tokens.idToken() != null) {
|
||||
try {
|
||||
validator.validateIdToken(tokens.idToken(), entry.nonce());
|
||||
} catch (OidcValidationException e) {
|
||||
res.status(400);
|
||||
return "ID token validation failed: " + e.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> claims = mergeClaims(tokens);
|
||||
Session session = OidcCredentialSource.newSession(
|
||||
UUID.randomUUID().toString(),
|
||||
tokens.accessToken(), tokens.idToken(), tokens.refreshToken(),
|
||||
Instant.now().plusSeconds(tokens.expiresIn()), claims);
|
||||
config.sessionStore().save(session);
|
||||
|
||||
res.header("Set-Cookie", sessionCookie(session.id()))
|
||||
.redirect(entry.originalUrl());
|
||||
return null;
|
||||
});
|
||||
|
||||
// ── POST {prefix}/logout ──────────────────────────────────────────────
|
||||
// Invalidates the local session and redirects to end_session_endpoint.
|
||||
app.post(prefix + "/logout", (req, res) -> {
|
||||
String sessionId = OidcCredentialSource.cookieValue(req, "oidc_session");
|
||||
String idTokenHint = null;
|
||||
|
||||
if (sessionId != null) {
|
||||
Session session = config.sessionStore().find(sessionId).orElse(null);
|
||||
if (session != null) idTokenHint = session.attributeAsString(OidcCredentialSource.ID_TOKEN);
|
||||
config.sessionStore().delete(sessionId);
|
||||
}
|
||||
|
||||
String clearCookie = "oidc_session=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax";
|
||||
String location;
|
||||
|
||||
if (meta.endSessionEndpoint() != null) {
|
||||
String postLogout = absoluteSelf(req, config.postLogoutRedirectUri());
|
||||
StringBuilder url = new StringBuilder(meta.endSessionEndpoint())
|
||||
.append("?post_logout_redirect_uri=").append(enc(postLogout));
|
||||
if (idTokenHint != null)
|
||||
url.append("&id_token_hint=").append(enc(idTokenHint));
|
||||
location = url.toString();
|
||||
} else {
|
||||
location = config.postLogoutRedirectUri();
|
||||
}
|
||||
|
||||
res.header("Set-Cookie", clearCookie).redirect(location);
|
||||
return null;
|
||||
});
|
||||
|
||||
// Register OpenAPI security scheme if flash-ext-openapi is on the classpath.
|
||||
try {
|
||||
OpenApiIntegration.register(ctx, config, meta);
|
||||
} catch (NoClassDefFoundError ignored) {
|
||||
// flash-ext-openapi not available — OpenAPI integration disabled
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Merges claims from both the access token and the ID token.
|
||||
* ID token values win on conflict so that verified identity claims are authoritative.
|
||||
*/
|
||||
private static Map<String, Object> mergeClaims(OidcTokenResponse tokens) {
|
||||
Map<String, Object> merged = new HashMap<>();
|
||||
if (tokens.accessToken() != null) merged.putAll(JwtUtils.parseClaims(tokens.accessToken()));
|
||||
if (tokens.idToken() != null) merged.putAll(JwtUtils.parseClaims(tokens.idToken()));
|
||||
return Map.copyOf(merged);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an {@link HttpClient}. If {@link OidcConfig#insecureTls()} is set,
|
||||
* installs a trust-all {@link SSLContext} that accepts any certificate.
|
||||
* <b>Only safe for development with self-signed certificates.</b>
|
||||
*/
|
||||
private static HttpClient buildHttpClient(OidcConfig config) {
|
||||
if (!config.insecureTls()) return HttpClient.newHttpClient();
|
||||
try {
|
||||
TrustManager[] trustAll = { new X509TrustManager() {
|
||||
public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; }
|
||||
public void checkClientTrusted(X509Certificate[] c, String a) {}
|
||||
public void checkServerTrusted(X509Certificate[] c, String a) {}
|
||||
}};
|
||||
SSLContext sslCtx = SSLContext.getInstance("TLS");
|
||||
sslCtx.init(null, trustAll, new SecureRandom());
|
||||
return HttpClient.newBuilder().sslContext(sslCtx).build();
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to create trust-all SSLContext", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String absoluteRedirectUri(Request req) {
|
||||
return absoluteSelf(req, config.redirectUri());
|
||||
}
|
||||
|
||||
private String absoluteSelf(Request req, String uri) {
|
||||
if (!uri.startsWith("/")) return uri;
|
||||
return OidcCredentialSource.selfOrigin(req, config.selfScheme()) + uri;
|
||||
}
|
||||
|
||||
private static String enc(String v) {
|
||||
return URLEncoder.encode(v, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static String sessionCookie(String id) {
|
||||
return "oidc_session=" + id + "; HttpOnly; Path=/; SameSite=Lax";
|
||||
}
|
||||
|
||||
/**
|
||||
* Loaded lazily so that {@code flash-ext-openapi} classes are only resolved at
|
||||
* runtime when {@link OpenApiContributorRegistry} is actually on the classpath.
|
||||
*/
|
||||
private static final class OpenApiIntegration {
|
||||
static void register(FlashContext ctx,
|
||||
OidcConfig config, OidcProviderMetadata meta) {
|
||||
ctx.find(OpenApiContributorRegistry.class)
|
||||
.ifPresent(registry -> registry.add(new OpenApiContributor() {
|
||||
|
||||
@Override
|
||||
public Map<String, Object> componentContributions() {
|
||||
Map<String, String> scopesMap = new LinkedHashMap<>();
|
||||
for (String s : config.scopes().split("\\s+")) {
|
||||
if (!s.isBlank()) scopesMap.put(s, s);
|
||||
}
|
||||
Map<String, Object> flow = new LinkedHashMap<>();
|
||||
flow.put("authorizationUrl", meta.authorizationEndpoint());
|
||||
flow.put("tokenUrl", meta.tokenEndpoint());
|
||||
flow.put("scopes", scopesMap);
|
||||
|
||||
Map<String, Object> scheme = new LinkedHashMap<>();
|
||||
scheme.put("type", "oauth2");
|
||||
scheme.put("flows", Map.of("authorizationCode", flow));
|
||||
|
||||
Map<String, Object> securitySchemes = new LinkedHashMap<>();
|
||||
securitySchemes.put(config.schemeName(), scheme);
|
||||
return Map.of("securitySchemes", securitySchemes);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OpenApiOperationContribution operationFor(Class<?> handlerClass) {
|
||||
OpenApiOperationContribution.Builder out =
|
||||
OpenApiOperationContribution.builder();
|
||||
|
||||
List<String> operationScopes = AuthPolicy.openApiScopesFor(handlerClass);
|
||||
if (operationScopes != null) {
|
||||
out.security(config.schemeName(), operationScopes);
|
||||
}
|
||||
|
||||
AuthPolicy policy = AuthPolicy.compileFromAnnotations(handlerClass);
|
||||
if (policy == null || policy.optionalAuth()) return out.build();
|
||||
|
||||
out.response(401, OpenApiResponseContribution.of("Authentication required"));
|
||||
|
||||
String[] roles = policy.requiredRoles();
|
||||
String[] scopes = policy.requiredScopes();
|
||||
if (roles.length == 0 && scopes.length == 0) return out.build();
|
||||
|
||||
String roleMessage = roles.length == 0 ? null : roleRequiredMessage(roles);
|
||||
String scopeMessage = scopes.length == 0 ? null : scopeRequiredMessage(scopes);
|
||||
if (roleMessage != null && scopeMessage != null) {
|
||||
out.response(403, OpenApiResponseContribution.of(roleMessage + "; " + scopeMessage));
|
||||
} else
|
||||
out.response(403, OpenApiResponseContribution.of(Objects.requireNonNullElse(roleMessage, scopeMessage)));
|
||||
return out.build();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private static String roleRequiredMessage(String[] roles) {
|
||||
if (roles.length == 1) return "\"" + roles[0] + "\" role required";
|
||||
return "Roles \"" + String.join(", ", roles) + "\" are required";
|
||||
}
|
||||
|
||||
private static String scopeRequiredMessage(String[] scopes) {
|
||||
if (scopes.length == 1) return "\"" + scopes[0] + "\" scope required";
|
||||
return "Scopes \"" + String.join(", ", scopes) + "\" are required";
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package dev.relism.flash.ext.oidc;
|
||||
|
||||
/**
|
||||
* OIDC provider endpoints discovered from {@code {issuer}/.well-known/openid-configuration}.
|
||||
*
|
||||
* <p>{@link #endSessionEndpoint()} may be {@code null} — not all providers expose it
|
||||
* (e.g. some Authelia configurations omit it).
|
||||
*/
|
||||
public record OidcProviderMetadata(
|
||||
String authorizationEndpoint,
|
||||
String tokenEndpoint,
|
||||
String userinfoEndpoint,
|
||||
String jwksUri,
|
||||
String endSessionEndpoint // nullable
|
||||
) {}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package dev.relism.flash.ext.oidc;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Short-lived store mapping state nonces → (original URL, PKCE verifier).
|
||||
*
|
||||
* <p>Entries expire after {@value #TTL_SECONDS} seconds. Cleanup runs on every
|
||||
* access to prevent unbounded growth without needing a background thread.
|
||||
*/
|
||||
final class OidcStateStore {
|
||||
|
||||
static final int TTL_SECONDS = 600; // 10 minutes
|
||||
|
||||
record Entry(String originalUrl, String codeVerifier, String nonce, Instant expiresAt) {}
|
||||
|
||||
private final ConcurrentHashMap<String, Entry> store = new ConcurrentHashMap<>();
|
||||
|
||||
void put(String state, String originalUrl, String codeVerifier, String nonce) {
|
||||
cleanup();
|
||||
store.put(state, new Entry(originalUrl, codeVerifier, nonce,
|
||||
Instant.now().plusSeconds(TTL_SECONDS)));
|
||||
}
|
||||
|
||||
/** Atomically retrieves and removes the entry; returns empty if absent or expired. */
|
||||
Optional<Entry> consumeAndRemove(String nonce) {
|
||||
cleanup();
|
||||
Entry e = store.remove(nonce);
|
||||
if (e == null || Instant.now().isAfter(e.expiresAt())) return Optional.empty();
|
||||
return Optional.of(e);
|
||||
}
|
||||
|
||||
private void cleanup() {
|
||||
Instant now = Instant.now();
|
||||
store.entrySet().removeIf(kv -> now.isAfter(kv.getValue().expiresAt()));
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package dev.relism.flash.ext.oidc;
|
||||
|
||||
/** Parsed response from an OAuth2 token endpoint. Package-private — internal use only. */
|
||||
record OidcTokenResponse(
|
||||
String accessToken,
|
||||
String idToken, // may be null on refresh if provider omits it
|
||||
String refreshToken, // may be null
|
||||
int expiresIn,
|
||||
int refreshExpiresIn
|
||||
) {}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package dev.relism.flash.ext.oidc;
|
||||
|
||||
import dev.relism.flash.exceptions.HttpException;
|
||||
|
||||
/**
|
||||
* Thrown when OIDC token validation fails (signature, claims, nonce, expiry, etc.).
|
||||
* Distinct from {@link HttpException}: this signals a protocol-level
|
||||
* failure, not an HTTP response — callers decide the appropriate status code.
|
||||
*/
|
||||
public final class OidcValidationException extends RuntimeException {
|
||||
public OidcValidationException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package dev.relism.flash.ext.oidc;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* PKCE (RFC 7636) utilities: code verifier generation and S256 challenge computation.
|
||||
* Package-private — used exclusively by {@link OidcExtension}.
|
||||
*/
|
||||
final class PkceUtils {
|
||||
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
private PkceUtils() {}
|
||||
|
||||
/**
|
||||
* Generates a cryptographically random code verifier (43 URL-safe characters,
|
||||
* per RFC 7636 §4.1 — 32 bytes encoded as unpadded Base64URL).
|
||||
*/
|
||||
static String generateVerifier() {
|
||||
byte[] bytes = new byte[32];
|
||||
RANDOM.nextBytes(bytes);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the S256 code challenge: {@code BASE64URL(SHA-256(ASCII(verifier)))}.
|
||||
*/
|
||||
static String computeChallenge(String verifier) throws Exception {
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256")
|
||||
.digest(verifier.getBytes(StandardCharsets.US_ASCII));
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
|
||||
}
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
package dev.relism.flash.ext.oidc;
|
||||
|
||||
import net.minidev.json.JSONValue;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* HTTP client for OAuth2 token endpoint operations (pure HTTP, no SDK).
|
||||
*
|
||||
* <p>Supports two client authentication methods (RFC 6749 §2.3):
|
||||
* <ul>
|
||||
* <li>{@link ClientAuthMethod#POST} — credentials in form body ({@code client_secret_post})</li>
|
||||
* <li>{@link ClientAuthMethod#BASIC} — credentials in {@code Authorization: Basic} header
|
||||
* ({@code client_secret_basic})</li>
|
||||
* </ul>
|
||||
*/
|
||||
final class TokenClient {
|
||||
|
||||
private final HttpClient http;
|
||||
private final String clientId;
|
||||
private final String clientSecret;
|
||||
private final ClientAuthMethod authMethod;
|
||||
|
||||
TokenClient(HttpClient http, OidcConfig config) {
|
||||
this.http = http;
|
||||
this.clientId = config.clientId();
|
||||
this.clientSecret = config.clientSecret();
|
||||
this.authMethod = config.clientAuthMethod();
|
||||
}
|
||||
|
||||
/** Authorization Code + PKCE exchange. */
|
||||
OidcTokenResponse exchangeCode(String tokenEndpoint,
|
||||
String code, String redirectUri,
|
||||
String codeVerifier) throws Exception {
|
||||
Map<String, String> params = new LinkedHashMap<>();
|
||||
params.put("grant_type", "authorization_code");
|
||||
params.put("code", code);
|
||||
params.put("redirect_uri", redirectUri);
|
||||
params.put("code_verifier", codeVerifier);
|
||||
return post(tokenEndpoint, params);
|
||||
}
|
||||
|
||||
/** Refresh token grant. */
|
||||
OidcTokenResponse refresh(String tokenEndpoint, String refreshToken) throws Exception {
|
||||
Map<String, String> params = new LinkedHashMap<>();
|
||||
params.put("grant_type", "refresh_token");
|
||||
params.put("refresh_token", refreshToken);
|
||||
return post(tokenEndpoint, params);
|
||||
}
|
||||
|
||||
// -- Internals ------------------------------------------------------------
|
||||
|
||||
private OidcTokenResponse post(String url, Map<String, String> params) throws Exception {
|
||||
HttpRequest.Builder req = HttpRequest.newBuilder()
|
||||
.uri(URI.create(url))
|
||||
.header("Content-Type", "application/x-www-form-urlencoded");
|
||||
|
||||
if (authMethod == ClientAuthMethod.BASIC) {
|
||||
String creds = Base64.getEncoder().encodeToString(
|
||||
(clientId + ":" + clientSecret).getBytes(StandardCharsets.UTF_8));
|
||||
req.header("Authorization", "Basic " + creds);
|
||||
} else {
|
||||
params.put("client_id", clientId);
|
||||
params.put("client_secret", clientSecret);
|
||||
}
|
||||
|
||||
HttpResponse<String> resp = http.send(
|
||||
req.POST(HttpRequest.BodyPublishers.ofString(form(params))).build(),
|
||||
HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (resp.statusCode() < 200 || resp.statusCode() >= 300)
|
||||
throw new IllegalStateException(
|
||||
"Token endpoint [" + resp.statusCode() + "]: " + resp.body());
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> json = (Map<String, Object>) JSONValue.parse(resp.body());
|
||||
|
||||
return new OidcTokenResponse(
|
||||
(String) json.get("access_token"),
|
||||
(String) json.get("id_token"),
|
||||
(String) json.get("refresh_token"),
|
||||
numInt(json, "expires_in", 300),
|
||||
numInt(json, "refresh_expires_in", 1800)
|
||||
);
|
||||
}
|
||||
|
||||
private static String form(Map<String, String> params) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
params.forEach((k, v) -> {
|
||||
if (!sb.isEmpty()) sb.append('&');
|
||||
sb.append(enc(k)).append('=').append(enc(v));
|
||||
});
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String enc(String v) {
|
||||
return URLEncoder.encode(v, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static int numInt(Map<String, Object> m, String key, int def) {
|
||||
Object v = m.get(key);
|
||||
return v instanceof Number n ? n.intValue() : def;
|
||||
}
|
||||
}
|
||||
+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\""));
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package dev.relism.flash.ext.oidc;
|
||||
|
||||
import dev.relism.flash.ext.openapi.OpenApiContributorRegistry;
|
||||
import dev.relism.flash.ext.openapi.OpenApiOperationContribution;
|
||||
import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
|
||||
import dev.relism.flash.ext.openapi.OpenApiContributor;
|
||||
import dev.relism.flash.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 org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Constructor;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class OidcOpenApiInteropTest {
|
||||
|
||||
@Authenticated
|
||||
static class AuthOnly {}
|
||||
|
||||
@Authenticated(optional = true)
|
||||
static class AuthOptional {}
|
||||
|
||||
@RolesAllowed("admin")
|
||||
static class OneRole {}
|
||||
|
||||
@RolesAllowed({"admin", "operator"})
|
||||
static class MultiRole {}
|
||||
|
||||
@ScopesAllowed("orders:write")
|
||||
static class OneScope {}
|
||||
|
||||
@ScopesAllowed({"orders:write", "payments:write"})
|
||||
static class MultiScope {}
|
||||
|
||||
@RolesAllowed("admin")
|
||||
@ScopesAllowed("orders:write")
|
||||
static class RoleAndScope {}
|
||||
|
||||
@Test
|
||||
void autoResponses_authOnly() throws Exception {
|
||||
Map<Integer, String> responses = responses(AuthOnly.class);
|
||||
assertEquals("Authentication required", responses.get(401));
|
||||
assertFalse(responses.containsKey(403));
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoResponses_optionalAuth_addsNothing() throws Exception {
|
||||
Map<Integer, String> responses = responses(AuthOptional.class);
|
||||
assertTrue(responses.isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoResponses_oneRole_formatsSingular() throws Exception {
|
||||
Map<Integer, String> responses = responses(OneRole.class);
|
||||
assertEquals("Authentication required", responses.get(401));
|
||||
assertEquals("\"admin\" role required", responses.get(403));
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoResponses_multiRoles_formatsPlural() throws Exception {
|
||||
Map<Integer, String> responses = responses(MultiRole.class);
|
||||
assertEquals("Roles \"admin, operator\" are required", responses.get(403));
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoResponses_oneScope_formatsSingular() throws Exception {
|
||||
Map<Integer, String> responses = responses(OneScope.class);
|
||||
assertEquals("\"orders:write\" scope required", responses.get(403));
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoResponses_multiScopes_formatsPlural() throws Exception {
|
||||
Map<Integer, String> responses = responses(MultiScope.class);
|
||||
assertEquals("Scopes \"orders:write, payments:write\" are required", responses.get(403));
|
||||
}
|
||||
|
||||
@Test
|
||||
void autoResponses_roleAndScope_combinesMessages() throws Exception {
|
||||
Map<Integer, String> responses = responses(RoleAndScope.class);
|
||||
assertEquals("\"admin\" role required; \"orders:write\" scope required", responses.get(403));
|
||||
}
|
||||
|
||||
@Test
|
||||
void securityContribution_presentForAuthenticatedHandler() throws Exception {
|
||||
OpenApiOperationContribution operation = contributor().operationFor(AuthOnly.class);
|
||||
List<Map<String, List<String>>> security = operation.security();
|
||||
assertEquals(1, security.size());
|
||||
assertTrue(security.getFirst().containsKey("issuer"));
|
||||
}
|
||||
|
||||
private static OpenApiContributor contributor() throws Exception {
|
||||
Class<?> clazz = Class.forName("dev.relism.flash.ext.oidc.OidcExtension$OpenApiIntegration");
|
||||
Constructor<?> ctor = clazz.getDeclaredConstructor();
|
||||
ctor.setAccessible(true);
|
||||
Object instance = ctor.newInstance();
|
||||
|
||||
Method m = clazz.getDeclaredMethod("register", FlashContext.class, OidcConfig.class, OidcProviderMetadata.class);
|
||||
m.setAccessible(true);
|
||||
|
||||
FlashContext ctx = new FlashContext();
|
||||
OpenApiContributorRegistry registry = new OpenApiContributorRegistry();
|
||||
ctx.provide(OpenApiContributorRegistry.class, registry);
|
||||
ctx.complete();
|
||||
|
||||
OidcConfig config = OidcConfig.builder("https://issuer", "c", "s", "/cb").build();
|
||||
OidcProviderMetadata meta = new OidcProviderMetadata("a", "t", "u", "j", "e");
|
||||
m.invoke(instance, ctx, config, meta);
|
||||
|
||||
return registry.contributors().getFirst();
|
||||
}
|
||||
|
||||
private static Map<Integer, String> responses(Class<?> cls) throws Exception {
|
||||
Map<Integer, OpenApiResponseContribution> byCode = contributor().operationFor(cls).responses();
|
||||
java.util.LinkedHashMap<Integer, String> out = new java.util.LinkedHashMap<>();
|
||||
for (Map.Entry<Integer, OpenApiResponseContribution> e : byCode.entrySet()) {
|
||||
out.put(e.getKey(), e.getValue().description());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user