preparing for a conceptual refactoring...

This commit is contained in:
Relism
2026-03-28 14:11:12 +01:00
parent 7b996b552b
commit 2edd68b0aa
60 changed files with 3432 additions and 518 deletions
@@ -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;
}
@@ -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();
});
@@ -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;
}