preparing for a conceptual refactoring...
This commit is contained in:
@@ -32,7 +32,7 @@ Optional: `flash-ext-openapi` — if present, OIDC security schemes are added to
|
||||
## Installation
|
||||
|
||||
```java
|
||||
FlashApp.of(new HttpServer(config))
|
||||
FlashApp.create(8080)
|
||||
.install(new JacksonExtension())
|
||||
.install(new OpenApiExtension(...)) // optional — enables Swagger security
|
||||
.install(new OidcExtension(
|
||||
@@ -144,17 +144,17 @@ OidcMiddleware oidc = app.ctx().require(OidcMiddleware.class);
|
||||
app.get("/api/me", (req, res) -> {
|
||||
OidcUser u = ClaimsHolder.user(); // never null here
|
||||
return Map.of("sub", u.sub(), "email", u.email());
|
||||
}, oidc.protect());
|
||||
}).with(oidc.protect());
|
||||
|
||||
// Authentication + role check
|
||||
app.delete("/api/admin/users/{id}", (req, res) -> {
|
||||
OidcUser u = ClaimsHolder.user();
|
||||
// ...
|
||||
}, oidc.requireRole("admin"));
|
||||
}).with(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"));
|
||||
app.get("/api/reports", (req, res) -> { ... })
|
||||
.with(oidc.requireRole("admin", "reports-viewer"));
|
||||
```
|
||||
|
||||
`oidc.protect()` / `oidc.requireRole(...)` return a `Middleware` — a composable
|
||||
@@ -326,8 +326,8 @@ OidcMiddleware mwA = app.ctx().require(OidcMiddleware.class); // tenantA's mid
|
||||
app.install(new OidcExtension(tenantB));
|
||||
OidcMiddleware mwB = app.ctx().require(OidcMiddleware.class); // tenantB's middleware
|
||||
|
||||
app.get("/a/dashboard", (req, res) -> { ... }, mwA.protect());
|
||||
app.get("/b/dashboard", (req, res) -> { ... }, mwB.protect());
|
||||
app.get("/a/dashboard", (req, res) -> { ... }).with(mwA.protect());
|
||||
app.get("/b/dashboard", (req, res) -> { ... }).with(mwB.protect());
|
||||
```
|
||||
|
||||
Class-based handlers annotated with `@Authenticated` / `@RolesAllowed` get the last
|
||||
|
||||
@@ -11,13 +11,30 @@ import java.lang.annotation.Target;
|
||||
*
|
||||
* <p>For role-based access use {@link RolesAllowed} instead (it implies authentication).
|
||||
*
|
||||
* <p>Set {@code optional = true} on public routes that personalise their response when
|
||||
* the user happens to be logged in but should remain accessible to guests. The middleware
|
||||
* will populate {@link ClaimsHolder} if credentials are present and silently skip it
|
||||
* otherwise — the request is never rejected.
|
||||
*
|
||||
* <pre>{@code
|
||||
* // Hard auth — redirects / 401 when unauthenticated:
|
||||
* @Route(method = HttpMethod.GET, path = "/api/profile")
|
||||
* @Authenticated
|
||||
* public class GetProfile extends JacksonHandler { ... }
|
||||
*
|
||||
* // Soft auth — guest-friendly, ClaimsHolder populated only when logged in:
|
||||
* @Route(method = HttpMethod.GET, path = "/")
|
||||
* @Authenticated(optional = true)
|
||||
* public class HomePage extends HtmlHandler { ... }
|
||||
* }</pre>
|
||||
*/
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Target(ElementType.TYPE)
|
||||
public @interface Authenticated {
|
||||
/**
|
||||
* When {@code true} the middleware never rejects unauthenticated requests — it only
|
||||
* populates {@link ClaimsHolder} when valid credentials are present.
|
||||
* Defaults to {@code false} (hard authentication required).
|
||||
*/
|
||||
boolean optional() default false;
|
||||
}
|
||||
|
||||
+10
-10
@@ -1,8 +1,8 @@
|
||||
package dev.relism.ext.oidc;
|
||||
|
||||
import dev.relism.extension.ExtensionContext;
|
||||
import dev.relism.extension.FlashApp;
|
||||
import dev.relism.extension.FlashExtension;
|
||||
import dev.relism.extension.FlashRegistrar;
|
||||
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.TrustManager;
|
||||
@@ -59,7 +59,7 @@ public class OidcExtension implements FlashExtension {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void install(FlashApp app, ExtensionContext ctx) {
|
||||
public void install(FlashRegistrar app, ExtensionContext ctx) {
|
||||
|
||||
// 1. Build the shared HttpClient (optionally with TLS verification disabled)
|
||||
HttpClient http = buildHttpClient(config);
|
||||
@@ -116,7 +116,7 @@ public class OidcExtension implements FlashExtension {
|
||||
+ "&code_challenge=" + challenge
|
||||
+ "&code_challenge_method=S256";
|
||||
|
||||
res.status(302).header("Location", authUrl);
|
||||
res.redirect(authUrl);
|
||||
return null;
|
||||
}).with();
|
||||
|
||||
@@ -165,9 +165,8 @@ public class OidcExtension implements FlashExtension {
|
||||
);
|
||||
config.sessionStore().save(session);
|
||||
|
||||
res.status(302)
|
||||
.header("Set-Cookie", sessionCookie(session.id()))
|
||||
.header("Location", entry.originalUrl());
|
||||
res.header("Set-Cookie", sessionCookie(session.id()))
|
||||
.redirect(entry.originalUrl());
|
||||
return null;
|
||||
}).with();
|
||||
|
||||
@@ -200,9 +199,8 @@ public class OidcExtension implements FlashExtension {
|
||||
location = config.postLogoutRedirectUri();
|
||||
}
|
||||
|
||||
res.status(302)
|
||||
.header("Set-Cookie", clearCookie)
|
||||
.header("Location", location);
|
||||
res.header("Set-Cookie", clearCookie)
|
||||
.redirect(location);
|
||||
return null;
|
||||
}).with();
|
||||
|
||||
@@ -212,7 +210,9 @@ public class OidcExtension implements FlashExtension {
|
||||
if (roles != null) return List.of(oidcMw.rolesMiddleware(roles.value()));
|
||||
|
||||
Authenticated auth = handlerClass.getAnnotation(Authenticated.class);
|
||||
if (auth != null) return List.of(oidcMw.authenticatedMiddleware());
|
||||
if (auth != null) return List.of(auth.optional()
|
||||
? oidcMw.optionalMiddleware()
|
||||
: oidcMw.authenticatedMiddleware());
|
||||
|
||||
return List.of();
|
||||
});
|
||||
|
||||
+54
-1
@@ -68,6 +68,29 @@ public class OidcMiddleware {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Silently populates {@link ClaimsHolder} if a valid bearer token or session cookie
|
||||
* is present, but never rejects or redirects unauthenticated requests. Use this on
|
||||
* public routes that want to personalise the response when the user happens to be
|
||||
* logged in (e.g. showing a username on a landing page).
|
||||
*
|
||||
* <pre>{@code
|
||||
* app.get("/", handler).with(oidc.optional());
|
||||
* // Inside handler: ClaimsHolder.user() is non-null iff the user is logged in.
|
||||
* }</pre>
|
||||
*/
|
||||
public Middleware optional() {
|
||||
return next -> (req, res) -> {
|
||||
Map<String, Object> claims = resolveQuiet(req);
|
||||
if (claims != null) ClaimsHolder.set(claims);
|
||||
try {
|
||||
return next.handle(req, res);
|
||||
} finally {
|
||||
ClaimsHolder.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link #protect()} but also enforces that the caller holds at least one
|
||||
* of the given roles (OR semantics). Roles are extracted via
|
||||
@@ -90,10 +113,40 @@ public class OidcMiddleware {
|
||||
// -- Package-private: AnnotationProcessor hooks ---------------------------
|
||||
|
||||
Middleware authenticatedMiddleware() { return protect(); }
|
||||
Middleware optionalMiddleware() { return optional(); }
|
||||
Middleware rolesMiddleware(String[] required) { return requireRole(required); }
|
||||
|
||||
// -- 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 auth = req.header("Authorization");
|
||||
if (auth != null && auth.startsWith("Bearer "))
|
||||
return validator.validate(auth.substring(7));
|
||||
|
||||
String sessionId = cookieValue(req, "oidc_session");
|
||||
if (sessionId != null) {
|
||||
Optional<OidcSession> found = config.sessionStore().find(sessionId);
|
||||
if (found.isPresent()) {
|
||||
OidcSession session = found.get();
|
||||
if (!session.isAccessTokenExpired())
|
||||
return session.claims();
|
||||
if (session.refreshToken() != null) {
|
||||
try {
|
||||
OidcSession refreshed = doRefresh(session);
|
||||
config.sessionStore().save(refreshed);
|
||||
return refreshed.claims();
|
||||
} catch (Exception ignored) { }
|
||||
}
|
||||
config.sessionStore().delete(sessionId);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns claims on success, or {@code null} if a redirect was already written to
|
||||
* {@code res}. Throws {@link HttpException} 401/403 for API clients.
|
||||
@@ -136,7 +189,7 @@ public class OidcMiddleware {
|
||||
// Browser — redirect to login, preserving the original URL in state
|
||||
String loginUrl = config.routePrefix() + "/login?redirect="
|
||||
+ URLEncoder.encode(req.path(), StandardCharsets.UTF_8);
|
||||
res.status(302).header("Location", loginUrl);
|
||||
res.redirect(loginUrl);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user