You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@juneau.apache.org by ja...@apache.org on 2026/05/29 13:49:39 UTC
(juneau) branch master updated: feat(rest,jwt,oidc): TODO-127/114/140/141/115/117 - BeanPropertyMeta abstract-collection fix, TraceContextResponseProcessor, OIDC-RP module, JwksCache/JwtTokenValidator improvements, observability annotations+metrics, MDC async propagation
This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git
The following commit(s) were added to refs/heads/master by this push:
new 830e0ca051 feat(rest,jwt,oidc): TODO-127/114/140/141/115/117 - BeanPropertyMeta abstract-collection fix, TraceContextResponseProcessor, OIDC-RP module, JwksCache/JwtTokenValidator improvements, observability annotations+metrics, MDC async propagation
830e0ca051 is described below
commit 830e0ca051c604213fa6a03ad16a8deef38286e7
Author: James Bognar <ja...@apache.org>
AuthorDate: Fri May 29 09:49:33 2026 -0400
feat(rest,jwt,oidc): TODO-127/114/140/141/115/117 - BeanPropertyMeta abstract-collection fix, TraceContextResponseProcessor, OIDC-RP module, JwksCache/JwtTokenValidator improvements, observability annotations+metrics, MDC async propagation
---
.../juneau/commons/bean/BeanPropertyMeta.java | 65 +-
.../juneau/commons/logging/LogRecordCapture.java | 19 +-
.../org/apache/juneau/commons/logging/Logger.java | 17 +-
.../juneau/commons/reflect/ParameterInfo.java | 10 +-
juneau-distrib/pom.xml | 25 +
.../org/apache/juneau/rest/auth/jwt/JwksCache.java | 113 ++-
.../juneau/rest/auth/jwt/JwtTokenValidator.java | 82 +-
.../micrometer/MicrometerMetricsRecorder.java | 22 +-
.../oauth/flow/OAuthAuthorizationCodeFlow.java | 41 +
juneau-rest/juneau-rest-server-oidc-rp/pom.xml | 128 +++
.../juneau/rest/auth/oidc/rp/EphemeralStore.java | 154 +++
.../rest/auth/oidc/rp/IdTokenValidatorAdapter.java | 253 +++++
.../rest/auth/oidc/rp/InMemorySessionStore.java | 198 ++++
.../juneau/rest/auth/oidc/rp/OidcRelyingParty.java | 1018 ++++++++++++++++++++
.../juneau/rest/auth/oidc/rp/OidcSession.java | 91 ++
.../rest/auth/oidc/rp/OidcSessionAuthFilter.java | 87 ++
.../juneau/rest/auth/oidc/rp/SessionStore.java | 129 +++
.../auth/oidc/rp/SignedCookieSessionStore.java | 232 +++++
.../juneau/rest/auth/oidc/rp/package-info.java | 38 +
.../juneau/rest/tracing/otel/OtelTracerHook.java | 26 +
.../java/org/apache/juneau/rest/RestContext.java | 267 ++++-
.../java/org/apache/juneau/rest/RestOpContext.java | 147 ++-
.../java/org/apache/juneau/rest/RestOpInvoker.java | 24 +-
.../apache/juneau/rest/RestServerConstants.java | 37 +
.../org/apache/juneau/rest/annotation/Rest.java | 94 ++
.../juneau/rest/annotation/RestAnnotation.java | 38 +
.../apache/juneau/rest/annotation/RestDelete.java | 64 ++
.../rest/annotation/RestDeleteAnnotation.java | 76 ++
.../org/apache/juneau/rest/annotation/RestGet.java | 64 ++
.../juneau/rest/annotation/RestGetAnnotation.java | 76 ++
.../org/apache/juneau/rest/annotation/RestOp.java | 98 ++
.../juneau/rest/annotation/RestOpAnnotation.java | 76 ++
.../apache/juneau/rest/annotation/RestOptions.java | 64 ++
.../rest/annotation/RestOptionsAnnotation.java | 76 ++
.../apache/juneau/rest/annotation/RestPatch.java | 64 ++
.../rest/annotation/RestPatchAnnotation.java | 76 ++
.../apache/juneau/rest/annotation/RestPost.java | 64 ++
.../juneau/rest/annotation/RestPostAnnotation.java | 76 ++
.../org/apache/juneau/rest/annotation/RestPut.java | 64 ++
.../juneau/rest/annotation/RestPutAnnotation.java | 76 ++
.../juneau/rest/metrics/MetricsRecorder.java | 8 +-
.../juneau/rest/metrics/NoOpMetricsRecorder.java | 2 +-
.../rest/processor/AsyncResponseProcessor.java | 30 +-
.../juneau/rest/processor/MdcAsyncListener.java | 205 ++++
.../tracing/TraceContextResponseProcessor.java | 122 +++
juneau-rest/pom.xml | 1 +
juneau-utest/pom.xml | 11 +
.../bean/BeanPropertyMeta_AbstractSet_Test.java | 165 ++++
.../apache/juneau/commons/inject/Value_Test.java | 62 +-
.../apache/juneau/commons/logging/Logger_Test.java | 20 +
.../reflect/ParameterInfo_OptionalValue_Test.java | 184 ++++
.../observability/ObservabilityNoopBenchmark.java | 2 +-
.../rest/async/AsyncCompletionExecutor_Test.java | 325 +++++++
.../juneau/rest/auth/jwt/JwksCache_Test.java | 309 +++++-
.../auth/jwt/JwtTokenValidator_Builder_Test.java | 44 +
.../jwt/JwtTokenValidator_ValueAdoption_Test.java | 54 +-
.../rest/auth/oidc/rp/EphemeralStore_Test.java | 119 +++
.../auth/oidc/rp/IdTokenValidatorAdapter_Test.java | 112 +++
.../auth/oidc/rp/InMemorySessionStore_Test.java | 113 +++
.../rest/auth/oidc/rp/OidcRelyingParty_Test.java | 290 ++++++
.../juneau/rest/auth/oidc/rp/OidcTestSupport.java | 232 +++++
.../oidc/rp/SignedCookieSessionStore_Test.java | 107 ++
.../metrics/MetricsRecorder_Contract_Test.java | 6 +-
.../micrometer/MicrometerMetricsRecorder_Test.java | 12 +-
.../RestObservabilityAttributes_Test.java | 251 +++++
.../observability/RestObservabilitySvl_Test.java | 128 +++
.../TraceContextResponseProcessor_Test.java | 202 ++++
.../rest/processor/MdcAsyncListener_Test.java | 217 +++++
juneau-utest/test-run-history.tsv | 1 +
69 files changed, 7599 insertions(+), 104 deletions(-)
diff --git a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/bean/BeanPropertyMeta.java b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/bean/BeanPropertyMeta.java
index 74eea2c4aa..b232341d2a 100644
--- a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/bean/BeanPropertyMeta.java
+++ b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/bean/BeanPropertyMeta.java
@@ -1217,25 +1217,24 @@ public class BeanPropertyMeta implements Comparable<BeanPropertyMeta> {
if (propertyClass.isInstance(valueList) || (nn(setter) && setter.getParameterTypes().get(0).is(Collection.class))) {
if (! elementType.isObject()) {
- var l = new ArrayList<>(valueList);
- for (var i = l.listIterator(); i.hasNext();) {
- var v = i.next();
+ // Element coercion is required. Build the coerced elements into a concrete collection
+ // whose shape is assignable to the (abstract) property type — never a bare ArrayList —
+ // so Deque/Queue/SortedSet fields aren't handed an incompatible container. (The source
+ // value can incidentally satisfy propertyClass.isInstance(...) — e.g. the parser's
+ // generic list is a LinkedList, which is-a Deque/Queue — yet still need coercion.)
+ Collection coerced = createDefaultCollectionForAbstractType(propertyClass);
+ for (var v : valueList) {
var needsConversion = v == null ? elementType.isOptional() : ! elementType.isInstance(v);
- if (needsConversion)
- i.set(session.convertToType(v, elementType));
+ coerced.add(needsConversion ? session.convertToType(v, elementType) : v);
}
- valueList = l;
+ valueList = coerced;
}
invokeSetter(bean, pName, valueList);
return r;
}
+ // Robust best-effort-then-fallback materialization — never returns null (see helper Javadoc).
propList = createDefaultCollectionForAbstractType(propertyClass);
- if (propList == null) {
- throw bex(beanMeta.getBeanInfo(),
- "Cannot set property ''{0}'' of type ''{1}'' to object of type ''{2}'' because the assigned map cannot be converted to the specified type because the property type is abstract, and the property value is currently null",
- name, propertyClass.getName(), cn(value1));
- }
invokeSetter(bean, pName, propList);
}
propList.clear();
@@ -1370,16 +1369,48 @@ public class BeanPropertyMeta implements Comparable<BeanPropertyMeta> {
return beanMeta.getClassInfo().getName();
}
+ /**
+ * Returns a concrete {@link Collection} instance suitable for populating an abstract collection-typed bean
+ * property that has no setter and no {@code @BeanProp(type=...)} override.
+ *
+ * <p>
+ * Resolution is <b>best-effort-then-fallback</b>:
+ * <ol>
+ * <li><b>Best-effort materialization of the declared type.</b> First attempts to instantiate
+ * {@code propertyClass} directly via {@link BeanInstantiator} (preferring a zero-arg constructor). This
+ * succeeds when the declared type is itself instantiable — e.g. a concrete {@link AbstractSet} /
+ * {@link AbstractList} subclass with an accessible no-arg constructor, or any type
+ * {@code BeanInstantiator} can otherwise construct. The attempt never throws; on any failure it silently
+ * degrades to the shape-based fallback below.
+ * <li><b>Shape-based fallback.</b> When the declared type is genuinely not instantiable (a {@link Set} /
+ * {@link List} / {@link Queue} interface, a truly abstract class, or a class with no usable constructor),
+ * a default concrete type is chosen by collection shape:
+ * <ul>
+ * <li>{@link SortedSet} / {@link NavigableSet} → {@link TreeSet}
+ * <li>{@link Set} → {@link LinkedHashSet} (preserves insertion order)
+ * <li>{@link Deque} / {@link Queue} → {@link ArrayDeque}
+ * <li>{@link List} / raw {@link Collection} (and any other collection shape) → {@link ArrayList}
+ * </ul>
+ * </ol>
+ *
+ * @param propertyClass The declared (abstract or interface) collection type of the property.
+ * @return A new, empty, concrete {@link Collection} instance. Never <jk>null</jk>.
+ */
private Collection<?> createDefaultCollectionForAbstractType(Class<?> propertyClass) {
- if (propertyClass == SortedSet.class || propertyClass == NavigableSet.class)
+ // Best-effort: try to materialize the declared type itself (handles concrete AbstractSet/AbstractList
+ // subclasses, registered defaults, etc.). Never throws — degrades to the shape-based fallback on failure.
+ Collection declared = BeanInstantiator.of(Collection.class).type(info(propertyClass)).preferZeroArgConstructor().fallback(() -> null).run();
+ if (nn(declared))
+ return declared;
+
+ // Shape-based fallback for genuinely non-instantiable declared types.
+ if (SortedSet.class.isAssignableFrom(propertyClass) || NavigableSet.class.isAssignableFrom(propertyClass))
return new TreeSet<>();
- if (propertyClass == Set.class)
+ if (Set.class.isAssignableFrom(propertyClass))
return new LinkedHashSet<>();
- if (propertyClass == Deque.class || propertyClass == Queue.class)
+ if (Deque.class.isAssignableFrom(propertyClass) || Queue.class.isAssignableFrom(propertyClass))
return new ArrayDeque<>();
- if (propertyClass == List.class || propertyClass == Collection.class)
- return new ArrayList<>();
- return null;
+ return new ArrayList<>(); // List or raw Collection (and any other collection shape).
}
/**
diff --git a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/logging/LogRecordCapture.java b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/logging/LogRecordCapture.java
index 85c31bce7b..3a66a35d35 100644
--- a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/logging/LogRecordCapture.java
+++ b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/logging/LogRecordCapture.java
@@ -20,7 +20,6 @@ import java.io.Closeable;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
-import java.util.logging.Level;
/**
* Captures log records for testing purposes.
@@ -62,17 +61,6 @@ public class LogRecordCapture implements LogRecordListener, Closeable {
private final Logger logger;
private final List<LogRecord> records = Collections.synchronizedList(new ArrayList<>());
- /**
- * The logger level saved before capture started, restored on {@link #close()}.
- *
- * <p>
- * Saved and restored so that {@link Logger#isLoggable(Level)} always returns {@code true}
- * for every level while capture is active. Without this, certain JDK 25 implementations
- * skip the {@code doLog} body when the effective level is coarser than the logged level
- * (e.g. INFO when a FINE record is emitted), resulting in 0 captured records.
- */
- private final Level savedLevel;
-
/**
* Constructor.
*
@@ -80,10 +68,6 @@ public class LogRecordCapture implements LogRecordListener, Closeable {
*/
LogRecordCapture(Logger logger) {
this.logger = logger;
- this.savedLevel = logger.getLevel();
- // Force level to ALL so every log record reaches our listener regardless
- // of the logger's configured level on any JDK version.
- logger.setLevel(Level.ALL);
logger.addLogRecordListener(this);
}
@@ -152,11 +136,10 @@ public class LogRecordCapture implements LogRecordListener, Closeable {
}
/**
- * Closes this capture, removes it from the logger's listeners, and restores the logger level.
+ * Closes this capture and removes it from the logger's listeners.
*/
@Override
public void close() {
logger.removeLogRecordListener(this);
- logger.setLevel(savedLevel);
}
}
diff --git a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/logging/Logger.java b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/logging/Logger.java
index 12aeb7b4da..0f3e84396e 100644
--- a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/logging/Logger.java
+++ b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/logging/Logger.java
@@ -22,10 +22,10 @@ import static java.util.logging.Level.*;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
+import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Handler;
import java.util.logging.Level;
-import org.apache.juneau.commons.collections.Cache;
import org.apache.juneau.commons.utils.Utils;
/**
@@ -84,13 +84,18 @@ import org.apache.juneau.commons.utils.Utils;
public class Logger extends java.util.logging.Logger {
/**
- * Cache of logger instances by name.
+ * Permanent registry of logger instances by name.
*
* <p>
- * Logger instances are cached and reused for the same name, ensuring that listeners
- * attached to a logger persist across multiple calls to {@link #getLogger(String)}.
+ * Uses an unbounded {@link ConcurrentHashMap} so every name maps to exactly one
+ * {@link Logger} instance for the JVM lifetime. An evictable cache (e.g. one with a
+ * {@code maxSize} that clears the whole map) would cause a class-loaded
+ * {@code static final Logger} field to refer to an instance that is no longer in the
+ * registry; a subsequent call to {@link #getLogger(String)} would then return a new,
+ * different instance, breaking {@link LogRecordCapture} and any other code that
+ * assumes one name == one identity.
*/
- private static final Cache<String, Logger> loggers = Cache.of(String.class, Logger.class).supplier(k -> new Logger(java.util.logging.Logger.getLogger(k))).build();
+ private static final ConcurrentHashMap<String, Logger> loggers = new ConcurrentHashMap<>();
/**
* The underlying logger instance that we delegate to.
@@ -125,7 +130,7 @@ public class Logger extends java.util.logging.Logger {
* @return A logger instance (cached and reused for the same name).
*/
public static Logger getLogger(String name) {
- return loggers.get(name);
+ return loggers.computeIfAbsent(name, k -> new Logger(java.util.logging.Logger.getLogger(k)));
}
/**
diff --git a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/reflect/ParameterInfo.java b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/reflect/ParameterInfo.java
index 6105948335..822c21e947 100644
--- a/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/reflect/ParameterInfo.java
+++ b/juneau-core/juneau-commons/src/main/java/org/apache/juneau/commons/reflect/ParameterInfo.java
@@ -904,7 +904,15 @@ public class ParameterInfo extends ElementInfo implements Annotatable {
// Supplier<String> field/parameter type opts in to re-evaluating reads.
// BeanStore is forwarded so caller-scoped PropertySource beans (e.g. per-RestContext
// @Rest(config=...) Configs) participate in expression resolution alongside Settings.
- return ValueResolver.resolve(valueExpr, ptu.inner(), inner.getParameterizedType(), this.toString(), beanStore);
+ var resolved = ValueResolver.resolve(valueExpr, ptu.inner(), inner.getParameterizedType(), this.toString(), beanStore);
+ // Mirror FieldInfo.inject: wrap in Optional when the declared parameter type is Optional<T>.
+ // VarResolver substitutes "" for a missing key with no default — collapse both null and ""
+ // to Optional.empty() so @Value("${maybe}") Optional<T> behaves the same as Spring's.
+ if (pt.is(Optional.class))
+ return (resolved == null || (resolved instanceof String s && s.isEmpty()))
+ ? Optional.empty()
+ : Optional.of(resolved);
+ return resolved;
}
if (JsrSupport.isProviderType(ptu.inner())) {
diff --git a/juneau-distrib/pom.xml b/juneau-distrib/pom.xml
index 18823486ed..ecd2b7e729 100644
--- a/juneau-distrib/pom.xml
+++ b/juneau-distrib/pom.xml
@@ -558,6 +558,31 @@
<destFileName>org.apache.juneau.rest.server.oauth_${project.version}.jar</destFileName>
</artifactItem>
+ <!-- juneau-rest-server-oidc-rp -->
+ <artifactItem>
+ <outputDirectory>${project.build.directory}/src/lib</outputDirectory>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-rest-server-oidc-rp</artifactId>
+ <version>${project.version}</version>
+ <type>jar</type>
+ <classifier>sources</classifier>
+ <destFileName>apache-juneau-rest-server-oidc-rp-${project.version}-sources.jar</destFileName>
+ </artifactItem>
+ <artifactItem>
+ <outputDirectory>${project.build.directory}/bin/lib</outputDirectory>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-rest-server-oidc-rp</artifactId>
+ <version>${project.version}</version>
+ <destFileName>apache-juneau-rest-server-oidc-rp-${project.version}.jar</destFileName>
+ </artifactItem>
+ <artifactItem>
+ <outputDirectory>${project.build.directory}/bin/osgi</outputDirectory>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-rest-server-oidc-rp</artifactId>
+ <version>${project.version}</version>
+ <destFileName>org.apache.juneau.rest.server.oidc.rp_${project.version}.jar</destFileName>
+ </artifactItem>
+
<!-- juneau-rest-server-view-jsp -->
<artifactItem>
<outputDirectory>${project.build.directory}/src/lib</outputDirectory>
diff --git a/juneau-rest/juneau-rest-server-jwt/src/main/java/org/apache/juneau/rest/auth/jwt/JwksCache.java b/juneau-rest/juneau-rest-server-jwt/src/main/java/org/apache/juneau/rest/auth/jwt/JwksCache.java
index ec40eb41c0..b6d3c9653b 100644
--- a/juneau-rest/juneau-rest-server-jwt/src/main/java/org/apache/juneau/rest/auth/jwt/JwksCache.java
+++ b/juneau-rest/juneau-rest-server-jwt/src/main/java/org/apache/juneau/rest/auth/jwt/JwksCache.java
@@ -18,6 +18,7 @@ package org.apache.juneau.rest.auth.jwt;
import java.time.*;
import java.util.*;
+import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
import java.util.logging.*;
@@ -27,7 +28,8 @@ import com.nimbusds.jose.jwk.source.*;
import com.nimbusds.jose.proc.*;
/**
- * Time-bounded cache of {@link JWKSet} keys with graceful-degradation on fetch failure.
+ * Time-bounded cache of {@link JWKSet} keys with graceful-degradation on fetch failure
+ * and optional eager refresh on a key-id ({@code kid}) cache miss.
*
* <p>
* Wraps a {@link JWKSource} so that:
@@ -36,6 +38,22 @@ import com.nimbusds.jose.proc.*;
* <li>If the underlying source fails (network down, JWKS endpoint returning 5xx), the cache
* continues to return the last-known-good key set with a warning logged at {@code WARNING}
* level. This avoids correlated auth outages caused by transient network blips.
+ * <li>When {@code eagerRefreshOnKidMiss} is {@code true} (the default when constructed via
+ * {@link JwtTokenValidator.Builder}), a fresh-cache selection that returns no matching keys
+ * triggers a single out-of-band JWKS refresh before the TTL would normally expire. This
+ * eliminates the rotation window where an IdP publishes a new signing key mid-TTL and clients
+ * presenting tokens signed with that key would otherwise receive spurious 401 responses (up to
+ * the full TTL = 5 minutes by default). The eager refresh is bounded by two guards:
+ * <ul>
+ * <li>A <em>per-cache cooldown</em> (default 10 seconds) that caps how often the JWKS
+ * endpoint can be hit during a miss burst or an endpoint outage.
+ * <li>A <em>shared in-flight {@link CompletableFuture}</em> so at most one network request
+ * runs concurrently; all other threads experiencing the same miss wait for (and share)
+ * the result of that single fetch.
+ * </ul>
+ * <li>This feature applies only when the cache is constructed via
+ * {@link JwtTokenValidator.Builder#jwksUrl(java.net.URI) jwksUrl(...)}. A caller-supplied
+ * {@link JwtTokenValidator.Builder#jwkSource(JWKSource) jwkSource(...)} is unaffected.
* </ul>
*
* <p>
@@ -61,25 +79,51 @@ final class JwksCache implements JWKSource<SecurityContext> {
/** Selector that pulls every key from the upstream source. */
private static final JWKSelector SELECT_ALL = new JWKSelector(new JWKMatcher.Builder().build());
+ /**
+ * Maximum wait for a loser thread joining an in-flight eager refresh. Slightly longer than a
+ * typical HTTP read timeout so a misbehaving delegate can't pin a request thread indefinitely.
+ */
+ private static final long IN_FLIGHT_TIMEOUT_SECONDS = 10;
+
private final JWKSource<SecurityContext> delegate;
private final Duration ttl;
private final Clock clock;
+ private final boolean eagerRefreshOnKidMiss;
+ private final Duration eagerRefreshCooldown;
private final AtomicReference<Entry> slot = new AtomicReference<>();
- JwksCache(JWKSource<SecurityContext> delegate, Duration ttl, Clock clock) {
+ /** Epoch of the most recent eager-refresh attempt; {@link Instant#MIN} initially (first attempt always allowed). */
+ private final AtomicReference<Instant> lastEagerAttempt = new AtomicReference<>(Instant.MIN);
+
+ /**
+ * In-flight eager-refresh future; {@code null} when idle. Set via CAS to deduplicate concurrent
+ * miss requests: the CAS winner does the single network fetch; losers await this future.
+ */
+ private final AtomicReference<CompletableFuture<JWKSet>> inFlight = new AtomicReference<>();
+
+ JwksCache(JWKSource<SecurityContext> delegate, Duration ttl, Clock clock,
+ boolean eagerRefreshOnKidMiss, Duration eagerRefreshCooldown) {
this.delegate = delegate;
this.ttl = ttl;
this.clock = clock;
+ this.eagerRefreshOnKidMiss = eagerRefreshOnKidMiss;
+ this.eagerRefreshCooldown = eagerRefreshCooldown;
}
@Override /* Overridden from JWKSource */
public List<JWK> get(JWKSelector selector, SecurityContext ctx) throws KeySourceException {
var now = clock.instant();
var current = slot.get();
- if (current != null && current.fetchedAt.plus(ttl).isAfter(now))
- return selector.select(current.jwkSet);
+ if (current != null && current.fetchedAt.plus(ttl).isAfter(now)) {
+ // Fresh-cache path: serve from cache unless we get an empty selection and eager is on.
+ var hits = selector.select(current.jwkSet);
+ if (!hits.isEmpty() || !eagerRefreshOnKidMiss)
+ return hits;
+ return tryEagerRefresh(selector, ctx, now);
+ }
+ // Expired / cold path — unchanged from pre-9.5.0 behavior.
try {
- var refreshed = new JWKSet(delegate.get(SELECT_ALL, ctx));
+ var refreshed = fetchFromDelegate(ctx);
slot.set(new Entry(refreshed, now));
return selector.select(refreshed);
} catch (KeySourceException | RuntimeException e) {
@@ -92,5 +136,64 @@ final class JwksCache implements JWKSource<SecurityContext> {
}
}
+ /**
+ * Attempts a single eager refresh when the fresh cache produced no keys for the selector.
+ *
+ * <ul>
+ * <li>Cooldown gate: returns empty immediately if a prior attempt was within the cooldown window.
+ * <li>CAS winner: runs the one network fetch, updates the slot, completes the shared future.
+ * <li>CAS losers: wait for the winner's future (bounded timeout), then select from its result.
+ * <li>On outage: retains the last-known-good slot, logs WARNING, returns empty (→ 401).
+ * <li>{@code lastEagerAttempt} is always advanced in the {@code finally} block so a burst of
+ * distinct unknown kids cannot bypass the cooldown.
+ * </ul>
+ */
+ private List<JWK> tryEagerRefresh(JWKSelector selector, SecurityContext ctx, Instant now) {
+ if (now.isBefore(lastEagerAttempt.get().plus(eagerRefreshCooldown))) {
+ LOG.fine(() -> "JWKS eager refresh skipped (within cooldown)");
+ return Collections.emptyList();
+ }
+ var myFuture = new CompletableFuture<JWKSet>();
+ var prior = inFlight.compareAndExchange(null, myFuture);
+ if (prior != null)
+ return awaitAndSelect(selector, prior);
+
+ // CAS winner: run the single eager fetch.
+ try {
+ LOG.fine(() -> "JWKS eager refresh triggered by kid miss");
+ var refreshed = fetchFromDelegate(ctx);
+ slot.set(new Entry(refreshed, now));
+ myFuture.complete(refreshed);
+ return selector.select(refreshed);
+ } catch (KeySourceException | RuntimeException e) {
+ myFuture.completeExceptionally(e);
+ if (slot.get() != null)
+ LOG.log(Level.WARNING, e,
+ () -> "JWKS eager refresh failed; retaining last-known-good set. Underlying error: " + e.getMessage());
+ return Collections.emptyList();
+ } finally {
+ // Advance cooldown on every attempt (success, still-missing, or outage) so a burst of
+ // unknown kids cannot hammer the endpoint within one cooldown window.
+ lastEagerAttempt.set(now);
+ inFlight.compareAndSet(myFuture, null);
+ }
+ }
+
+ /** Loser path: wait for the in-flight future and select from its result. */
+ private static List<JWK> awaitAndSelect(JWKSelector selector, CompletableFuture<JWKSet> future) {
+ try {
+ return selector.select(future.get(IN_FLIGHT_TIMEOUT_SECONDS, TimeUnit.SECONDS));
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return Collections.emptyList();
+ } catch (ExecutionException | TimeoutException e) {
+ return Collections.emptyList();
+ }
+ }
+
+ private JWKSet fetchFromDelegate(SecurityContext ctx) throws KeySourceException {
+ return new JWKSet(delegate.get(SELECT_ALL, ctx));
+ }
+
private record Entry(JWKSet jwkSet, Instant fetchedAt) {}
}
diff --git a/juneau-rest/juneau-rest-server-jwt/src/main/java/org/apache/juneau/rest/auth/jwt/JwtTokenValidator.java b/juneau-rest/juneau-rest-server-jwt/src/main/java/org/apache/juneau/rest/auth/jwt/JwtTokenValidator.java
index 6f304c09cb..53c66f5ae3 100644
--- a/juneau-rest/juneau-rest-server-jwt/src/main/java/org/apache/juneau/rest/auth/jwt/JwtTokenValidator.java
+++ b/juneau-rest/juneau-rest-server-jwt/src/main/java/org/apache/juneau/rest/auth/jwt/JwtTokenValidator.java
@@ -53,7 +53,15 @@ import com.nimbusds.jwt.proc.*;
* <li><b>Clock skew</b> — defaults to 60 seconds tolerance for {@code exp} and {@code nbf}.
* Capped at 300 seconds by the builder.
* <li><b>JWKS caching</b> — keys are fetched and cached for 5 minutes by default. On fetch failure
- * the cache continues serving past TTL with a warning logged (see {@link JwksCache}).
+ * the cache continues serving past TTL with a warning logged (see {@link JwksCache}). When a
+ * fresh-cache selection returns no keys (typically because an IdP rotated its signing key
+ * mid-TTL), the cache performs one eager out-of-band refresh before failing the request. This
+ * behavior is <b>enabled by default</b> ({@link Builder#jwksEagerRefreshOnKidMiss(boolean)
+ * jwksEagerRefreshOnKidMiss(true)}) and bounded by a per-cache cooldown (default 10 seconds)
+ * plus a single-in-flight guard. Opt out via
+ * {@link Builder#jwksEagerRefreshOnKidMiss(boolean) jwksEagerRefreshOnKidMiss(false)}.
+ * Applies only to {@link Builder#jwksUrl(java.net.URI) jwksUrl(...)}-backed caches; a
+ * caller-supplied {@link Builder#jwkSource(JWKSource) jwkSource(...)} is unaffected.
* </ul>
*
* <h5 class='topic'>Usage</h5>
@@ -141,6 +149,22 @@ public class JwtTokenValidator implements TokenValidator {
@Value("${juneau.jwt.jwksCacheTtl:PT5M}")
Duration jwksCacheTtl;
+ /**
+ * Whether to perform an eager JWKS refresh on a {@code kid} cache miss; populated via
+ * {@link Value @Value} from {@code juneau.jwt.jwksEagerRefreshOnKidMiss} (boolean, default
+ * {@code true}).
+ */
+ @Value("${juneau.jwt.jwksEagerRefreshOnKidMiss:true}")
+ boolean jwksEagerRefreshOnKidMiss;
+
+ /**
+ * Minimum interval between eager JWKS refreshes; populated via {@link Value @Value} from
+ * {@code juneau.jwt.jwksEagerRefreshCooldown} (ISO-8601 duration, default {@code PT10S} = 10
+ * seconds).
+ */
+ @Value("${juneau.jwt.jwksEagerRefreshCooldown:PT10S}")
+ Duration jwksEagerRefreshCooldown;
+
private Clock clock = Clock.systemUTC();
/** Constructor. */
@@ -255,6 +279,55 @@ public class JwtTokenValidator implements TokenValidator {
return this;
}
+ /**
+ * Enables or disables the eager JWKS refresh that fires on a fresh-cache {@code kid} miss.
+ *
+ * <p>
+ * When {@code true} (the default), the first request that presents a token whose signing
+ * key is not in the current (still-fresh) cache triggers a single out-of-band JWKS refresh.
+ * This closes the rotation window where an IdP publishes a new key mid-TTL and clients would
+ * otherwise see up to {@code jwksCacheTtl} minutes of spurious 401 responses for perfectly
+ * valid tokens. The refresh is bounded by {@link #jwksEagerRefreshCooldown(Duration)} and a
+ * single-in-flight guard (no thundering herd).
+ *
+ * <p>
+ * Applies only to {@link #jwksUrl(java.net.URI) jwksUrl(...)}-backed caches. A
+ * caller-supplied {@link #jwkSource(JWKSource) jwkSource(...)} is unaffected.
+ *
+ * @param value {@code true} to enable (default); {@code false} to restore pre-9.5.0 behavior.
+ * @return This object.
+ */
+ public Builder jwksEagerRefreshOnKidMiss(boolean value) {
+ jwksEagerRefreshOnKidMiss = value;
+ return this;
+ }
+
+ /**
+ * Sets the minimum spacing between consecutive eager JWKS refreshes.
+ *
+ * <p>
+ * A burst of unknown-{@code kid} tokens (e.g. misconfigured client, replay attack) or a
+ * JWKS-endpoint outage will trigger at most one eager refresh per cooldown window. This
+ * prevents the eager-refresh path from becoming a vector for amplified JWKS-endpoint load.
+ *
+ * <p>
+ * Must be positive, at most 60 seconds, and at most {@link #jwksCacheTtl(Duration)
+ * jwksCacheTtl} (validated at {@link #build()} time).
+ *
+ * @param value The cooldown. Must not be {@code null}.
+ * @return This object.
+ */
+ public Builder jwksEagerRefreshCooldown(Duration value) {
+ assertArgNotNull("value", value);
+ if (value.isZero() || value.isNegative())
+ throw new IllegalArgumentException("jwksEagerRefreshCooldown must be positive");
+ if (value.compareTo(Duration.ofSeconds(60)) > 0)
+ throw new IllegalArgumentException(
+ "jwksEagerRefreshCooldown must not exceed 60 seconds (was " + value + ")");
+ jwksEagerRefreshCooldown = value;
+ return this;
+ }
+
/**
* Overrides the {@link Clock} used for {@code exp} / {@code nbf} comparisons and JWKS cache
* TTL. Useful in tests.
@@ -281,6 +354,10 @@ public class JwtTokenValidator implements TokenValidator {
throw new IllegalStateException("JwtTokenValidator requires either jwksUrl(...) or jwkSource(...)");
if (jwksUrl != null && jwkSource != null)
throw new IllegalStateException("JwtTokenValidator: jwksUrl(...) and jwkSource(...) are mutually exclusive");
+ if (jwksEagerRefreshCooldown.compareTo(jwksCacheTtl) > 0)
+ throw new IllegalStateException(
+ "jwksEagerRefreshCooldown (" + jwksEagerRefreshCooldown
+ + ") must not exceed jwksCacheTtl (" + jwksCacheTtl + ")");
return new JwtTokenValidator(this);
}
}
@@ -305,7 +382,8 @@ public class JwtTokenValidator implements TokenValidator {
this.clock = builder.clock;
this.jwkSource = builder.jwkSource != null
? builder.jwkSource
- : new JwksCache(createRemoteJwkSource(builder.jwksUrl), builder.jwksCacheTtl, builder.clock);
+ : new JwksCache(createRemoteJwkSource(builder.jwksUrl), builder.jwksCacheTtl, builder.clock,
+ builder.jwksEagerRefreshOnKidMiss, builder.jwksEagerRefreshCooldown);
}
/**
diff --git a/juneau-rest/juneau-rest-server-micrometer/src/main/java/org/apache/juneau/rest/metrics/micrometer/MicrometerMetricsRecorder.java b/juneau-rest/juneau-rest-server-micrometer/src/main/java/org/apache/juneau/rest/metrics/micrometer/MicrometerMetricsRecorder.java
index 4ab8d65922..f05e88e7da 100644
--- a/juneau-rest/juneau-rest-server-micrometer/src/main/java/org/apache/juneau/rest/metrics/micrometer/MicrometerMetricsRecorder.java
+++ b/juneau-rest/juneau-rest-server-micrometer/src/main/java/org/apache/juneau/rest/metrics/micrometer/MicrometerMetricsRecorder.java
@@ -82,6 +82,12 @@ import io.micrometer.core.instrument.*;
* <jk>new</jk> MicrometerMetricsRecorder(<jv>r</jv>, <js>"my.service.http.timer"</js>)
* </p>
*
+ * <p>
+ * Per-operation overrides are available via {@link org.apache.juneau.rest.annotation.RestOp#metricName()}.
+ * When a non-empty {@code metricName} is passed to {@code record(...)}, it takes precedence over the
+ * recorder-level timer name. Per-op tags added via {@link org.apache.juneau.rest.annotation.RestOp#metricTags()}
+ * are merged into the standard tag set as additional {@code key=value} pairs.
+ *
* <h5 class='section'>See Also:</h5><ul>
* <li class='jc'>{@link MetricsRecorder}
* <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/RestServerObservability">REST Server — Observability (Micrometer + OpenTelemetry)</a>
@@ -144,14 +150,20 @@ public class MicrometerMetricsRecorder implements MetricsRecorder {
public String getTimerName() { return timerName; }
@Override /* MetricsRecorder */
- public void record(String opName, String httpMethod, String uriTemplate, int statusCode, Duration elapsed, Throwable error) {
- Timer.builder(timerName)
+ public void record(String opName, String httpMethod, String uriTemplate, int statusCode, Duration elapsed, Throwable error, String metricName, String metricTags) {
+ var effectiveName = (metricName != null && !metricName.isEmpty()) ? metricName : timerName;
+ var builder = Timer.builder(effectiveName)
.tag(TAG_METHOD, defaultIfBlank(httpMethod, ""))
.tag(TAG_URI, defaultIfBlank(uriTemplate, ""))
.tag(TAG_STATUS, Integer.toString(statusCode))
- .tag(TAG_EXCEPTION, exceptionTag(error))
- .register(registry)
- .record(elapsed);
+ .tag(TAG_EXCEPTION, exceptionTag(error));
+ if (metricTags != null && !metricTags.isEmpty())
+ for (var pair : metricTags.split(",")) {
+ var kv = pair.split("=", 2);
+ if (kv.length == 2)
+ builder = builder.tag(kv[0].strip(), kv[1].strip());
+ }
+ builder.register(registry).record(elapsed);
}
private static String exceptionTag(Throwable error) {
diff --git a/juneau-rest/juneau-rest-server-oauth/src/main/java/org/apache/juneau/rest/auth/oauth/flow/OAuthAuthorizationCodeFlow.java b/juneau-rest/juneau-rest-server-oauth/src/main/java/org/apache/juneau/rest/auth/oauth/flow/OAuthAuthorizationCodeFlow.java
index 63307f0244..78fca82722 100644
--- a/juneau-rest/juneau-rest-server-oauth/src/main/java/org/apache/juneau/rest/auth/oauth/flow/OAuthAuthorizationCodeFlow.java
+++ b/juneau-rest/juneau-rest-server-oauth/src/main/java/org/apache/juneau/rest/auth/oauth/flow/OAuthAuthorizationCodeFlow.java
@@ -32,6 +32,8 @@ import com.nimbusds.oauth2.sdk.http.HTTPRequest;
import com.nimbusds.oauth2.sdk.id.ClientID;
import com.nimbusds.oauth2.sdk.id.State;
import com.nimbusds.oauth2.sdk.pkce.*;
+import com.nimbusds.openid.connect.sdk.AuthenticationRequest;
+import com.nimbusds.openid.connect.sdk.Nonce;
/**
* Authorization-code grant (RFC 6749 §4.1) flow helper with mandatory PKCE per RFC 7636.
@@ -249,6 +251,45 @@ public class OAuthAuthorizationCodeFlow {
return request.toURI();
}
+ /**
+ * Builds an OpenID Connect {@code authorize?...} URL carrying an OIDC {@code nonce} parameter.
+ *
+ * <p>
+ * Unlike {@link #buildAuthorizationUrl(String, CodeChallenge)} (which emits a plain OAuth 2.0
+ * {@link AuthorizationRequest} with no {@code nonce}), this method emits an OIDC
+ * {@link AuthenticationRequest} so the IdP echoes the supplied {@code nonce} back inside the issued
+ * ID token. An OpenID Connect relying party stores the {@code nonce} before the redirect and
+ * verifies the {@code nonce} claim on the returned ID token to defeat token replay.
+ *
+ * <p>
+ * The configured {@code scope(...)} set is used as-is; OIDC requires it to contain {@code openid}.
+ * The optional {@code customizer} receives the underlying {@link AuthenticationRequest.Builder} so
+ * callers can set OIDC-specific parameters (e.g. {@code prompt}, {@code max_age}, {@code acr_values})
+ * without this module needing to surface each one.
+ *
+ * @param state The opaque {@code state} parameter (CSRF protection). Must not be <jk>null</jk> or
+ * blank.
+ * @param codeChallenge The PKCE code challenge. Must not be <jk>null</jk>. Always uses S256.
+ * @param nonce The OIDC {@code nonce} value. Must not be <jk>null</jk> or blank.
+ * @param customizer Optional hook on the {@link AuthenticationRequest.Builder} for OIDC-specific
+ * parameters. May be <jk>null</jk>.
+ * @return The authorization URL.
+ */
+ public URI buildAuthenticationUrl(String state, CodeChallenge codeChallenge, String nonce, Consumer<AuthenticationRequest.Builder> customizer) {
+ assertArgNotNullOrBlank("state", state);
+ assertArgNotNull("codeChallenge", codeChallenge);
+ assertArgNotNullOrBlank("nonce", nonce);
+ Scope nimbusScope = scopes.isEmpty() ? new Scope("openid") : new Scope(scopes.toArray(new String[0]));
+ var builder = new AuthenticationRequest.Builder(new ResponseType(ResponseType.Value.CODE), nimbusScope, new ClientID(clientId), redirectUri)
+ .endpointURI(authorizationEndpoint)
+ .state(new State(state))
+ .nonce(new Nonce(nonce))
+ .codeChallenge(codeChallenge, CodeChallengeMethod.S256);
+ if (customizer != null)
+ customizer.accept(builder);
+ return builder.build().toURI();
+ }
+
/**
* Exchanges the authorization code returned by the IdP for an access token.
*
diff --git a/juneau-rest/juneau-rest-server-oidc-rp/pom.xml b/juneau-rest/juneau-rest-server-oidc-rp/pom.xml
new file mode 100644
index 0000000000..00d879d3fe
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-oidc-rp/pom.xml
@@ -0,0 +1,128 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ Licensed to the Apache Software Foundation (ASF) under one or more
+ contributor license agreements. See the NOTICE file distributed with
+ this work for additional information regarding copyright ownership.
+ The ASF licenses this file to You under the Apache License, Version 2.0
+ (the "License"); you may not use this file except in compliance with
+ the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+-->
+<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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+ <modelVersion>4.0.0</modelVersion>
+
+ <parent>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-rest</artifactId>
+ <version>9.5.0-SNAPSHOT</version>
+ </parent>
+
+ <artifactId>juneau-rest-server-oidc-rp</artifactId>
+ <name>Apache Juneau REST Server OIDC Relying Party</name>
+ <description>Apache Juneau REST Server - OpenID Connect Relying Party login (opt-in, Nimbus SDK)</description>
+ <packaging>bundle</packaging>
+
+ <properties>
+ <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+ <!--
+ Nimbus OAuth 2.0 SDK pin - kept on the same 11.37.2 line as juneau-rest-server-oauth
+ (see FINISHED-94c). A future bump must re-run the dependency:tree containment check.
+ -->
+ <nimbus-oauth2-oidc-sdk.version>11.37.2</nimbus-oauth2-oidc-sdk.version>
+ </properties>
+
+ <dependencies>
+ <!--
+ Compile dependency on the OAuth building-block module (FINISHED-94c): OidcDiscoveryClient,
+ OAuthAuthorizationCodeFlow, OAuthRefreshTokenFlow, OAuthToken. This transitively brings
+ juneau-rest-server (the AuthFilter framework + ClaimsPrincipal) onto the classpath.
+ -->
+ <dependency>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-rest-server-oauth</artifactId>
+ <version>${project.version}</version>
+ </dependency>
+
+ <!--
+ Nimbus OAuth 2.0 SDK is declared `provided` so it does NOT leak as a transitive dependency
+ of this module or any upstream module. Mirrors the FINISHED-94c containment pattern - the
+ containment check `mvn -pl juneau-rest/juneau-rest-server dependency:tree | grep -iE
+ "(nimbusds|oauth2-oidc)"` must return nothing.
+
+ Brings nimbus-jose-jwt transitively (also contained), which this module uses for the
+ ID-token / logout-token signature handling via Nimbus's validators.
+ -->
+ <dependency>
+ <groupId>com.nimbusds</groupId>
+ <artifactId>oauth2-oidc-sdk</artifactId>
+ <version>${nimbus-oauth2-oidc-sdk.version}</version>
+ <scope>provided</scope>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-source-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>attach-sources</id>
+ <phase>verify</phase>
+ <goals>
+ <goal>jar-no-fork</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.felix</groupId>
+ <artifactId>maven-bundle-plugin</artifactId>
+ <extensions>true</extensions>
+ <configuration>
+ <supportIncrementalBuild>true</supportIncrementalBuild>
+ </configuration>
+ <executions>
+ <execution>
+ <id>bundle-manifest</id>
+ <phase>process-classes</phase>
+ <goals>
+ <goal>manifest</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-jar-plugin</artifactId>
+ </plugin>
+ <plugin>
+ <groupId>org.jacoco</groupId>
+ <artifactId>jacoco-maven-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>default-prepare-agent</id>
+ <goals>
+ <goal>prepare-agent</goal>
+ </goals>
+ </execution>
+ <execution>
+ <id>default-report</id>
+ <phase>prepare-package</phase>
+ <goals>
+ <goal>report</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+</project>
diff --git a/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/EphemeralStore.java b/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/EphemeralStore.java
new file mode 100644
index 0000000000..1e88198364
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/EphemeralStore.java
@@ -0,0 +1,154 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.auth.oidc.rp;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+
+import java.time.*;
+import java.util.*;
+
+/**
+ * Single-use, TTL-bounded store for the per-login {@code state} → {@code (nonce, codeVerifier,
+ * redirectTarget)} association created during {@link OidcRelyingParty#startLogin} and consumed during
+ * {@link OidcRelyingParty#completeLogin}.
+ *
+ * <p>
+ * Security properties (see OpenID Connect Core §3.1.2.1 and the RP charter):
+ * <ul>
+ * <li><b>Single-use</b> — {@link #consume(String)} atomically removes the entry, so a replayed
+ * callback with the same {@code state} fails.
+ * <li><b>TTL-bounded</b> — entries older than the configured TTL are treated as a miss and swept.
+ * <li><b>Size-bounded</b> — an LRU cap (default 10 000) prevents unbounded growth from abandoned
+ * login attempts; eviction mirrors the {@code BoundedLruTokenCache} shape from FINISHED-94c.
+ * </ul>
+ *
+ * <p>
+ * Thread-safe.
+ *
+ * @since 9.5.0
+ */
+public class EphemeralStore {
+
+ /** Default maximum number of in-flight login attempts retained. */
+ public static final int DEFAULT_MAX_ENTRIES = 10_000;
+
+ /** Default time-to-live for a pending login (the user has at most a few minutes at the IdP). */
+ public static final Duration DEFAULT_TTL = Duration.ofMinutes(5);
+
+ /** Hard cap on the configurable TTL. */
+ static final Duration MAX_TTL = Duration.ofMinutes(30);
+
+ /**
+ * A pending-login association awaiting the IdP callback.
+ *
+ * @param nonce The OIDC {@code nonce} value bound into the authorization request.
+ * @param codeVerifier The PKCE {@code code_verifier} string.
+ * @param redirectTarget The application URL to redirect to after a successful login. May be
+ * {@code null}.
+ * @param createdAt The instant the entry was stored.
+ */
+ public record PendingLogin(String nonce, String codeVerifier, String redirectTarget, Instant createdAt) {}
+
+ private final int maxEntries;
+ private final Duration ttl;
+ private final Clock clock;
+ private final Map<String,PendingLogin> entries;
+ private final Object lock = new Object();
+
+ /**
+ * Constructor.
+ *
+ * @param ttl The single-use TTL. Must be positive and not exceed 30 minutes.
+ * @param maxEntries The LRU size cap. Must be positive.
+ * @param clock The clock for TTL comparisons. Must not be <jk>null</jk>.
+ */
+ public EphemeralStore(Duration ttl, int maxEntries, Clock clock) {
+ assertArgNotNull("ttl", ttl);
+ assertArg(!ttl.isZero() && !ttl.isNegative(), "ttl must be positive");
+ assertArg(ttl.compareTo(MAX_TTL) <= 0, "ttl must not exceed 30 minutes (was {0})", ttl);
+ assertArg(maxEntries > 0, "maxEntries must be positive (was {0})", maxEntries);
+ this.ttl = ttl;
+ this.maxEntries = maxEntries;
+ this.clock = assertArgNotNull("clock", clock);
+ this.entries = new LinkedHashMap<>(16, 0.75f, true) {
+ private static final long serialVersionUID = 1L;
+ @Override
+ protected boolean removeEldestEntry(Map.Entry<String,PendingLogin> eldest) {
+ return size() > EphemeralStore.this.maxEntries;
+ }
+ };
+ }
+
+ /**
+ * Stores a pending-login association keyed by {@code state}.
+ *
+ * @param state The opaque {@code state} value. Must not be <jk>null</jk> or blank.
+ * @param nonce The OIDC {@code nonce}. Must not be <jk>null</jk> or blank.
+ * @param codeVerifier The PKCE {@code code_verifier}. Must not be <jk>null</jk> or blank.
+ * @param redirectTarget The post-login redirect target. May be <jk>null</jk>.
+ */
+ public void store(String state, String nonce, String codeVerifier, String redirectTarget) {
+ assertArgNotNullOrBlank("state", state);
+ assertArgNotNullOrBlank("nonce", nonce);
+ assertArgNotNullOrBlank("codeVerifier", codeVerifier);
+ var now = clock.instant();
+ synchronized (lock) {
+ sweepExpired(now);
+ entries.put(state, new PendingLogin(nonce, codeVerifier, redirectTarget, now));
+ }
+ }
+
+ /**
+ * Atomically removes and returns the pending-login association for {@code state}, if present and
+ * not expired.
+ *
+ * @param state The {@code state} value from the callback. Must not be <jk>null</jk>.
+ * @return The association, or {@link Optional#empty()} if absent or expired.
+ */
+ public Optional<PendingLogin> consume(String state) {
+ if (state == null)
+ return Optional.empty();
+ var now = clock.instant();
+ synchronized (lock) {
+ var p = entries.remove(state);
+ if (p == null)
+ return Optional.empty();
+ if (isExpired(p, now))
+ return Optional.empty();
+ return Optional.of(p);
+ }
+ }
+
+ /**
+ * Returns the current entry count. Primarily for tests and metrics.
+ *
+ * @return The entry count.
+ */
+ public int size() {
+ synchronized (lock) {
+ return entries.size();
+ }
+ }
+
+ private boolean isExpired(PendingLogin p, Instant now) {
+ return !now.isBefore(p.createdAt().plus(ttl));
+ }
+
+ private void sweepExpired(Instant now) {
+ entries.values().removeIf(p -> isExpired(p, now));
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/IdTokenValidatorAdapter.java b/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/IdTokenValidatorAdapter.java
new file mode 100644
index 0000000000..d5517a1d85
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/IdTokenValidatorAdapter.java
@@ -0,0 +1,253 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.auth.oidc.rp;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+
+import java.net.*;
+import java.text.ParseException;
+import java.util.*;
+
+import org.apache.juneau.rest.auth.AuthenticationException;
+import org.apache.juneau.rest.auth.ClaimsPrincipal;
+
+import com.nimbusds.jose.*;
+import com.nimbusds.jose.jwk.*;
+import com.nimbusds.jose.jwk.source.*;
+import com.nimbusds.jose.proc.*;
+import com.nimbusds.jwt.*;
+import com.nimbusds.oauth2.sdk.id.*;
+import com.nimbusds.openid.connect.sdk.Nonce;
+import com.nimbusds.openid.connect.sdk.claims.IDTokenClaimsSet;
+import com.nimbusds.openid.connect.sdk.validators.IDTokenValidator;
+
+/**
+ * Wraps Nimbus's {@link IDTokenValidator} to validate an OpenID Connect ID token's signature and claims
+ * and project the verified claims onto a Juneau {@link ClaimsPrincipal}.
+ *
+ * <p>
+ * Nimbus's {@code IDTokenValidator} performs the full OIDC ID-token validation in one call: signature
+ * verification against the IdP JWKS, exact {@code iss} match, {@code aud} contains the client id,
+ * {@code azp} when there are multiple audiences, {@code exp} (with clock skew), required {@code iat},
+ * and — when supplied — {@code nonce} match. This adapter owns only the configuration and
+ * the projection to {@link ClaimsPrincipal}; no bespoke crypto.
+ *
+ * <p>
+ * The signing-algorithm allowlist defaults to {@code [RS256, ES256]} (both SHA-256 based). The unsafe
+ * {@code "none"} algorithm is permanently rejected (a token signed with {@code none} has no key and so
+ * fails the key selector), and SHA-1-family algorithms are excluded by virtue of not being on the
+ * allowlist — consistent with the {@code juneau-rest-server-jwt} strict-default stance.
+ *
+ * <p>
+ * For production, configure {@link Builder#jwksUri(URI)} (Nimbus builds a cached remote key source).
+ * For tests / HSM-backed signers / multi-issuer setups, inject a {@link Builder#jwkSet(JWKSet)} or
+ * {@link Builder#jwkSource(JWKSource)} directly.
+ *
+ * @since 9.5.0
+ */
+public class IdTokenValidatorAdapter {
+
+ /**
+ * Static creator.
+ *
+ * @return A new builder.
+ */
+ public static Builder create() {
+ return new Builder();
+ }
+
+ /**
+ * Builder.
+ */
+ public static class Builder {
+ private String issuer;
+ private String clientId;
+ private URI jwksUri;
+ private JWKSet jwkSet;
+ private JWKSource<SecurityContext> jwkSource;
+ private Set<JWSAlgorithm> algorithms = new LinkedHashSet<>(Arrays.asList(JWSAlgorithm.RS256, JWSAlgorithm.ES256));
+ private int maxClockSkewSeconds = 60;
+
+ /** Constructor. */
+ protected Builder() {}
+
+ /**
+ * Sets the expected {@code iss} (issuer). Required.
+ *
+ * @param value The issuer. Must not be <jk>null</jk> or blank.
+ * @return This object.
+ */
+ public Builder issuer(String value) {
+ issuer = assertArgNotNullOrBlank("value", value);
+ return this;
+ }
+
+ /**
+ * Sets the OAuth client id (expected {@code aud}). Required.
+ *
+ * @param value The client id. Must not be <jk>null</jk> or blank.
+ * @return This object.
+ */
+ public Builder clientId(String value) {
+ clientId = assertArgNotNullOrBlank("value", value);
+ return this;
+ }
+
+ /**
+ * Sets the JWKS endpoint URI the validator fetches signing keys from. Mutually exclusive with
+ * {@link #jwkSet(JWKSet)} / {@link #jwkSource(JWKSource)}.
+ *
+ * @param value The JWKS URI. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public Builder jwksUri(URI value) {
+ jwksUri = assertArgNotNull("value", value);
+ return this;
+ }
+
+ /**
+ * Sets a fixed JWK set (useful for tests / static keys). Mutually exclusive with
+ * {@link #jwksUri(URI)} / {@link #jwkSource(JWKSource)}.
+ *
+ * @param value The JWK set. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public Builder jwkSet(JWKSet value) {
+ jwkSet = assertArgNotNull("value", value);
+ return this;
+ }
+
+ /**
+ * Sets a custom {@link JWKSource}. Mutually exclusive with {@link #jwksUri(URI)} /
+ * {@link #jwkSet(JWKSet)}.
+ *
+ * @param value The JWK source. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public Builder jwkSource(JWKSource<SecurityContext> value) {
+ jwkSource = assertArgNotNull("value", value);
+ return this;
+ }
+
+ /**
+ * Sets the signing-algorithm allowlist. Replaces the default {@code [RS256, ES256]}.
+ *
+ * @param values The allowed algorithms. Must be non-empty and must not contain
+ * {@link JWSAlgorithm#NONE}.
+ * @return This object.
+ */
+ public Builder algorithms(JWSAlgorithm...values) {
+ assertArgNotNull("values", values);
+ assertArg(values.length > 0, "algorithms allowlist must be non-empty");
+ var next = new LinkedHashSet<JWSAlgorithm>();
+ for (var a : values) {
+ assertArgNotNull("algorithm", a);
+ assertArg(! JWSAlgorithm.NONE.equals(a), "\"none\" algorithm is permanently rejected");
+ next.add(a);
+ }
+ algorithms = next;
+ return this;
+ }
+
+ /**
+ * Sets the clock-skew tolerance (in seconds) for {@code exp} / {@code iat} validation.
+ *
+ * @param value The tolerance in seconds. Must be non-negative.
+ * @return This object.
+ */
+ public Builder maxClockSkewSeconds(int value) {
+ assertArg(value >= 0, "maxClockSkewSeconds must be non-negative (was {0})", value);
+ maxClockSkewSeconds = value;
+ return this;
+ }
+
+ /**
+ * Builds the adapter.
+ *
+ * @return A new {@link IdTokenValidatorAdapter}.
+ */
+ public IdTokenValidatorAdapter build() {
+ if (issuer == null)
+ throw new IllegalStateException("IdTokenValidatorAdapter requires issuer(...)");
+ if (clientId == null)
+ throw new IllegalStateException("IdTokenValidatorAdapter requires clientId(...)");
+ var sources = 0;
+ if (jwksUri != null) sources++;
+ if (jwkSet != null) sources++;
+ if (jwkSource != null) sources++;
+ if (sources != 1)
+ throw new IllegalStateException("IdTokenValidatorAdapter requires exactly one of jwksUri(...), jwkSet(...), or jwkSource(...)");
+ return new IdTokenValidatorAdapter(this);
+ }
+ }
+
+ private final IDTokenValidator validator;
+
+ /**
+ * Constructor.
+ *
+ * @param b The builder.
+ */
+ protected IdTokenValidatorAdapter(Builder b) {
+ JWKSource<SecurityContext> source = resolveSource(b);
+ var keySelector = new JWSVerificationKeySelector<SecurityContext>(b.algorithms, source);
+ this.validator = new IDTokenValidator(new Issuer(b.issuer), new ClientID(b.clientId), keySelector, null);
+ this.validator.setMaxClockSkew(b.maxClockSkewSeconds);
+ }
+
+ private static JWKSource<SecurityContext> resolveSource(Builder b) {
+ if (b.jwkSource != null)
+ return b.jwkSource;
+ if (b.jwkSet != null)
+ return new ImmutableJWKSet<>(b.jwkSet);
+ try {
+ return JWKSourceBuilder.create(b.jwksUri.toURL()).build();
+ } catch (MalformedURLException e) {
+ throw new IllegalArgumentException("Invalid JWKS URI: " + b.jwksUri, e);
+ }
+ }
+
+ /**
+ * Validates an ID token and projects the verified claims onto a {@link ClaimsPrincipal}.
+ *
+ * @param idToken The raw (compact) ID token JWT. Must not be <jk>null</jk> or blank.
+ * @param expectedNonce The {@code nonce} the relying party stored before the redirect; the ID
+ * token's {@code nonce} claim must match. May be <jk>null</jk> only if the flow did not use a
+ * nonce (the RP always supplies one).
+ * @return The verified claims as a {@link ClaimsPrincipal} whose name is the {@code sub} claim.
+ * @throws AuthenticationException If parsing, signature verification, or any claim check fails.
+ */
+ public ClaimsPrincipal validate(String idToken, String expectedNonce) throws AuthenticationException {
+ assertArgNotNullOrBlank("idToken", idToken);
+ JWT jwt;
+ try {
+ jwt = JWTParser.parse(idToken);
+ } catch (ParseException e) {
+ throw new AuthenticationException(e, "ID token could not be parsed");
+ }
+ IDTokenClaimsSet claims;
+ try {
+ claims = validator.validate(jwt, expectedNonce == null ? null : new Nonce(expectedNonce));
+ } catch (BadJOSEException e) {
+ throw new AuthenticationException(e, "ID token validation failed: {0}", e.getMessage());
+ } catch (JOSEException e) {
+ throw new AuthenticationException(e, "ID token signature verification failed");
+ }
+ var sub = claims.getSubject().getValue();
+ return new ClaimsPrincipal(sub, claims.toJSONObject());
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/InMemorySessionStore.java b/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/InMemorySessionStore.java
new file mode 100644
index 0000000000..3eac9c9764
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/InMemorySessionStore.java
@@ -0,0 +1,198 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.auth.oidc.rp;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+
+import java.time.*;
+import java.util.*;
+
+/**
+ * In-memory {@link SessionStore} backed by a bounded-LRU map, indexed by {@code sub} and {@code sid} so
+ * it can satisfy back-channel logout.
+ *
+ * <p>
+ * This is the simpler single-instance / development option. It is lost on restart and breaks under
+ * horizontal scaling without sticky sessions; for production multi-instance deployments use
+ * {@link SignedCookieSessionStore} (stateless, the documented default) or a caller-supplied distributed
+ * store.
+ *
+ * <p>
+ * Eviction is the conjunction of a size cap (LRU) and per-session TTL (an expired session is treated as
+ * a miss and removed on lookup). Because it retains sessions server-side, it
+ * {@link #supportsServerSideRevocation() supports} {@link #invalidateBySubject(String)} and
+ * {@link #invalidateBySessionId(String)}.
+ *
+ * @since 9.5.0
+ */
+public class InMemorySessionStore implements SessionStore {
+
+ /** Default maximum number of live sessions retained. */
+ public static final int DEFAULT_MAX_ENTRIES = 10_000;
+
+ /**
+ * Static creator with the default size cap.
+ *
+ * @return A new store.
+ */
+ public static InMemorySessionStore create() {
+ return new InMemorySessionStore(DEFAULT_MAX_ENTRIES, Clock.systemUTC());
+ }
+
+ /**
+ * Static creator with a custom size cap.
+ *
+ * @param maxEntries The maximum number of live sessions. Must be positive.
+ * @return A new store.
+ */
+ public static InMemorySessionStore create(int maxEntries) {
+ return new InMemorySessionStore(maxEntries, Clock.systemUTC());
+ }
+
+ private final int maxEntries;
+ private final Clock clock;
+ private final Map<String,OidcSession> byId;
+ private final Map<String,Set<String>> bySubject = new HashMap<>();
+ private final Map<String,Set<String>> bySid = new HashMap<>();
+ private final Object lock = new Object();
+
+ /**
+ * Constructor.
+ *
+ * @param maxEntries The maximum number of live sessions. Must be positive.
+ * @param clock The clock used for TTL comparisons. Must not be <jk>null</jk>.
+ */
+ public InMemorySessionStore(int maxEntries, Clock clock) {
+ assertArg(maxEntries > 0, "maxEntries must be positive (was {0})", maxEntries);
+ this.maxEntries = maxEntries;
+ this.clock = assertArgNotNull("clock", clock);
+ this.byId = new LinkedHashMap<>(16, 0.75f, true) {
+ private static final long serialVersionUID = 1L;
+ @Override
+ protected boolean removeEldestEntry(Map.Entry<String,OidcSession> eldest) {
+ if (size() > InMemorySessionStore.this.maxEntries) {
+ unindex(eldest.getValue());
+ return true;
+ }
+ return false;
+ }
+ };
+ }
+
+ @Override /* Overridden from SessionStore */
+ public String createSessionCookieValue(OidcSession session) {
+ assertArgNotNull("session", session);
+ synchronized (lock) {
+ byId.put(session.id(), session);
+ bySubject.computeIfAbsent(session.subject(), k -> new HashSet<>()).add(session.id());
+ session.sid().ifPresent(sid -> bySid.computeIfAbsent(sid, k -> new HashSet<>()).add(session.id()));
+ }
+ return session.id();
+ }
+
+ @Override /* Overridden from SessionStore */
+ public Optional<OidcSession> lookup(String cookieValue) {
+ assertArgNotNull("cookieValue", cookieValue);
+ synchronized (lock) {
+ var s = byId.get(cookieValue);
+ if (s == null)
+ return Optional.empty();
+ if (s.isExpired(clock.instant())) {
+ removeById(cookieValue);
+ return Optional.empty();
+ }
+ return Optional.of(s);
+ }
+ }
+
+ @Override /* Overridden from SessionStore */
+ public void invalidate(String cookieValue) {
+ assertArgNotNull("cookieValue", cookieValue);
+ synchronized (lock) {
+ removeById(cookieValue);
+ }
+ }
+
+ @Override /* Overridden from SessionStore */
+ public boolean supportsServerSideRevocation() {
+ return true;
+ }
+
+ @Override /* Overridden from SessionStore */
+ public int invalidateBySubject(String subject) {
+ assertArgNotNullOrBlank("subject", subject);
+ synchronized (lock) {
+ var ids = bySubject.get(subject);
+ if (ids == null)
+ return 0;
+ var count = 0;
+ for (var id : new ArrayList<>(ids))
+ count += removeById(id) ? 1 : 0;
+ return count;
+ }
+ }
+
+ @Override /* Overridden from SessionStore */
+ public int invalidateBySessionId(String sid) {
+ assertArgNotNullOrBlank("sid", sid);
+ synchronized (lock) {
+ var ids = bySid.get(sid);
+ if (ids == null)
+ return 0;
+ var count = 0;
+ for (var id : new ArrayList<>(ids))
+ count += removeById(id) ? 1 : 0;
+ return count;
+ }
+ }
+
+ /**
+ * Returns the current live-session count. Primarily for tests and metrics.
+ *
+ * @return The session count.
+ */
+ public int size() {
+ synchronized (lock) {
+ return byId.size();
+ }
+ }
+
+ private boolean removeById(String id) {
+ var removed = byId.remove(id);
+ if (removed == null)
+ return false;
+ unindex(removed);
+ return true;
+ }
+
+ private void unindex(OidcSession s) {
+ var subjects = bySubject.get(s.subject());
+ if (subjects != null) {
+ subjects.remove(s.id());
+ if (subjects.isEmpty())
+ bySubject.remove(s.subject());
+ }
+ s.sid().ifPresent(sid -> {
+ var sids = bySid.get(sid);
+ if (sids != null) {
+ sids.remove(s.id());
+ if (sids.isEmpty())
+ bySid.remove(sid);
+ }
+ });
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/OidcRelyingParty.java b/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/OidcRelyingParty.java
new file mode 100644
index 0000000000..f53a5bbd30
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/OidcRelyingParty.java
@@ -0,0 +1,1018 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.auth.oidc.rp;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+
+import java.io.*;
+import java.net.*;
+import java.security.Principal;
+import java.time.*;
+import java.util.*;
+import java.util.function.*;
+
+import org.apache.juneau.rest.auth.AuthenticationException;
+import org.apache.juneau.rest.auth.ClaimsPrincipal;
+import org.apache.juneau.rest.auth.oauth.OAuthToken;
+import org.apache.juneau.rest.auth.oauth.flow.*;
+import org.apache.juneau.rest.auth.oauth.oidc.*;
+
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.source.*;
+import com.nimbusds.jose.proc.*;
+import com.nimbusds.jwt.*;
+import com.nimbusds.oauth2.sdk.http.HTTPRequest;
+import com.nimbusds.oauth2.sdk.id.*;
+import com.nimbusds.oauth2.sdk.pkce.*;
+import com.nimbusds.oauth2.sdk.token.BearerAccessToken;
+import com.nimbusds.openid.connect.sdk.*;
+import com.nimbusds.openid.connect.sdk.claims.LogoutTokenClaimsSet;
+import com.nimbusds.openid.connect.sdk.validators.LogoutTokenValidator;
+
+import jakarta.servlet.http.*;
+
+/**
+ * Facade orchestrating an OpenID Connect Authorization-Code-with-PKCE <i>Relying Party</i> login flow on
+ * top of the {@code juneau-rest-server-oauth} building blocks.
+ *
+ * <p>
+ * A single instance wires the login / callback / logout endpoints plus the session SPI for one IdP. The
+ * application mounts the three endpoints as explicit {@code @RestGet} methods that delegate to this
+ * facade — there are no auto-mounted servlet routes (URL ownership stays with the app):
+ *
+ * <p class='bjava'>
+ * <ja>@Rest</ja>(path=<js>"/auth"</js>)
+ * <jk>public class</jk> LoginResource <jk>extends</jk> BasicRestServlet {
+ *
+ * <jk>private final</jk> OidcRelyingParty <jf>rp</jf> = OidcRelyingParty.<jsm>create</jsm>()
+ * .issuer(URI.<jsm>create</jsm>(<js>"https://accounts.google.com"</js>))
+ * .clientId(<js>"web-app"</js>)
+ * .clientSecret(<jv>env</jv>(<js>"OIDC_CLIENT_SECRET"</js>))
+ * .redirectUri(URI.<jsm>create</jsm>(<js>"https://app.example.com/auth/callback"</js>))
+ * .scope(<js>"openid"</js>, <js>"profile"</js>, <js>"email"</js>)
+ * .sessionStore(SignedCookieSessionStore.<jsm>create</jsm>().signingKey(<jv>env</jv>(<js>"SESSION_KEY"</js>)).build())
+ * .build();
+ *
+ * <ja>@RestGet</ja>(path=<js>"/login"</js>)
+ * <jk>public void</jk> login(RestRequest <jv>req</jv>, RestResponse <jv>res</jv>) <jk>throws</jk> Exception {
+ * <jf>rp</jf>.startLogin(<jv>req</jv>, <jv>res</jv>);
+ * }
+ *
+ * <ja>@RestGet</ja>(path=<js>"/callback"</js>)
+ * <jk>public void</jk> callback(RestRequest <jv>req</jv>, RestResponse <jv>res</jv>) <jk>throws</jk> Exception {
+ * <jf>rp</jf>.completeLogin(<jv>req</jv>, <jv>res</jv>);
+ * }
+ *
+ * <ja>@RestGet</ja>(path=<js>"/logout"</js>)
+ * <jk>public void</jk> logout(RestRequest <jv>req</jv>, RestResponse <jv>res</jv>) <jk>throws</jk> Exception {
+ * <jf>rp</jf>.logout(<jv>req</jv>, <jv>res</jv>);
+ * }
+ * }
+ * </p>
+ *
+ * <p>
+ * Register {@link #authFilter()} in the servlet filter chain (or {@code AuthFilterChain}) so each request
+ * resolves the session cookie into a {@link ClaimsPrincipal} for {@code RoleBasedRestGuard} /
+ * {@code @Auth Principal}.
+ *
+ * <h5 class='section'>Security</h5>
+ * <ul>
+ * <li>{@code state} and {@code nonce} are single-use and TTL-bounded (default 5 min); a
+ * missing / replayed value fails the callback.
+ * <li>PKCE S256 is enforced end-to-end.
+ * <li>The ID token is validated for signature, {@code iss}, {@code aud}/{@code azp}, {@code exp}, and
+ * {@code nonce} (see {@link IdTokenValidatorAdapter}).
+ * <li>The session id is freshly generated on login (no pre-auth fixation).
+ * <li>Auth responses set {@code Cache-Control: no-store}; tokens are never logged.
+ * <li>The session cookie is {@code HttpOnly} + {@code Secure} + {@code SameSite} by default.
+ * </ul>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='jc'>{@link SessionStore}
+ * <li class='jc'>{@link OidcSessionAuthFilter}
+ * <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/OidcRelyingParty">OIDC Relying Party</a>
+ * <li class='link'><a class="doclink" href="https://openid.net/specs/openid-connect-core-1_0.html">OpenID Connect Core 1.0</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+public class OidcRelyingParty {
+
+ private static final String DEFAULT_COOKIE_NAME = "JUNEAU_OIDC_SESSION";
+ private static final String DEFAULT_ROLES_CLAIM = "scope";
+ private static final Duration DEFAULT_SESSION_TTL = Duration.ofHours(8);
+
+ /**
+ * Static creator.
+ *
+ * @return A new builder.
+ */
+ public static Builder create() {
+ return new Builder();
+ }
+
+ /**
+ * Builder.
+ */
+ public static class Builder {
+ URI issuer;
+ OidcMetadata metadata;
+ String clientId;
+ Supplier<String> clientSecretSupplier;
+ URI redirectUri;
+ URI postLogoutRedirectUri;
+ String postLoginRedirect = "/";
+ Set<String> scopes = new LinkedHashSet<>();
+ SessionStore sessionStore;
+ String rolesClaim = DEFAULT_ROLES_CLAIM;
+ Duration stateNonceTtl = EphemeralStore.DEFAULT_TTL;
+ int ephemeralMaxEntries = EphemeralStore.DEFAULT_MAX_ENTRIES;
+ Duration sessionTtl = DEFAULT_SESSION_TTL;
+ String cookieName = DEFAULT_COOKIE_NAME;
+ boolean cookieSecure = true;
+ boolean cookieHttpOnly = true;
+ String cookieSameSite = "Lax";
+ Consumer<HTTPRequest> httpRequestConfigurator;
+ Consumer<AuthenticationRequest.Builder> authenticationRequestCustomizer;
+ JWSAlgorithm[] idTokenAlgorithms;
+ int clockSkewSeconds = 60;
+ JWKSet jwkSet;
+ JWKSource<SecurityContext> jwkSource;
+ Set<String> userInfoClaims = new LinkedHashSet<>();
+ Clock clock = Clock.systemUTC();
+
+ /** Constructor. */
+ protected Builder() {}
+
+ /**
+ * Sets the IdP issuer URL. OIDC discovery resolves the endpoints + JWKS URI lazily. Either
+ * this or {@link #metadata(OidcMetadata)} is required.
+ *
+ * @param value The issuer URL. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public Builder issuer(URI value) {
+ issuer = assertArgNotNull("value", value);
+ return this;
+ }
+
+ /**
+ * Sets the IdP metadata explicitly, bypassing discovery. Either this or {@link #issuer(URI)}
+ * is required.
+ *
+ * @param value The metadata. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public Builder metadata(OidcMetadata value) {
+ metadata = assertArgNotNull("value", value);
+ return this;
+ }
+
+ /**
+ * Sets the OAuth client id. Required.
+ *
+ * @param value The client id. Must not be <jk>null</jk> or blank.
+ * @return This object.
+ */
+ public Builder clientId(String value) {
+ clientId = assertArgNotNullOrBlank("value", value);
+ return this;
+ }
+
+ /**
+ * Sets the client secret. May be omitted for public clients (PKCE-only).
+ *
+ * @param value The client secret. Must not be <jk>null</jk> or blank.
+ * @return This object.
+ */
+ public Builder clientSecret(String value) {
+ assertArgNotNullOrBlank("value", value);
+ clientSecretSupplier = () -> value;
+ return this;
+ }
+
+ /**
+ * Sets the client secret via a supplier.
+ *
+ * @param value The supplier. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public Builder clientSecretSupplier(Supplier<String> value) {
+ clientSecretSupplier = assertArgNotNull("value", value);
+ return this;
+ }
+
+ /**
+ * Sets the redirect URI registered with the IdP. Required.
+ *
+ * @param value The redirect URI. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public Builder redirectUri(URI value) {
+ redirectUri = assertArgNotNull("value", value);
+ return this;
+ }
+
+ /**
+ * Sets the post-logout redirect URI passed to the IdP end-session endpoint.
+ *
+ * @param value The post-logout redirect URI. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public Builder postLogoutRedirectUri(URI value) {
+ postLogoutRedirectUri = assertArgNotNull("value", value);
+ return this;
+ }
+
+ /**
+ * Sets the default post-login redirect target (when the login request supplied no safe
+ * {@code redirect} parameter). Defaults to {@code "/"}.
+ *
+ * @param value The default redirect path. Must not be <jk>null</jk> or blank.
+ * @return This object.
+ */
+ public Builder postLoginRedirect(String value) {
+ postLoginRedirect = assertArgNotNullOrBlank("value", value);
+ return this;
+ }
+
+ /**
+ * Adds requested scopes. {@code openid} is always included.
+ *
+ * @param values The scopes.
+ * @return This object.
+ */
+ public Builder scope(String...values) {
+ assertArgNotNull("values", values);
+ for (var v : values) {
+ assertArgNotNullOrBlank("scope", v);
+ scopes.add(v);
+ }
+ return this;
+ }
+
+ /**
+ * Sets the session store. Required.
+ *
+ * @param value The session store. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public Builder sessionStore(SessionStore value) {
+ sessionStore = assertArgNotNull("value", value);
+ return this;
+ }
+
+ /**
+ * Sets the claim roles are extracted from. Defaults to {@code "scope"} (whitespace-split);
+ * array claims are also accepted. Mirrors the {@code OAuthFilter} convention.
+ *
+ * @param value The claim name. Must not be <jk>null</jk> or blank.
+ * @return This object.
+ */
+ public Builder rolesClaim(String value) {
+ rolesClaim = assertArgNotNullOrBlank("value", value);
+ return this;
+ }
+
+ /**
+ * Sets the single-use TTL for the {@code state} / {@code nonce} store. Defaults to 5 minutes.
+ *
+ * @param value The TTL. Must be positive and not exceed 30 minutes.
+ * @return This object.
+ */
+ public Builder stateNonceTtl(Duration value) {
+ stateNonceTtl = assertArgNotNull("value", value);
+ return this;
+ }
+
+ /**
+ * Sets the session lifetime (bounded independently of token lifetime). Defaults to 8 hours.
+ *
+ * @param value The session TTL. Must be positive.
+ * @return This object.
+ */
+ public Builder sessionTtl(Duration value) {
+ assertArgNotNull("value", value);
+ assertArg(!value.isZero() && !value.isNegative(), "sessionTtl must be positive");
+ sessionTtl = value;
+ return this;
+ }
+
+ /**
+ * Sets the session cookie name. Defaults to {@code JUNEAU_OIDC_SESSION}.
+ *
+ * @param value The cookie name. Must not be <jk>null</jk> or blank.
+ * @return This object.
+ */
+ public Builder cookieName(String value) {
+ cookieName = assertArgNotNullOrBlank("value", value);
+ return this;
+ }
+
+ /**
+ * Sets the cookie {@code Secure} flag. Defaults to <jk>true</jk> (fail-closed).
+ *
+ * @param value The flag.
+ * @return This object.
+ */
+ public Builder cookieSecure(boolean value) {
+ cookieSecure = value;
+ return this;
+ }
+
+ /**
+ * Sets the cookie {@code HttpOnly} flag. Defaults to <jk>true</jk> (fail-closed).
+ *
+ * @param value The flag.
+ * @return This object.
+ */
+ public Builder cookieHttpOnly(boolean value) {
+ cookieHttpOnly = value;
+ return this;
+ }
+
+ /**
+ * Sets the cookie {@code SameSite} attribute. Defaults to {@code "Lax"}.
+ *
+ * @param value The SameSite value (e.g. {@code "Lax"} or {@code "Strict"}). Must not be
+ * <jk>null</jk> or blank.
+ * @return This object.
+ */
+ public Builder cookieSameSite(String value) {
+ cookieSameSite = assertArgNotNullOrBlank("value", value);
+ return this;
+ }
+
+ /**
+ * Sets a Nimbus {@link HTTPRequest} configurator escape hatch (proxy / TLS / timeouts) applied
+ * to discovery + token + userinfo calls.
+ *
+ * @param value The callback. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public Builder httpRequestConfigurator(Consumer<HTTPRequest> value) {
+ httpRequestConfigurator = assertArgNotNull("value", value);
+ return this;
+ }
+
+ /**
+ * Sets a Nimbus {@link AuthenticationRequest.Builder} customizer escape hatch (e.g. {@code prompt},
+ * {@code max_age}, {@code acr_values}) applied to the authorization redirect.
+ *
+ * @param value The callback. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public Builder authenticationRequestCustomizer(Consumer<AuthenticationRequest.Builder> value) {
+ authenticationRequestCustomizer = assertArgNotNull("value", value);
+ return this;
+ }
+
+ /**
+ * Sets the ID-token signing-algorithm allowlist. Defaults to {@code [RS256, ES256]}.
+ *
+ * @param values The allowed algorithms.
+ * @return This object.
+ */
+ public Builder idTokenAlgorithms(JWSAlgorithm...values) {
+ idTokenAlgorithms = assertArgNotNull("values", values);
+ return this;
+ }
+
+ /**
+ * Sets the clock-skew tolerance (seconds) for ID-token validation. Defaults to 60.
+ *
+ * @param value The tolerance in seconds. Must be non-negative.
+ * @return This object.
+ */
+ public Builder clockSkewSeconds(int value) {
+ assertArg(value >= 0, "clockSkewSeconds must be non-negative (was {0})", value);
+ clockSkewSeconds = value;
+ return this;
+ }
+
+ /**
+ * Injects a fixed JWK set for ID-token / logout-token signature verification, bypassing the
+ * JWKS endpoint. Useful for tests and static-key deployments.
+ *
+ * @param value The JWK set. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public Builder jwkSet(JWKSet value) {
+ jwkSet = assertArgNotNull("value", value);
+ return this;
+ }
+
+ /**
+ * Injects a custom JWK source for ID-token / logout-token signature verification.
+ *
+ * @param value The JWK source. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public Builder jwkSource(JWKSource<SecurityContext> value) {
+ jwkSource = assertArgNotNull("value", value);
+ return this;
+ }
+
+ /**
+ * Adds claim names to backfill from the UserInfo endpoint when absent from the ID token.
+ *
+ * <p>
+ * When empty (the default) the UserInfo endpoint is never called — the {@link Principal}
+ * is built from ID-token claims only.
+ *
+ * @param values The claim names to backfill.
+ * @return This object.
+ */
+ public Builder userInfoClaims(String...values) {
+ assertArgNotNull("values", values);
+ for (var v : values) {
+ assertArgNotNullOrBlank("claim", v);
+ userInfoClaims.add(v);
+ }
+ return this;
+ }
+
+ /**
+ * Overrides the clock used for session expiry + the ephemeral store. Useful in tests.
+ *
+ * @param value The clock. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public Builder clock(Clock value) {
+ clock = assertArgNotNull("value", value);
+ return this;
+ }
+
+ /**
+ * Builds the relying party.
+ *
+ * @return A new {@link OidcRelyingParty}.
+ */
+ public OidcRelyingParty build() {
+ if (issuer == null && metadata == null)
+ throw new IllegalStateException("OidcRelyingParty requires issuer(...) or metadata(...)");
+ if (clientId == null)
+ throw new IllegalStateException("OidcRelyingParty requires clientId(...)");
+ if (redirectUri == null)
+ throw new IllegalStateException("OidcRelyingParty requires redirectUri(...)");
+ if (sessionStore == null)
+ throw new IllegalStateException("OidcRelyingParty requires sessionStore(...)");
+ return new OidcRelyingParty(this);
+ }
+ }
+
+ private final URI issuer;
+ private final String clientId;
+ private final Supplier<String> clientSecretSupplier;
+ private final URI redirectUri;
+ private final URI postLogoutRedirectUri;
+ private final String postLoginRedirect;
+ private final Set<String> scopes;
+ private final SessionStore sessionStore;
+ private final String rolesClaim;
+ private final Duration sessionTtl;
+ private final String cookieName;
+ private final boolean cookieSecure;
+ private final boolean cookieHttpOnly;
+ private final String cookieSameSite;
+ private final Consumer<HTTPRequest> httpRequestConfigurator;
+ private final Consumer<AuthenticationRequest.Builder> authenticationRequestCustomizer;
+ private final JWSAlgorithm[] idTokenAlgorithms;
+ private final int clockSkewSeconds;
+ private final JWKSet jwkSet;
+ private final JWKSource<SecurityContext> injectedJwkSource;
+ private final Set<String> userInfoClaims;
+ private final Clock clock;
+ private final EphemeralStore ephemeralStore;
+
+ private volatile OidcMetadata metadataCache;
+ private final OidcMetadata explicitMetadata;
+ private volatile OAuthAuthorizationCodeFlow codeFlowCache;
+ private volatile IdTokenValidatorAdapter idTokenValidatorCache;
+ private volatile JWKSource<SecurityContext> jwkSourceCache;
+ private volatile LogoutTokenValidator logoutTokenValidatorCache;
+
+ /**
+ * Constructor.
+ *
+ * @param b The builder.
+ */
+ protected OidcRelyingParty(Builder b) {
+ this.issuer = b.issuer;
+ this.explicitMetadata = b.metadata;
+ this.clientId = b.clientId;
+ this.clientSecretSupplier = b.clientSecretSupplier;
+ this.redirectUri = b.redirectUri;
+ this.postLogoutRedirectUri = b.postLogoutRedirectUri;
+ this.postLoginRedirect = b.postLoginRedirect;
+ var s = new LinkedHashSet<String>();
+ s.add("openid");
+ s.addAll(b.scopes);
+ this.scopes = Collections.unmodifiableSet(s);
+ this.sessionStore = b.sessionStore;
+ this.rolesClaim = b.rolesClaim;
+ this.sessionTtl = b.sessionTtl;
+ this.cookieName = b.cookieName;
+ this.cookieSecure = b.cookieSecure;
+ this.cookieHttpOnly = b.cookieHttpOnly;
+ this.cookieSameSite = b.cookieSameSite;
+ this.httpRequestConfigurator = b.httpRequestConfigurator;
+ this.authenticationRequestCustomizer = b.authenticationRequestCustomizer;
+ this.idTokenAlgorithms = b.idTokenAlgorithms;
+ this.clockSkewSeconds = b.clockSkewSeconds;
+ this.jwkSet = b.jwkSet;
+ this.injectedJwkSource = b.jwkSource;
+ this.userInfoClaims = Collections.unmodifiableSet(new LinkedHashSet<>(b.userInfoClaims));
+ this.clock = b.clock;
+ this.ephemeralStore = new EphemeralStore(b.stateNonceTtl, b.ephemeralMaxEntries, b.clock);
+ }
+
+ //-----------------------------------------------------------------------------------------------------------------
+ // Public flow methods
+ //-----------------------------------------------------------------------------------------------------------------
+
+ /**
+ * Begins the login flow: generates {@code state} + {@code nonce} + PKCE verifier, stores them
+ * single-use, and redirects the user-agent to the IdP authorization endpoint.
+ *
+ * @param req The HTTP request. An optional {@code redirect} query parameter (a safe relative path)
+ * is remembered as the post-login target.
+ * @param res The HTTP response.
+ * @throws IOException If the redirect cannot be written.
+ */
+ public void startLogin(HttpServletRequest req, HttpServletResponse res) throws IOException {
+ assertArgNotNull("req", req);
+ assertArgNotNull("res", res);
+ var state = new State().getValue();
+ var nonce = new Nonce().getValue();
+ var verifier = new CodeVerifier();
+ var challenge = CodeChallenge.compute(CodeChallengeMethod.S256, verifier);
+ var redirectTarget = safeRelativePath(req.getParameter("redirect"));
+ ephemeralStore.store(state, nonce, verifier.getValue(), redirectTarget);
+ var authUrl = codeFlow().buildAuthenticationUrl(state, challenge, nonce, authenticationRequestCustomizer);
+ noStore(res);
+ res.sendRedirect(authUrl.toString());
+ }
+
+ /**
+ * Completes the login flow: parses the callback, verifies + consumes {@code state}, exchanges the
+ * code (PKCE), validates the ID token (signature / {@code iss} / {@code aud} / {@code exp} /
+ * {@code nonce}), creates a session, sets the session cookie, and redirects to the application.
+ *
+ * @param req The callback HTTP request (carrying {@code code} + {@code state}).
+ * @param res The HTTP response.
+ * @throws IOException If the redirect cannot be written.
+ * @throws AuthenticationException If the callback is an error, the state is missing / replayed, or
+ * ID-token validation fails.
+ */
+ public void completeLogin(HttpServletRequest req, HttpServletResponse res) throws IOException, AuthenticationException {
+ assertArgNotNull("req", req);
+ assertArgNotNull("res", res);
+
+ var callbackUri = fullRequestUri(req);
+ AuthenticationResponse parsed;
+ try {
+ parsed = AuthenticationResponseParser.parse(callbackUri);
+ } catch (com.nimbusds.oauth2.sdk.ParseException e) {
+ throw new AuthenticationException(e, "OIDC callback could not be parsed");
+ }
+ if (! parsed.indicatesSuccess()) {
+ var err = parsed.toErrorResponse().getErrorObject();
+ throw new AuthenticationException("OIDC callback returned an error: {0}", err == null ? "unknown" : err.getCode());
+ }
+ var success = parsed.toSuccessResponse();
+ if (success.getAuthorizationCode() == null)
+ throw new AuthenticationException("OIDC callback contained no authorization code");
+ var code = success.getAuthorizationCode().getValue();
+ var state = success.getState() == null ? null : success.getState().getValue();
+
+ var pending = ephemeralStore.consume(state).orElseThrow(
+ () -> new AuthenticationException("OIDC callback state is missing, expired, or already used"));
+
+ OAuthToken token;
+ try {
+ token = codeFlow().exchange(code, new CodeVerifier(pending.codeVerifier()));
+ } catch (OAuthFlowException e) {
+ throw new AuthenticationException(e, "OIDC token exchange failed");
+ }
+ var idToken = token.idToken().orElseThrow(
+ () -> new AuthenticationException("IdP token response contained no id_token"));
+
+ var principal = idTokenValidator().validate(idToken, pending.nonce());
+ principal = maybeBackfillUserInfo(principal, token);
+
+ var subject = principal.getName();
+ var sid = principal.getClaim("sid", String.class);
+ var roles = extractRoles(principal);
+ var now = clock.instant();
+ var session = new OidcSession(
+ new State().getValue(),
+ subject,
+ sid,
+ principal,
+ roles,
+ Optional.of(token),
+ now,
+ now.plus(sessionTtl));
+
+ var cookieValue = sessionStore.createSessionCookieValue(session);
+ noStore(res);
+ res.addHeader("Set-Cookie", buildSetCookie(cookieValue, sessionTtl.toSeconds()));
+ res.sendRedirect(pending.redirectTarget() != null ? pending.redirectTarget() : postLoginRedirect);
+ }
+
+ /**
+ * Logs the user out: invalidates the session, clears the session cookie, and (when the IdP
+ * advertises an end-session endpoint) redirects through it; otherwise redirects to the
+ * post-logout target.
+ *
+ * @param req The HTTP request.
+ * @param res The HTTP response.
+ * @throws IOException If the redirect cannot be written.
+ */
+ public void logout(HttpServletRequest req, HttpServletResponse res) throws IOException {
+ assertArgNotNull("req", req);
+ assertArgNotNull("res", res);
+ var cookieValue = readCookie(req);
+ JWT idTokenHint = null;
+ if (cookieValue != null) {
+ var session = sessionStore.lookup(cookieValue);
+ if (session.isPresent() && session.get().token().isPresent()) {
+ var idt = session.get().token().get().idToken();
+ if (idt.isPresent()) {
+ try {
+ idTokenHint = JWTParser.parse(idt.get());
+ } catch (java.text.ParseException e) {
+ idTokenHint = null; // HTT - best-effort hint only
+ }
+ }
+ }
+ sessionStore.invalidate(cookieValue);
+ }
+ noStore(res);
+ res.addHeader("Set-Cookie", buildSetCookie("", 0));
+
+ var endSession = metadata().endSessionEndpoint();
+ if (endSession != null) {
+ var logoutReq = new LogoutRequest(endSession, idTokenHint, postLogoutRedirectUri, null);
+ res.sendRedirect(logoutReq.toURI().toString());
+ } else {
+ res.sendRedirect(postLogoutRedirectUri != null ? postLogoutRedirectUri.toString() : postLoginRedirect);
+ }
+ }
+
+ /**
+ * Refreshes the current session's access token using the rotating-refresh-token grant.
+ *
+ * <p>
+ * Replaces the stored refresh token with the one the IdP returns; if the IdP rejects the refresh
+ * (e.g. a rotated token was reused / revoked), the session is invalidated. Only applies to stores
+ * that retain tokens (server-side); {@link SignedCookieSessionStore} sessions carry no tokens and
+ * yield {@link Optional#empty()}.
+ *
+ * @param req The HTTP request.
+ * @param res The HTTP response (a refreshed session resets the cookie).
+ * @return The refreshed session, or {@link Optional#empty()} if there was nothing to refresh.
+ */
+ public Optional<OidcSession> refresh(HttpServletRequest req, HttpServletResponse res) {
+ assertArgNotNull("req", req);
+ assertArgNotNull("res", res);
+ var cookieValue = readCookie(req);
+ if (cookieValue == null)
+ return Optional.empty();
+ var existing = sessionStore.lookup(cookieValue);
+ if (existing.isEmpty() || existing.get().token().isEmpty())
+ return Optional.empty();
+ var oldToken = existing.get().token().get();
+ if (oldToken.refreshToken().isEmpty())
+ return Optional.empty();
+
+ OAuthToken refreshed;
+ try {
+ var flowBuilder = OAuthRefreshTokenFlow.create()
+ .tokenEndpoint(metadata().tokenEndpoint())
+ .clientId(clientId)
+ .refreshToken(oldToken.refreshToken().get());
+ if (clientSecretSupplier != null)
+ flowBuilder.clientSecretSupplier(clientSecretSupplier);
+ if (httpRequestConfigurator != null)
+ flowBuilder.httpRequestConfigurator(httpRequestConfigurator);
+ refreshed = flowBuilder.build().acquire();
+ } catch (OAuthFlowException e) {
+ // Rotating-refresh-token reuse / revocation -> invalidate the session (fail-closed).
+ sessionStore.invalidate(cookieValue);
+ res.addHeader("Set-Cookie", buildSetCookie("", 0));
+ return Optional.empty();
+ }
+
+ var old = existing.get();
+ var now = clock.instant();
+ var newSession = new OidcSession(
+ new State().getValue(),
+ old.subject(),
+ old.sid(),
+ old.principal(),
+ old.roles(),
+ Optional.of(refreshed),
+ old.createdAt(),
+ now.plus(sessionTtl));
+ sessionStore.invalidate(cookieValue);
+ var newCookie = sessionStore.createSessionCookieValue(newSession);
+ noStore(res);
+ res.addHeader("Set-Cookie", buildSetCookie(newCookie, sessionTtl.toSeconds()));
+ return Optional.of(newSession);
+ }
+
+ /**
+ * Processes an IdP-pushed OpenID Connect back-channel logout token, invalidating the matching
+ * server-side session(s).
+ *
+ * <p>
+ * The logout token is validated via Nimbus's {@link LogoutTokenValidator} (signature against the
+ * JWKS, {@code iss}, {@code aud}, the back-channel {@code events} claim, and the {@code sub} /
+ * {@code sid} presence rule). Sessions are then revoked by {@code sid} (preferred, single-session)
+ * or {@code sub} (all of a subject's sessions).
+ *
+ * <p>
+ * Requires a {@link SessionStore#supportsServerSideRevocation() server-side-revocable} store
+ * — the default {@link SignedCookieSessionStore} cannot satisfy back-channel logout because a
+ * stateless cookie is not server-revocable.
+ *
+ * @param logoutToken The raw {@code logout_token} JWT pushed by the IdP. Must not be <jk>null</jk>
+ * or blank.
+ * @return The number of sessions invalidated.
+ * @throws AuthenticationException If the logout token fails validation.
+ * @throws IllegalStateException If the configured session store does not support server-side
+ * revocation.
+ */
+ public int backChannelLogout(String logoutToken) throws AuthenticationException {
+ assertArgNotNullOrBlank("logoutToken", logoutToken);
+ if (! sessionStore.supportsServerSideRevocation())
+ throw new IllegalStateException("Back-channel logout requires a server-side-revocable SessionStore (InMemorySessionStore or a distributed store); the configured store is stateless.");
+ JWT jwt;
+ try {
+ jwt = JWTParser.parse(logoutToken);
+ } catch (java.text.ParseException e) {
+ throw new AuthenticationException(e, "Logout token could not be parsed");
+ }
+ LogoutTokenClaimsSet claims;
+ try {
+ claims = logoutTokenValidator().validate(jwt);
+ } catch (com.nimbusds.jose.proc.BadJOSEException | com.nimbusds.jose.JOSEException e) {
+ throw new AuthenticationException(e, "Logout token validation failed: {0}", e.getMessage());
+ }
+ var sid = claims.getSessionID();
+ if (sid != null)
+ return sessionStore.invalidateBySessionId(sid.getValue());
+ var sub = claims.getSubject();
+ if (sub != null)
+ return sessionStore.invalidateBySubject(sub.getValue());
+ return 0; // HTT - validator enforces sub/sid presence; defensive
+ }
+
+ /**
+ * Returns a request-time {@link AuthFilter} that resolves this RP's session cookie into a
+ * {@link ClaimsPrincipal}.
+ *
+ * @return A new {@link OidcSessionAuthFilter} bound to this RP's session store + cookie name.
+ */
+ public OidcSessionAuthFilter authFilter() {
+ return new OidcSessionAuthFilter(sessionStore, cookieName);
+ }
+
+ /**
+ * Returns the configured session store.
+ *
+ * @return The session store.
+ */
+ public SessionStore sessionStore() {
+ return sessionStore;
+ }
+
+ /**
+ * Returns the session cookie name.
+ *
+ * @return The cookie name.
+ */
+ public String cookieName() {
+ return cookieName;
+ }
+
+ //-----------------------------------------------------------------------------------------------------------------
+ // Internals
+ //-----------------------------------------------------------------------------------------------------------------
+
+ private OidcMetadata metadata() {
+ var m = metadataCache;
+ if (m != null)
+ return m;
+ synchronized (this) {
+ if (metadataCache != null)
+ return metadataCache;
+ if (explicitMetadata != null) {
+ metadataCache = explicitMetadata;
+ return metadataCache;
+ }
+ try {
+ var clientBuilder = OidcDiscoveryClient.create().issuer(issuer);
+ if (httpRequestConfigurator != null)
+ clientBuilder.httpRequestConfigurator(httpRequestConfigurator);
+ metadataCache = clientBuilder.build().discover();
+ } catch (IOException | OidcDiscoveryException e) {
+ throw new IllegalStateException("OIDC discovery failed for issuer " + issuer, e);
+ }
+ return metadataCache;
+ }
+ }
+
+ private OAuthAuthorizationCodeFlow codeFlow() {
+ var f = codeFlowCache;
+ if (f != null)
+ return f;
+ synchronized (this) {
+ if (codeFlowCache != null)
+ return codeFlowCache;
+ var md = metadata();
+ var b = OAuthAuthorizationCodeFlow.create()
+ .authorizationEndpoint(md.authorizationEndpoint())
+ .tokenEndpoint(md.tokenEndpoint())
+ .clientId(clientId)
+ .redirectUri(redirectUri)
+ .scope(scopes.toArray(new String[0]));
+ if (clientSecretSupplier != null)
+ b.clientSecretSupplier(clientSecretSupplier);
+ if (httpRequestConfigurator != null)
+ b.httpRequestConfigurator(httpRequestConfigurator);
+ codeFlowCache = b.build();
+ return codeFlowCache;
+ }
+ }
+
+ private IdTokenValidatorAdapter idTokenValidator() {
+ var v = idTokenValidatorCache;
+ if (v != null)
+ return v;
+ synchronized (this) {
+ if (idTokenValidatorCache != null)
+ return idTokenValidatorCache;
+ var b = IdTokenValidatorAdapter.create()
+ .issuer(metadata().issuer().toString())
+ .clientId(clientId)
+ .jwkSource(jwkSource())
+ .maxClockSkewSeconds(clockSkewSeconds);
+ if (idTokenAlgorithms != null)
+ b.algorithms(idTokenAlgorithms);
+ idTokenValidatorCache = b.build();
+ return idTokenValidatorCache;
+ }
+ }
+
+ private JWKSource<SecurityContext> jwkSource() {
+ var s = jwkSourceCache;
+ if (s != null)
+ return s;
+ synchronized (this) {
+ if (jwkSourceCache != null)
+ return jwkSourceCache;
+ if (injectedJwkSource != null) {
+ jwkSourceCache = injectedJwkSource;
+ } else if (jwkSet != null) {
+ jwkSourceCache = new ImmutableJWKSet<>(jwkSet);
+ } else {
+ try {
+ jwkSourceCache = JWKSourceBuilder.create(metadata().jwksUri().toURL()).build();
+ } catch (MalformedURLException e) {
+ throw new IllegalStateException("Invalid JWKS URI: " + metadata().jwksUri(), e);
+ }
+ }
+ return jwkSourceCache;
+ }
+ }
+
+ private LogoutTokenValidator logoutTokenValidator() {
+ var v = logoutTokenValidatorCache;
+ if (v != null)
+ return v;
+ synchronized (this) {
+ if (logoutTokenValidatorCache != null)
+ return logoutTokenValidatorCache;
+ Set<JWSAlgorithm> algs = idTokenAlgorithms != null
+ ? new LinkedHashSet<>(Arrays.asList(idTokenAlgorithms))
+ : new LinkedHashSet<>(Arrays.asList(JWSAlgorithm.RS256, JWSAlgorithm.ES256));
+ var keySelector = new JWSVerificationKeySelector<SecurityContext>(algs, jwkSource());
+ logoutTokenValidatorCache = new LogoutTokenValidator(
+ new Issuer(metadata().issuer().toString()), new ClientID(clientId), keySelector, null);
+ return logoutTokenValidatorCache;
+ }
+ }
+
+ private ClaimsPrincipal maybeBackfillUserInfo(ClaimsPrincipal principal, OAuthToken token) {
+ if (userInfoClaims.isEmpty())
+ return principal;
+ var endpoint = metadata().userinfoEndpoint();
+ if (endpoint == null)
+ return principal;
+ var missing = userInfoClaims.stream().anyMatch(c -> ! principal.hasClaim(c));
+ if (! missing)
+ return principal;
+ try {
+ var http = new UserInfoRequest(endpoint, new BearerAccessToken(token.accessToken())).toHTTPRequest();
+ if (httpRequestConfigurator != null)
+ httpRequestConfigurator.accept(http);
+ var resp = UserInfoResponse.parse(http.send());
+ if (! resp.indicatesSuccess())
+ return principal;
+ var ui = resp.toSuccessResponse().getUserInfo().toJSONObject();
+ var merged = new LinkedHashMap<String,Object>(principal.getClaims());
+ for (var c : userInfoClaims)
+ if (! merged.containsKey(c) && ui.containsKey(c))
+ merged.put(c, ui.get(c));
+ return new ClaimsPrincipal(principal.getName(), merged);
+ } catch (IOException | com.nimbusds.oauth2.sdk.ParseException | RuntimeException e) {
+ return principal; // UserInfo backfill is best-effort; never fail the login on it.
+ }
+ }
+
+ private Set<String> extractRoles(ClaimsPrincipal principal) {
+ var v = principal.getClaims().get(rolesClaim);
+ if (v == null)
+ return Collections.emptySet();
+ var out = new LinkedHashSet<String>();
+ if (v instanceof String str) {
+ for (var piece : str.split("\\s+"))
+ if (! piece.isBlank())
+ out.add(piece);
+ } else if (v instanceof Collection<?> list) {
+ for (var item : list)
+ if (item instanceof String str)
+ out.add(str);
+ }
+ return out;
+ }
+
+ private String buildSetCookie(String value, long maxAgeSeconds) {
+ var sb = new StringBuilder();
+ sb.append(cookieName).append('=').append(value);
+ sb.append("; Path=/");
+ sb.append("; Max-Age=").append(maxAgeSeconds);
+ if (cookieHttpOnly)
+ sb.append("; HttpOnly");
+ if (cookieSecure)
+ sb.append("; Secure");
+ if (cookieSameSite != null)
+ sb.append("; SameSite=").append(cookieSameSite);
+ return sb.toString();
+ }
+
+ private String readCookie(HttpServletRequest req) {
+ var cookies = req.getCookies();
+ if (cookies == null)
+ return null;
+ for (var c : cookies)
+ if (cookieName.equals(c.getName()) && c.getValue() != null && ! c.getValue().isBlank())
+ return c.getValue();
+ return null;
+ }
+
+ private static void noStore(HttpServletResponse res) {
+ res.setHeader("Cache-Control", "no-store");
+ res.setHeader("Pragma", "no-cache");
+ }
+
+ private static URI fullRequestUri(HttpServletRequest req) {
+ var url = req.getRequestURL();
+ var qs = req.getQueryString();
+ if (qs != null)
+ url.append('?').append(qs);
+ return URI.create(url.toString());
+ }
+
+ /**
+ * Returns the value only if it is a safe app-relative path (defeats open-redirect via the
+ * {@code redirect} parameter).
+ */
+ private static String safeRelativePath(String value) {
+ if (value == null || value.isBlank())
+ return null;
+ if (! value.startsWith("/"))
+ return null;
+ if (value.startsWith("//") || value.contains("\\") || value.contains("://"))
+ return null;
+ return value;
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/OidcSession.java b/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/OidcSession.java
new file mode 100644
index 0000000000..cf9c35af63
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/OidcSession.java
@@ -0,0 +1,91 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.auth.oidc.rp;
+
+import java.time.Instant;
+import java.util.*;
+
+import org.apache.juneau.rest.auth.ClaimsPrincipal;
+import org.apache.juneau.rest.auth.oauth.OAuthToken;
+
+/**
+ * Immutable record describing a logged-in OpenID Connect Relying Party session.
+ *
+ * <p>
+ * Created by {@link OidcRelyingParty#completeLogin} after a successful login and persisted via a
+ * {@link SessionStore}. The {@link #principal()} carries the verified ID-token claims and is what the
+ * {@link OidcSessionAuthFilter} surfaces to {@code RoleBasedRestGuard} / {@code @Auth Principal}.
+ *
+ * <p>
+ * Two fields support server-side (back-channel) revocation: {@link #subject()} (the {@code sub} claim)
+ * and {@link #sid()} (the optional {@code sid} session-id claim). A server-side-indexed
+ * {@link SessionStore} keys on these so an IdP-pushed logout token can invalidate the matching
+ * session(s).
+ *
+ * @param id The opaque server-side session id. For {@link InMemorySessionStore} this is the cache key;
+ * for {@link SignedCookieSessionStore} it is a stable identifier embedded in the signed cookie. Never
+ * {@code null}.
+ * @param subject The ID-token {@code sub} claim. Never {@code null} or blank.
+ * @param sid The ID-token {@code sid} (session id) claim, if the IdP issued one. Used for back-channel
+ * logout targeting a single session rather than all of a subject's sessions.
+ * @param principal The verified ID-token claims as a {@link ClaimsPrincipal}. Never {@code null}.
+ * @param roles The roles extracted from the configured roles claim. Never {@code null}; may be empty.
+ * @param token The tokens returned from the token endpoint (access / refresh / id), if the store retains
+ * them. {@link SignedCookieSessionStore} does NOT retain tokens (stateless cookies don't carry them),
+ * so this is {@link Optional#empty()} for cookie-backed sessions.
+ * @param createdAt The instant the session was created. Never {@code null}.
+ * @param expiresAt The instant the session expires (bounded independently of token lifetime). Never
+ * {@code null}.
+ * @since 9.5.0
+ */
+public record OidcSession(
+ String id,
+ String subject,
+ Optional<String> sid,
+ ClaimsPrincipal principal,
+ Set<String> roles,
+ Optional<OAuthToken> token,
+ Instant createdAt,
+ Instant expiresAt) {
+
+ /**
+ * Compact constructor enforcing non-null fields and defensively copying the role set.
+ */
+ public OidcSession {
+ Objects.requireNonNull(id, "id");
+ Objects.requireNonNull(subject, "subject");
+ Objects.requireNonNull(sid, "sid");
+ Objects.requireNonNull(principal, "principal");
+ Objects.requireNonNull(token, "token");
+ Objects.requireNonNull(createdAt, "createdAt");
+ Objects.requireNonNull(expiresAt, "expiresAt");
+ roles = roles == null
+ ? Collections.emptySet()
+ : Collections.unmodifiableSet(new LinkedHashSet<>(roles));
+ }
+
+ /**
+ * Returns whether this session is expired relative to the supplied instant.
+ *
+ * @param now The reference instant.
+ * @return <jk>true</jk> if {@code now} is at or after {@link #expiresAt()}.
+ */
+ public boolean isExpired(Instant now) {
+ Objects.requireNonNull(now, "now");
+ return !now.isBefore(expiresAt);
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/OidcSessionAuthFilter.java b/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/OidcSessionAuthFilter.java
new file mode 100644
index 0000000000..38c8e7e1fd
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/OidcSessionAuthFilter.java
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.auth.oidc.rp;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+
+import java.util.*;
+
+import org.apache.juneau.rest.auth.*;
+
+import jakarta.servlet.http.*;
+
+/**
+ * {@link AuthFilter} that resolves the OIDC Relying Party session cookie into a {@link ClaimsPrincipal}
+ * on each request, so {@code RoleBasedRestGuard} and {@code @Auth Principal} injection see the
+ * filter-resolved identity (via {@link AuthenticatedRequestWrapper}) with zero downstream changes.
+ *
+ * <p>
+ * Resolution is fail-open to <i>unauthenticated</i> (not 401): a request with no session cookie, or with
+ * an expired / tampered / unknown cookie, returns {@link Optional#empty()} so the chain passes through
+ * and the application's normal "not logged in" handling (typically a redirect to the login endpoint)
+ * applies. A valid session returns {@link AuthResult} carrying the session principal and roles.
+ *
+ * <p>
+ * Like all {@link AuthFilter}s, this is usable standalone or composed inside an {@code AuthFilterChain}.
+ * Note that Juneau's {@code MockRest} does not execute servlet filters, so integration tests drive
+ * {@link #authenticate(HttpServletRequest)} / {@code doFilter} directly.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='jc'>{@link OidcRelyingParty}
+ * <li class='jc'>{@link AuthFilter}
+ * <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/OidcRelyingParty">OIDC Relying Party</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+public class OidcSessionAuthFilter extends AuthFilter {
+
+ private final SessionStore sessionStore;
+ private final String cookieName;
+
+ /**
+ * Constructor.
+ *
+ * @param sessionStore The session store to resolve cookies against. Must not be <jk>null</jk>.
+ * @param cookieName The session cookie name. Must not be <jk>null</jk> or blank.
+ */
+ public OidcSessionAuthFilter(SessionStore sessionStore, String cookieName) {
+ this.sessionStore = assertArgNotNull("sessionStore", sessionStore);
+ this.cookieName = assertArgNotNullOrBlank("cookieName", cookieName);
+ }
+
+ @Override /* Overridden from AuthFilter */
+ public Optional<AuthResult> authenticate(HttpServletRequest req) throws AuthenticationException {
+ var cookies = req.getCookies();
+ if (cookies == null)
+ return Optional.empty();
+ String value = null;
+ for (var c : cookies) {
+ if (cookieName.equals(c.getName())) {
+ value = c.getValue();
+ break;
+ }
+ }
+ if (value == null || value.isBlank())
+ return Optional.empty();
+ var session = sessionStore.lookup(value);
+ if (session.isEmpty())
+ return Optional.empty();
+ var s = session.get();
+ return Optional.of(AuthResult.of(s.principal(), s.roles()));
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/SessionStore.java b/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/SessionStore.java
new file mode 100644
index 0000000000..a3726fa1b4
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/SessionStore.java
@@ -0,0 +1,129 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.auth.oidc.rp;
+
+import java.util.Optional;
+
+/**
+ * SPI for persisting {@link OidcSession} instances and resolving them back from a session-cookie value.
+ *
+ * <p>
+ * Two implementations are bundled:
+ * <ul>
+ * <li>{@link SignedCookieSessionStore} — the documented default. Stateless: the session is
+ * HMAC-signed and stored entirely in the cookie value. Scales horizontally and survives restarts
+ * with no shared store, but <b>cannot be server-revoked</b> (so it does not support back-channel
+ * logout — see {@link #supportsServerSideRevocation()}).
+ * <li>{@link InMemorySessionStore} — the simpler single-instance / dev option. Server-side
+ * indexed by {@code sub} and {@code sid}, so it <b>does</b> support back-channel logout. Lost on
+ * restart; breaks under horizontal scaling without sticky sessions.
+ * </ul>
+ *
+ * <p>
+ * Distributed stores (Redis / JDBC) are caller-supplied via this SPI. A distributed store that wants
+ * to participate in back-channel logout must implement {@link #invalidateBySubject(String)} /
+ * {@link #invalidateBySessionId(String)} and report {@link #supportsServerSideRevocation()} as
+ * <jk>true</jk>.
+ *
+ * <p>
+ * Implementations must be thread-safe.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='jc'>{@link OidcSession}
+ * <li class='jc'>{@link InMemorySessionStore}
+ * <li class='jc'>{@link SignedCookieSessionStore}
+ * <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/OidcRelyingParty">OIDC Relying Party</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+public interface SessionStore {
+
+ /**
+ * Persists the supplied session and returns the opaque value to set as the session cookie.
+ *
+ * <p>
+ * For a server-side store this is typically a random session id used as a lookup key; for a
+ * stateless cookie store it is the entire signed session payload.
+ *
+ * @param session The session to persist. Must not be <jk>null</jk>.
+ * @return The cookie value the client should send back on subsequent requests. Never <jk>null</jk>.
+ */
+ String createSessionCookieValue(OidcSession session);
+
+ /**
+ * Resolves a session from a cookie value.
+ *
+ * @param cookieValue The cookie value previously returned by {@link #createSessionCookieValue}.
+ * Must not be <jk>null</jk>.
+ * @return The resolved session, or {@link Optional#empty()} if absent, expired, invalid, or
+ * tampered.
+ */
+ Optional<OidcSession> lookup(String cookieValue);
+
+ /**
+ * Invalidates the session identified by the supplied cookie value. No-op if absent.
+ *
+ * @param cookieValue The cookie value. Must not be <jk>null</jk>.
+ */
+ void invalidate(String cookieValue);
+
+ /**
+ * Returns whether this store can revoke sessions server-side (a prerequisite for back-channel
+ * logout).
+ *
+ * <p>
+ * Defaults to <jk>false</jk> — stateless cookie stores cannot revoke a session the server
+ * never retained.
+ *
+ * @return <jk>true</jk> if {@link #invalidateBySubject(String)} / {@link #invalidateBySessionId(String)}
+ * are supported.
+ */
+ default boolean supportsServerSideRevocation() {
+ return false;
+ }
+
+ /**
+ * Invalidates every session belonging to the supplied subject ({@code sub}).
+ *
+ * <p>
+ * Used by {@link OidcRelyingParty#backChannelLogout} when the IdP-pushed logout token carries a
+ * {@code sub} but no {@code sid}.
+ *
+ * @param subject The subject identifier. Must not be <jk>null</jk> or blank.
+ * @return The number of sessions invalidated.
+ * @throws UnsupportedOperationException If this store does not support server-side revocation.
+ */
+ default int invalidateBySubject(String subject) {
+ throw new UnsupportedOperationException("This SessionStore does not support server-side revocation (back-channel logout requires an in-memory or distributed store).");
+ }
+
+ /**
+ * Invalidates the session(s) bound to the supplied IdP session id ({@code sid}).
+ *
+ * <p>
+ * Used by {@link OidcRelyingParty#backChannelLogout} when the IdP-pushed logout token carries a
+ * {@code sid}.
+ *
+ * @param sid The IdP session id. Must not be <jk>null</jk> or blank.
+ * @return The number of sessions invalidated.
+ * @throws UnsupportedOperationException If this store does not support server-side revocation.
+ */
+ default int invalidateBySessionId(String sid) {
+ throw new UnsupportedOperationException("This SessionStore does not support server-side revocation (back-channel logout requires an in-memory or distributed store).");
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/SignedCookieSessionStore.java b/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/SignedCookieSessionStore.java
new file mode 100644
index 0000000000..a29cd6b750
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/SignedCookieSessionStore.java
@@ -0,0 +1,232 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.auth.oidc.rp;
+
+import static org.apache.juneau.commons.utils.AssertionUtils.*;
+
+import java.nio.charset.*;
+import java.time.*;
+import java.util.*;
+
+import org.apache.juneau.rest.auth.ClaimsPrincipal;
+
+import com.nimbusds.jose.*;
+import com.nimbusds.jose.crypto.*;
+import com.nimbusds.jwt.*;
+
+/**
+ * Stateless {@link SessionStore} that serializes the session into an HMAC-signed compact JWT carried
+ * entirely in the session cookie. This is the documented default.
+ *
+ * <p>
+ * Because the session lives client-side, this store scales horizontally and survives server restarts
+ * with no shared store. The trade-offs:
+ * <ul>
+ * <li><b>No server-side revocation</b> — {@link #supportsServerSideRevocation()} is
+ * <jk>false</jk>; a stateless cookie cannot be force-revoked, so this store does NOT support
+ * back-channel logout. Use {@link InMemorySessionStore} or a caller-supplied distributed store
+ * if back-channel logout is required.
+ * <li><b>Size cap</b> — cookies are capped (~4 KB); only minimal claims are stored, never
+ * the raw access / refresh / ID tokens. An over-cap payload throws rather than silently truncating.
+ * <li><b>Key rotation</b> — rotating the signing key invalidates all live sessions (users are
+ * bounced to re-login).
+ * </ul>
+ *
+ * <p>
+ * The payload is signed with {@code HS256} using the configured signing key (which must be at least 256
+ * bits / 32 bytes). Signature failures, expiry, and parse errors all resolve to
+ * {@link Optional#empty()} on {@link #lookup(String)} (fail-closed).
+ *
+ * @since 9.5.0
+ */
+public class SignedCookieSessionStore implements SessionStore {
+
+ /** Default maximum serialized cookie size in bytes. */
+ public static final int DEFAULT_MAX_COOKIE_BYTES = 4096;
+
+ private static final String CLAIM_NAME = "n";
+ private static final String CLAIM_ROLES = "r";
+ private static final String CLAIM_SID = "sid";
+ private static final String CLAIM_CLAIMS = "c";
+
+ /**
+ * Static creator.
+ *
+ * @return A new builder.
+ */
+ public static Builder create() {
+ return new Builder();
+ }
+
+ /**
+ * Builder.
+ */
+ public static class Builder {
+ private byte[] signingKey;
+ private int maxCookieBytes = DEFAULT_MAX_COOKIE_BYTES;
+ private Clock clock = Clock.systemUTC();
+
+ /** Constructor. */
+ protected Builder() {}
+
+ /**
+ * Sets the HMAC signing key from a string (UTF-8 encoded). Required.
+ *
+ * @param value The signing key. Must be at least 32 bytes (256 bits) when UTF-8 encoded.
+ * @return This object.
+ */
+ public Builder signingKey(String value) {
+ assertArgNotNullOrBlank("value", value);
+ return signingKey(value.getBytes(StandardCharsets.UTF_8));
+ }
+
+ /**
+ * Sets the HMAC signing key bytes. Required.
+ *
+ * @param value The signing key. Must be at least 32 bytes (256 bits).
+ * @return This object.
+ */
+ public Builder signingKey(byte[] value) {
+ assertArgNotNull("value", value);
+ assertArg(value.length >= 32, "signingKey must be at least 32 bytes (256 bits) for HS256; was {0}", value.length);
+ signingKey = value.clone();
+ return this;
+ }
+
+ /**
+ * Sets the maximum serialized cookie size in bytes.
+ *
+ * @param value The cap. Must be positive.
+ * @return This object.
+ */
+ public Builder maxCookieBytes(int value) {
+ assertArg(value > 0, "maxCookieBytes must be positive (was {0})", value);
+ maxCookieBytes = value;
+ return this;
+ }
+
+ /**
+ * Overrides the clock used for expiry comparisons. Useful in tests.
+ *
+ * @param value The clock. Must not be <jk>null</jk>.
+ * @return This object.
+ */
+ public Builder clock(Clock value) {
+ clock = assertArgNotNull("value", value);
+ return this;
+ }
+
+ /**
+ * Builds the store.
+ *
+ * @return A new {@link SignedCookieSessionStore}.
+ */
+ public SignedCookieSessionStore build() {
+ if (signingKey == null)
+ throw new IllegalStateException("SignedCookieSessionStore requires signingKey(...)");
+ return new SignedCookieSessionStore(this);
+ }
+ }
+
+ private final byte[] signingKey;
+ private final int maxCookieBytes;
+ private final Clock clock;
+
+ /**
+ * Constructor.
+ *
+ * @param b The builder.
+ */
+ protected SignedCookieSessionStore(Builder b) {
+ this.signingKey = b.signingKey;
+ this.maxCookieBytes = b.maxCookieBytes;
+ this.clock = b.clock;
+ }
+
+ @Override /* Overridden from SessionStore */
+ public String createSessionCookieValue(OidcSession session) {
+ assertArgNotNull("session", session);
+ try {
+ var cb = new JWTClaimsSet.Builder()
+ .subject(session.subject())
+ .jwtID(session.id())
+ .issueTime(Date.from(session.createdAt()))
+ .expirationTime(Date.from(session.expiresAt()))
+ .claim(CLAIM_NAME, session.principal().getName())
+ .claim(CLAIM_ROLES, new ArrayList<>(session.roles()))
+ .claim(CLAIM_CLAIMS, session.principal().getClaims());
+ session.sid().ifPresent(sid -> cb.claim(CLAIM_SID, sid));
+ var jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), cb.build());
+ jwt.sign(new MACSigner(signingKey));
+ var serialized = jwt.serialize();
+ if (serialized.getBytes(StandardCharsets.UTF_8).length > maxCookieBytes)
+ throw new IllegalStateException("Signed session cookie exceeds maxCookieBytes (" + maxCookieBytes
+ + "); reduce the claim set or switch to a server-side SessionStore.");
+ return serialized;
+ } catch (JOSEException e) {
+ throw new IllegalStateException("Failed to sign session cookie", e);
+ }
+ }
+
+ @Override /* Overridden from SessionStore */
+ @SuppressWarnings("unchecked")
+ public Optional<OidcSession> lookup(String cookieValue) {
+ assertArgNotNull("cookieValue", cookieValue);
+ try {
+ var jwt = SignedJWT.parse(cookieValue);
+ if (! jwt.verify(new MACVerifier(signingKey)))
+ return Optional.empty();
+ var claims = jwt.getJWTClaimsSet();
+ var exp = claims.getExpirationTime();
+ if (exp == null || ! clock.instant().isBefore(exp.toInstant()))
+ return Optional.empty();
+ var subject = claims.getSubject();
+ var name = claims.getStringClaim(CLAIM_NAME);
+ if (subject == null || name == null)
+ return Optional.empty();
+ var roles = new LinkedHashSet<String>();
+ var rolesClaim = claims.getStringListClaim(CLAIM_ROLES);
+ if (rolesClaim != null)
+ roles.addAll(rolesClaim);
+ Map<String,Object> principalClaims = (Map<String,Object>) claims.getClaim(CLAIM_CLAIMS);
+ var principal = new ClaimsPrincipal(name, principalClaims);
+ var sid = Optional.ofNullable(claims.getStringClaim(CLAIM_SID));
+ var createdAt = claims.getIssueTime() != null ? claims.getIssueTime().toInstant() : clock.instant();
+ return Optional.of(new OidcSession(
+ claims.getJWTID(),
+ subject,
+ sid,
+ principal,
+ roles,
+ Optional.empty(),
+ createdAt,
+ exp.toInstant()));
+ } catch (java.text.ParseException | JOSEException | RuntimeException e) {
+ return Optional.empty();
+ }
+ }
+
+ @Override /* Overridden from SessionStore */
+ public void invalidate(String cookieValue) {
+ // Stateless: nothing to remove server-side. The caller clears the cookie on the response.
+ }
+
+ @Override /* Overridden from SessionStore */
+ public boolean supportsServerSideRevocation() {
+ return false;
+ }
+}
diff --git a/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/package-info.java b/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/package-info.java
new file mode 100644
index 0000000000..b7e8ae4f2e
--- /dev/null
+++ b/juneau-rest/juneau-rest-server-oidc-rp/src/main/java/org/apache/juneau/rest/auth/oidc/rp/package-info.java
@@ -0,0 +1,38 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * OpenID Connect Relying Party (RP) login support for Juneau REST servers.
+ *
+ * <p>
+ * This opt-in module turns a Juneau REST server into a complete OpenID Connect <i>Relying Party</i> —
+ * the "Log in with Google / Okta / Entra ID / Keycloak" end-to-end glue — sitting on top of
+ * {@code juneau-rest-server-oauth}. It orchestrates the browser redirect → callback → session
+ * dance, owning the {@code state} / {@code nonce} / PKCE bookkeeping, strict ID-token validation, the
+ * session SPI, and the request-time session→identity binding.
+ *
+ * <p>
+ * The {@code com.nimbusds:oauth2-oidc-sdk} dependency is {@code provided}-scoped and contained at the
+ * module boundary — it never leaks transitively.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='jc'>{@link org.apache.juneau.rest.auth.oidc.rp.OidcRelyingParty}
+ * <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/OidcRelyingParty">OIDC Relying Party</a>
+ * <li class='link'><a class="doclink" href="https://openid.net/specs/openid-connect-core-1_0.html">OpenID Connect Core 1.0</a>
+ * </ul>
+ */
+package org.apache.juneau.rest.auth.oidc.rp;
diff --git a/juneau-rest/juneau-rest-server-otel/src/main/java/org/apache/juneau/rest/tracing/otel/OtelTracerHook.java b/juneau-rest/juneau-rest-server-otel/src/main/java/org/apache/juneau/rest/tracing/otel/OtelTracerHook.java
index e4a0c8922c..be11f2a0a9 100644
--- a/juneau-rest/juneau-rest-server-otel/src/main/java/org/apache/juneau/rest/tracing/otel/OtelTracerHook.java
+++ b/juneau-rest/juneau-rest-server-otel/src/main/java/org/apache/juneau/rest/tracing/otel/OtelTracerHook.java
@@ -19,6 +19,8 @@ package org.apache.juneau.rest.tracing.otel;
import static org.apache.juneau.commons.utils.AssertionUtils.*;
import static org.apache.juneau.commons.utils.Utils.*;
+import java.util.*;
+
import org.apache.juneau.rest.*;
import org.apache.juneau.rest.tracing.*;
import org.apache.juneau.rest.tracing.Scope;
@@ -174,11 +176,35 @@ public class OtelTracerHook implements TracerHook {
Span span = spanBuilder.startSpan();
Context spanContext = extracted.with(span);
+
+ // TODO-114: Render the W3C trace-context headers from the server-started span's context (the only
+ // point where it's reliably active) and stash them as request attributes for
+ // TraceContextResponseProcessor to write on the response. The configured propagator only injects
+ // traceparent when the span context is valid, and tracestate only when non-empty, so both guards are
+ // handled here for free.
+ stashTraceContext(request, spanContext);
+
io.opentelemetry.context.Scope otelScope = spanContext.makeCurrent();
return new OtelScope(span, otelScope);
}
+ private void stashTraceContext(RestRequest request, Context spanContext) {
+ var carrier = new HashMap<String,String>(4);
+ propagator.inject(spanContext, carrier, MAP_SETTER);
+ var traceparent = carrier.get("traceparent");
+ if (nn(traceparent))
+ request.setAttribute(TraceContextResponseProcessor.ATTR_TRACEPARENT, traceparent);
+ var tracestate = carrier.get("tracestate");
+ if (nn(tracestate) && ! tracestate.isEmpty())
+ request.setAttribute(TraceContextResponseProcessor.ATTR_TRACESTATE, tracestate);
+ }
+
+ private static final TextMapSetter<HashMap<String,String>> MAP_SETTER = (carrier, key, value) -> {
+ if (nn(carrier))
+ carrier.put(key, value);
+ };
+
/**
* Returns the {@link Tracer} this hook publishes spans to.
*
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
index 152f395225..78a725ecd7 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestContext.java
@@ -87,6 +87,7 @@ import org.apache.juneau.rest.debug.*;
import org.apache.juneau.rest.debug.format.*;
import org.apache.juneau.rest.httppart.*;
import org.apache.juneau.rest.logger.*;
+import org.apache.juneau.rest.metrics.*;
import org.apache.juneau.rest.processor.*;
import org.apache.juneau.rest.rrpc.*;
import org.apache.juneau.rest.servlet.*;
@@ -94,6 +95,7 @@ import org.apache.juneau.rest.staticfile.*;
import org.apache.juneau.rest.stats.*;
import org.apache.juneau.rest.openapi.*;
import org.apache.juneau.rest.swagger.*;
+import org.apache.juneau.rest.tracing.*;
import org.apache.juneau.rest.util.*;
import org.apache.juneau.rest.validation.*;
import org.apache.juneau.rest.vars.*;
@@ -283,6 +285,26 @@ public class RestContext extends Context {
*/
String[] paths;
+ /**
+ * Programmatic override for the MDC async propagation flag.
+ *
+ * <p>
+ * {@code null} (default) — defers to the {@code RestContext.mdcAsyncPropagation} env-driven
+ * default (itself defaulting to {@code true} when SLF4J is detected). Set via
+ * {@link #mdcAsyncPropagation(boolean)}.
+ */
+ Boolean mdcAsyncPropagation;
+
+ /**
+ * Programmatic override for the async-completion executor bean name.
+ *
+ * <p>
+ * {@code null} (default) — resolved from the {@code @Rest(asyncCompletionExecutor)} annotation chain.
+ * Set via {@link #asyncCompletionExecutor(String)} to supply the bean-name programmatically (useful in
+ * test rigs that construct a {@link RestContext} directly without annotations).
+ */
+ String asyncCompletionExecutorName;
+
/**
* Package-private constructor.
*
@@ -327,6 +349,49 @@ public class RestContext extends Context {
return this;
}
+ /**
+ * Enables or disables SLF4J MDC propagation from the request thread to
+ * {@link java.util.concurrent.CompletableFuture} completion threads.
+ *
+ * <p>
+ * Default is {@code true} (enabled) when SLF4J is on the runtime classpath.
+ * When enabled, the {@link org.apache.juneau.rest.processor.MdcAsyncListener} snapshots the
+ * request thread's MDC map before the async dispatch and restores it inside the
+ * {@link java.util.concurrent.CompletionStage#whenComplete(java.util.function.BiConsumer) whenComplete}
+ * callback so that log statements emitted during async completion see the original request context.
+ *
+ * <p>
+ * Call {@code mdcAsyncPropagation(false)} to opt a resource out of MDC propagation. The global default
+ * can also be overridden via the {@code RestContext.mdcAsyncPropagation} env-driven default.
+ *
+ * @param value {@code false} to disable MDC propagation on this resource.
+ * @return This object.
+ * @since 9.5.0
+ */
+ public Builder mdcAsyncPropagation(boolean value) {
+ mdcAsyncPropagation = value;
+ return this;
+ }
+
+ /**
+ * Programmatically sets the {@link java.util.concurrent.Executor} bean name used to route
+ * {@link java.util.concurrent.CompletableFuture} completion callbacks through a dedicated thread pool
+ * (TODO-118). Equivalent to {@link Rest#asyncCompletionExecutor()} on the resource class.
+ *
+ * <p>
+ * The value is looked up by name in the resource's bean store at context-build time. If the name does
+ * not resolve to an {@link java.util.concurrent.Executor} bean, the context constructor throws. Pass
+ * {@code null} (or omit this call) to clear the override and fall back to the annotation chain.
+ *
+ * @param beanName The bean-store name of the {@link java.util.concurrent.Executor} to use, or {@code null} to clear.
+ * @return This object.
+ * @since 9.5.0
+ */
+ public Builder asyncCompletionExecutor(String beanName) {
+ asyncCompletionExecutorName = beanName;
+ return this;
+ }
+
@Override /* Context.Builder is abstract - copy() is not meaningful for the transient RestContext bootstrap state. */
public Builder copy() {
throw new NoSuchMethodError("Not implemented.");
@@ -1019,6 +1084,25 @@ public class RestContext extends Context {
@Value("${RestContext.virtualThreads:false}")
private boolean defaultVirtualThreads;
+ /**
+ * Env-driven default controlling whether the {@link TraceContextResponseProcessor} is registered so the
+ * server writes W3C {@code traceparent} / {@code tracestate} response headers when a {@link TracerHook} is
+ * active (TODO-114). Defaults to {@code true} (on-when-tracer); set {@code RestContext.responseTraceparent=false}
+ * to keep the processor out of the chain entirely.
+ */
+ @Value("${RestContext.responseTraceparent:true}")
+ private boolean defaultResponseTraceparent;
+
+ /**
+ * Env-driven default controlling whether {@link org.apache.juneau.rest.processor.MdcAsyncListener} propagates
+ * the request thread's SLF4J MDC map to {@link java.util.concurrent.CompletableFuture} completion threads
+ * (TODO-117). Defaults to {@code true} (on when SLF4J is detected). Set
+ * {@code RestContext.mdcAsyncPropagation=false} to disable globally, or call
+ * {@link Builder#mdcAsyncPropagation(boolean)} per resource.
+ */
+ @Value("${RestContext.mdcAsyncPropagation:true}")
+ private boolean defaultMdcAsyncPropagation;
+
/** Env-driven default for {@code @Rest(eagerInit)}. */
@Value("${RestContext.eagerInit:false}")
private boolean defaultEagerInit;
@@ -1497,6 +1581,11 @@ public class RestContext extends Context {
// ResponseProcessorList.Builder.add(...) uses addAll (append) — final order: [DefaultConfig, parent, child].
var bs = beanStore();
var b = ResponseProcessorList.create(bs);
+ // TODO-114: When enabled (default on-when-tracer), front-load the W3C trace-context processor so it
+ // writes traceparent/tracestate headers before the body-rendering processors run. It short-circuits
+ // at request time when no TracerHook stashed a trace context, so it's zero-cost on the no-tracer path.
+ if (defaultResponseTraceparent)
+ b.add(TraceContextResponseProcessor.class);
getRestAnnotationsForProperty(PROPERTY_responseProcessors)
.forEach(ai -> b.add(ai.inner().responseProcessors()));
// @Bean method override REPLACES the entire annotation-derived list.
@@ -2112,12 +2201,21 @@ public class RestContext extends Context {
.addBean(AnnotationWorkList.class, annotationWork);
// @formatter:on
- // Inject @Value-annotated env-driven defaults onto this RestContext instance so that the
- // memoizer lambdas below (which run lazily on first .get() call) read fully resolved values.
- // Must run BEFORE isEagerInit() since the eagerInit memoizer itself reads defaultEagerInit.
- ClassInfo.of(this).inject(this, beanStore);
+ // Inject @Value-annotated env-driven defaults onto this RestContext instance so that the
+ // memoizer lambdas below (which run lazily on first .get() call) read fully resolved values.
+ // Must run BEFORE isEagerInit() since the eagerInit memoizer itself reads defaultEagerInit.
+ ClassInfo.of(this).inject(this, beanStore);
- if (isEagerInit()) {
+ // Startup-fail if @Rest(observability="true") is set but neither MetricsRecorder nor TracerHook is
+ // supplied. The check runs after all @Bean method injection so that consumer-provided beans are visible.
+ checkObservabilityBackendPresent();
+
+ // Startup-fail (TODO-118): if asyncCompletionExecutor is configured (either by annotation or
+ // programmatically), force-evaluate the memoizer now so "bean not found" surfaces at context
+ // init time rather than lazily on the first request.
+ checkAsyncCompletionExecutorPresent();
+
+ if (isEagerInit()) {
// Force-fire the framework-bean memoizers in dependency-friendly order so their @Rest()
// annotation walks (e.g. `@Rest(partParser=…)`, `@Rest(partSerializer=…)`, `@Rest(encoders=…)`,
// etc.) execute eagerly inside the try-catch. These walks MUTATE the cached creators that
@@ -2354,6 +2452,17 @@ public class RestContext extends Context {
private final Memoizer<Boolean> virtualThreadsEnabled = memoizer(() ->
mergeReplacedBooleanAttribute(PROPERTY_virtualThreads, defaultVirtualThreads));
+ /**
+ * Resource-level observability tri-state from {@code @Rest(observability)}: {@code "true"} (strict opt-in),
+ * {@code "false"} (explicit opt-out), or {@code null} / empty (default inherit behavior).
+ *
+ * <p>
+ * Per-operation overrides (from verb / {@code @RestOp} annotations) are resolved independently in
+ * {@link RestOpContext}.
+ */
+ private final Memoizer<String> observabilityAttribute = memoizer(() ->
+ mergeReplacedStringAttribute(PROPERTY_observability, null));
+
/**
* Configurable async-response timeout (milliseconds) applied by {@code AsyncResponseProcessor} to
* {@link CompletableFuture}-returning handlers; resolved from {@code @Rest(asyncTimeoutMillis)} (TODO-70).
@@ -2374,6 +2483,38 @@ public class RestContext extends Context {
}
});
+ /**
+ * The async-completion executor for this resource, resolved by name from the bean store; {@code null} when
+ * {@code @Rest(asyncCompletionExecutor)} is blank (the common case).
+ *
+ * <p>
+ * The memoizer throws {@link IllegalStateException} at first access if the bean name was supplied but no
+ * matching {@link Executor} bean is found — this surfaces as a startup failure, not a silent no-op (TODO-118).
+ */
+ private final Memoizer<Executor> asyncCompletionExecutor = memoizer(() -> {
+ // Programmatic override takes priority over the annotation chain.
+ var name = resolveAsyncCompletionExecutorName();
+ if (name == null || name.isBlank())
+ return null;
+ var resolved = beanStore().getBean(Executor.class, name);
+ // Try ExecutorService as well (a common supertype users supply that is-a Executor).
+ if (resolved.isEmpty())
+ resolved = beanStore().getBean(ExecutorService.class, name).map(e -> (Executor) e);
+ if (resolved.isEmpty())
+ throw new IllegalStateException(
+ "@Rest(asyncCompletionExecutor) on " + getResourceClass().getName()
+ + " references bean name '" + name + "' but no Executor (or ExecutorService) bean"
+ + " with that name is registered in the resource's bean store.");
+ return resolved.get();
+ });
+
+ // Extracted to a method so the compiler does not flag a blank-final-field access inside a field-initializer lambda.
+ private String resolveAsyncCompletionExecutorName() {
+ return builder.asyncCompletionExecutorName != null
+ ? builder.asyncCompletionExecutorName
+ : mergeReplacedStringAttribute(PROPERTY_asyncCompletionExecutor, null);
+ }
+
/**
* Lazily-instantiated virtual-thread executor used by {@link RestOpInvoker} when
* {@code @Rest(virtualThreads=true)} is set on this resource and the runtime is Java 21+.
@@ -3568,6 +3709,66 @@ public class RestContext extends Context {
*/
public boolean isVirtualThreadsEnabled() { return virtualThreadsEnabled.get(); }
+ /**
+ * Returns the raw resource-level observability attribute value from {@code @Rest(observability)}.
+ *
+ * <ul class='values'>
+ * <li>{@code "true"} — strict opt-in: all operations on this resource require a wired backend.
+ * <li>{@code "false"} — explicit opt-out: the observability block is disabled for all operations.
+ * <li>{@code null} / empty — default (inherit / no-op fallback) behavior.
+ * </ul>
+ *
+ * <p>
+ * Per-operation overrides from {@link org.apache.juneau.rest.annotation.RestOp#observability()} (and verb
+ * annotations) take precedence over this value; they are resolved in {@link RestOpContext}.
+ *
+ * @return The resolved {@code @Rest(observability)} string, or {@code null} when not set.
+ */
+ public String getObservabilityAttribute() { return observabilityAttribute.get(); }
+
+ /**
+ * Returns whether the resource explicitly opted out of observability via {@code @Rest(observability="false")}.
+ *
+ * @return <jk>true</jk> when the resource-level observability is disabled.
+ */
+ public boolean isObservabilityDisabled() {
+ var v = observabilityAttribute.get();
+ return v != null && v.equalsIgnoreCase("false");
+ }
+
+ /**
+ * Returns whether the server writes W3C {@code traceparent} / {@code tracestate} response headers when a
+ * {@link TracerHook} is active on the request (TODO-114).
+ *
+ * <p>
+ * Defaults to {@code true} (on-when-tracer); resolved from the {@code RestContext.responseTraceparent}
+ * env-driven default. When {@code true}, a {@link TraceContextResponseProcessor} is registered in the
+ * response-processor chain — it writes the headers only when a non-no-op {@code TracerHook} stashed a
+ * trace context for the request, so it stays zero-cost on the no-tracer path. When {@code false}, the
+ * processor is not registered at all.
+ *
+ * @return <jk>true</jk> if trace-context response-header propagation is enabled on this resource.
+ */
+ public boolean isResponseTraceparent() { return defaultResponseTraceparent; }
+
+ /**
+ * Returns whether SLF4J MDC propagation is enabled for {@link java.util.concurrent.CompletableFuture}
+ * completion threads on this resource (TODO-117).
+ *
+ * <p>
+ * Defaults to {@code true}; resolved first from the programmatic {@link Builder#mdcAsyncPropagation(boolean)}
+ * override (if set), then from the {@code RestContext.mdcAsyncPropagation} env-driven default.
+ * When {@code true}, {@link org.apache.juneau.rest.processor.MdcAsyncListener} wraps the
+ * {@link java.util.concurrent.CompletionStage#whenComplete(java.util.function.BiConsumer) whenComplete} callback
+ * registered by {@link org.apache.juneau.rest.processor.AsyncResponseProcessor} so the completion thread sees
+ * the request thread's MDC context. When {@code false}, no MDC work is done.
+ *
+ * @return <jk>true</jk> if MDC async propagation is enabled on this resource.
+ */
+ public boolean isMdcAsyncPropagation() {
+ return builder.mdcAsyncPropagation != null ? builder.mdcAsyncPropagation : defaultMdcAsyncPropagation;
+ }
+
/**
* Returns the lazily-instantiated virtual-thread executor for this resource, or {@code null} when
* {@code @Rest(virtualThreads=true)} is unset, the runtime is older than Java 21, or executor construction
@@ -3586,6 +3787,16 @@ public class RestContext extends Context {
*/
public long getAsyncTimeoutMillis() { return asyncTimeoutMillis.get(); }
+ /**
+ * Returns the {@link java.util.concurrent.Executor} configured for routing
+ * {@link java.util.concurrent.CompletableFuture} completion callbacks on this resource (TODO-118), or
+ * {@code null} when {@code @Rest(asyncCompletionExecutor)} is unset (natural completion thread).
+ *
+ * @return The executor, or {@code null} if no async-completion executor is configured.
+ * @since 9.5.0
+ */
+ public Executor getAsyncCompletionExecutor() { return asyncCompletionExecutor.get(); }
+
/**
* Returns whether framework beans and operation/child contexts are eagerly initialized at construction time.
*
@@ -3659,6 +3870,52 @@ public class RestContext extends Context {
* Ensures framework bean memoizers that can mutate creator state from {@code @Rest(...)} annotation walks
* have run before {@link RestOpContext} instances are built.
*/
+ /**
+ * Validates that when {@code @Rest(observability="true")} is declared on this resource, at least one real
+ * observability backend ({@link org.apache.juneau.rest.metrics.MetricsRecorder} or
+ * {@link org.apache.juneau.rest.tracing.TracerHook}) is registered in the bean store.
+ *
+ * <p>
+ * Called once during {@link RestContext} construction, after all {@code @Bean} method injection has run.
+ * If the attribute is {@code "true"} and both beans resolve to their {@code NoOp} singletons, construction
+ * fails with a {@link org.apache.juneau.rest.BasicHttpException InternalServerError} whose message precisely
+ * identifies the missing backends.
+ *
+ * <p>
+ * Per-operation {@code @RestOp(observability="true")} startup-fails are handled the same way inside
+ * {@link RestOpContext}.
+ *
+ * @throws InternalServerError when {@code @Rest(observability="true")} is set but no backend is wired.
+ */
+ private void checkObservabilityBackendPresent() {
+ var attr = observabilityAttribute.get();
+ if (!"true".equalsIgnoreCase(attr))
+ return;
+ var recorder = beanStore.getBean(MetricsRecorder.class).orElse(null);
+ var tracer = beanStore.getBean(TracerHook.class).orElse(null);
+ boolean hasRecorder = recorder != null && !(recorder instanceof NoOpMetricsRecorder);
+ boolean hasTracer = tracer != null && !(tracer instanceof NoOpTracerHook);
+ if (!hasRecorder && !hasTracer)
+ throw new InternalServerError(
+ "@Rest(observability=\"true\") is set on " + getResourceClass().getSimpleName()
+ + " but no MetricsRecorder or TracerHook @Bean is registered. "
+ + "Either wire a backend or change observability to \"\" (inherit) or \"false\" (opt-out).");
+ }
+
+ /**
+ * Eagerly evaluates the {@link #asyncCompletionExecutor} memoizer when the annotation or programmatic
+ * override is non-blank, ensuring "bean not found" surfaces as a startup failure (TODO-118).
+ *
+ * @throws IllegalStateException if the named executor bean is not in the bean store.
+ */
+ private void checkAsyncCompletionExecutorPresent() {
+ var name = builder.asyncCompletionExecutorName != null
+ ? builder.asyncCompletionExecutorName
+ : mergeReplacedStringAttribute(PROPERTY_asyncCompletionExecutor, null);
+ if (name != null && !name.isBlank())
+ asyncCompletionExecutor.get(); // throws IllegalStateException if bean is missing
+ }
+
private void initializeFrameworkBeansForRestOps() {
getMarshallingContext();
getEncoders();
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpContext.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpContext.java
index b892673e8e..0329657efb 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpContext.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpContext.java
@@ -53,6 +53,7 @@ import org.apache.juneau.httppart.bean.*;
import org.apache.juneau.jsonschema.*;
import org.apache.juneau.parser.*;
import org.apache.juneau.parser.ParseException;
+import org.apache.juneau.http.response.*;
import org.apache.juneau.rest.annotation.*;
import org.apache.juneau.rest.common.utils.*;
import org.apache.juneau.rest.converter.*;
@@ -678,6 +679,63 @@ public class RestOpContext extends Context implements Comparable<RestOpContext>
return -1L;
});
+ /**
+ * Per-op async-completion executor: op-level bean name overrides {@code @Rest}-level, then both are
+ * resolved against the resource's bean store. {@code null} when unset (natural completion thread).
+ */
+ private final Memoizer<Executor> asyncCompletionExecutor = memoizer(() -> {
+ var v = findOpString(PROPERTY_asyncCompletionExecutor);
+ if (v.isPresent()) {
+ var name = v.get();
+ var bs = beanStore();
+ var resolved = bs.getBean(Executor.class, name);
+ if (resolved.isEmpty())
+ resolved = bs.getBean(ExecutorService.class, name).map(e -> (Executor) e);
+ if (resolved.isEmpty())
+ throw new IllegalStateException(
+ "@RestOp(asyncCompletionExecutor) on " + restContext().getResourceClass().getName()
+ + "#" + method().getName() + " references bean name '" + name + "' but no Executor"
+ + " (or ExecutorService) bean with that name is registered in the resource's bean store.");
+ return resolved.get();
+ }
+ if (isInherited(PROPERTY_asyncCompletionExecutor))
+ return restContext().getAsyncCompletionExecutor();
+ return null;
+ });
+
+ /**
+ * Effective per-op observability state, resolving verb / {@code @RestOp} annotation override first,
+ * then falling back to the resource-level {@code @Rest(observability)} attribute.
+ *
+ * <ul class='values'>
+ * <li>{@code "true"} — strict opt-in; this operation requires a wired backend.
+ * <li>{@code "false"} — explicit opt-out; the observability block is short-circuited.
+ * <li>{@code null} — default inherit behavior; a missing backend silently falls back to NoOp.
+ * </ul>
+ */
+ private final Memoizer<String> observabilityAttr = memoizer(() -> {
+ var v = findOpString(PROPERTY_observability);
+ if (v.isPresent())
+ return v.get();
+ if (isInherited(PROPERTY_observability))
+ return restContext().getObservabilityAttribute();
+ return null;
+ });
+
+ /**
+ * Per-op metric name override from {@code @RestOp(metricName)}.
+ * Empty string (default) means the recorder uses its own default name derivation.
+ */
+ private final Memoizer<String> metricName = memoizer(() ->
+ findOpString(PROPERTY_metricName).orElse(""));
+
+ /**
+ * Per-op additional metric tags from {@code @RestOp(metricTags)}.
+ * Format: comma-separated {@code key=value} pairs. Empty string (default) means no additional tags.
+ */
+ private final Memoizer<String> metricTags = memoizer(() ->
+ findOpString(PROPERTY_metricTags).orElse(""));
+
/** Aggregated {@code noInherit} keys from all RestOp-group annotations on this operation. */
private final Memoizer<SortedSet<String>> noInheritOp = memoizer(() -> {
var l = getRestOpAnnotations().stream()
@@ -1088,6 +1146,32 @@ public class RestOpContext extends Context implements Comparable<RestOpContext>
* @param attr The annotation attribute name (e.g. {@code "defaultCharset"}).
* @return The resolved string, or empty if no annotation defines it.
*/
+ /**
+ * Startup-fail: validates that when the effective observability attribute for this op is {@code "true"},
+ * at least one real backend ({@link org.apache.juneau.rest.metrics.MetricsRecorder} or
+ * {@link org.apache.juneau.rest.tracing.TracerHook}) is registered.
+ *
+ * <p>
+ * Called once during construction. The resource-level check in {@link RestContext#checkObservabilityBackendPresent()}
+ * already fires for {@code @Rest(observability="true")}; this companion fires for individual
+ * {@code @RestOp(observability="true")} overrides that elevate a single op into strict mode.
+ */
+ private void checkOpObservabilityBackendPresent() {
+ var attr = observabilityAttr.get();
+ if (!"true".equalsIgnoreCase(attr))
+ return;
+ var bs = beanStore();
+ var recorder = bs.getBean(org.apache.juneau.rest.metrics.MetricsRecorder.class).orElse(null);
+ var tracer = bs.getBean(org.apache.juneau.rest.tracing.TracerHook.class).orElse(null);
+ boolean hasRecorder = recorder != null && !(recorder instanceof org.apache.juneau.rest.metrics.NoOpMetricsRecorder);
+ boolean hasTracer = tracer != null && !(tracer instanceof org.apache.juneau.rest.tracing.NoOpTracerHook);
+ if (!hasRecorder && !hasTracer)
+ throw new InternalServerError(
+ "@RestOp(observability=\"true\") is set on " + context.getResourceClass().getSimpleName() + "." + method.getName()
+ + " but no MetricsRecorder or TracerHook @Bean is registered. "
+ + "Either wire a backend or change observability to \"\" (inherit) or \"false\" (opt-out).");
+ }
+
private Optional<String> findOpString(String attr) {
var vr = varResolver();
for (var ai : getRestOpAnnotations()) {
@@ -1226,13 +1310,17 @@ public class RestOpContext extends Context implements Comparable<RestOpContext>
bs.add(HttpPartSerializer.class, getPartSerializer());
bs.add(SerializerSet.class, getSerializers());
- // Inject @Value-annotated env-driven defaults onto this RestOpContext instance so that the
- // memoizer lambdas (defaultCharset, maxInput) read fully resolved values when first invoked.
- ClassInfo.of(this).inject(this, bs);
+ // Inject @Value-annotated env-driven defaults onto this RestOpContext instance so that the
+ // memoizer lambdas (defaultCharset, maxInput) read fully resolved values when first invoked.
+ ClassInfo.of(this).inject(this, bs);
+
+ // Startup-fail check: if this op (or its resource) declares observability="true" but neither
+ // MetricsRecorder nor TracerHook is wired, fail fast with a precise error.
+ checkOpObservabilityBackendPresent();
- // The 6 formerly-eager scalar fields are now memoized; no eagerness needed here.
- // Pre-warm httpMethod so it is in the memoizer cache for immediate use by compareTo/match.
- httpMethod.get();
+ // The 6 formerly-eager scalar fields are now memoized; no eagerness needed here.
+ // Pre-warm httpMethod so it is in the memoizer cache for immediate use by compareTo/match.
+ httpMethod.get();
var pm = getPathMatchers();
bs.add(UrlPathMatcher[].class, pm);
@@ -1464,6 +1552,43 @@ public class RestOpContext extends Context implements Comparable<RestOpContext>
*/
public boolean isVirtualThreadsEnabled() { return virtualThreadsEnabled.get(); }
+ /**
+ * Returns whether the observability block is enabled for this operation.
+ *
+ * <p>
+ * Returns {@code false} when the effective resolved attribute is {@code "false"} (explicit opt-out).
+ * Returns {@code true} in all other cases ({@code "true"} strict opt-in or empty/null default).
+ *
+ * <p>
+ * Note: the default behavior (empty/null attribute) still runs the observability block, but silently
+ * falls back to {@link org.apache.juneau.rest.metrics.NoOpMetricsRecorder} when no backend is wired.
+ * Only {@code "false"} completely short-circuits the block.
+ *
+ * @return <jk>false</jk> when this operation has explicitly opted out of observability.
+ * @see RestOp#observability()
+ * @see Rest#observability()
+ */
+ public boolean isObservabilityEnabled() {
+ var v = observabilityAttr.get();
+ return !"false".equalsIgnoreCase(v);
+ }
+
+ /**
+ * Returns the per-op metric name override from {@code @RestOp(metricName)}.
+ *
+ * @return The metric name override, or an empty string when not set.
+ * @see RestOp#metricName()
+ */
+ public String getMetricName() { return metricName.get(); }
+
+ /**
+ * Returns the per-op additional metric tags from {@code @RestOp(metricTags)}.
+ *
+ * @return The tags string (comma-separated {@code key=value} pairs), or empty string when not set.
+ * @see RestOp#metricTags()
+ */
+ public String getMetricTags() { return metricTags.get(); }
+
/**
* Returns the configured async-response timeout (milliseconds) for this operation, or {@code -1} when no
* value was supplied at the op or resource level — in which case the default 30-second fallback applies in
@@ -1473,6 +1598,16 @@ public class RestOpContext extends Context implements Comparable<RestOpContext>
*/
public long getAsyncTimeoutMillis() { return asyncTimeoutMillis.get(); }
+ /**
+ * Returns the effective {@link java.util.concurrent.Executor} for routing
+ * {@link java.util.concurrent.CompletableFuture} completion callbacks on this operation (TODO-118), or
+ * {@code null} when neither the op nor the resource configures an executor (natural completion thread).
+ *
+ * @return The executor, or {@code null} if no async-completion executor is configured.
+ * @since 9.5.0
+ */
+ public Executor getAsyncCompletionExecutor() { return asyncCompletionExecutor.get(); }
+
/**
* Returns the parsers to use for this method.
*
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpInvoker.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpInvoker.java
index c86deeecc2..097429b78d 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpInvoker.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestOpInvoker.java
@@ -165,7 +165,11 @@ public class RestOpInvoker extends MethodInvoker {
long startNanos = 0L;
Throwable observed = null;
boolean observabilityDeferred = false;
- if (observable) {
+ var opContext = opSession.getContext();
+ // observable=true means this is the @RestOp handler (not a pre/post call), AND observability is not
+ // explicitly disabled for this operation via @Rest(observability="false") or @RestOp(observability="false").
+ boolean effectivelyObservable = observable && opContext.isObservabilityEnabled();
+ if (effectivelyObservable) {
var bs = opSession.getRestContext().getBeanStore();
recorder = bs.getBean(MetricsRecorder.class).orElse(NoOpMetricsRecorder.INSTANCE);
var tracer = bs.getBean(TracerHook.class).orElse(NoOpTracerHook.INSTANCE);
@@ -190,9 +194,9 @@ public class RestOpInvoker extends MethodInvoker {
if (! inner().hasReturnType(Void.TYPE) && (nn(output) || ! res.getOutputStreamCalled()))
res.setContent(output);
- if (observable && output instanceof CompletionStage<?> stage) {
+ if (effectivelyObservable && output instanceof CompletionStage<?> stage) {
observabilityDeferred = true;
- deferObservability(stage, recorder, tracerScope, startNanos, opSession);
+ deferObservability(stage, recorder, tracerScope, startNanos, opSession, opContext.getMetricName(), opContext.getMetricTags());
}
} catch (IllegalAccessException | IllegalArgumentException e) {
@@ -204,7 +208,7 @@ public class RestOpInvoker extends MethodInvoker {
res.setStatus(500); // May be overridden later.
res.setContent(opSession.getRestContext().convertThrowable(e2));
} finally {
- if (observable && ! observabilityDeferred) {
+ if (effectivelyObservable && ! observabilityDeferred) {
int status = res.getStatus();
if (status == 0)
status = (observed == null) ? 200 : 500;
@@ -216,9 +220,9 @@ public class RestOpInvoker extends MethodInvoker {
try {
tracerScope.close();
} finally {
- var elapsed = Duration.ofNanos(System.nanoTime() - startNanos);
- var pathTemplate = resolveUriTemplate(opSession);
- recorder.record(getFullName(), req.getMethod(), pathTemplate, status, elapsed, observed);
+ var elapsed = Duration.ofNanos(System.nanoTime() - startNanos);
+ var pathTemplate = resolveUriTemplate(opSession);
+ recorder.record(getFullName(), req.getMethod(), pathTemplate, status, elapsed, observed, opContext.getMetricName(), opContext.getMetricTags());
}
}
}
@@ -226,7 +230,7 @@ public class RestOpInvoker extends MethodInvoker {
}
private void deferObservability(CompletionStage<?> stage, MetricsRecorder recorder, Scope tracerScope,
- long startNanos, RestOpSession opSession) {
+ long startNanos, RestOpSession opSession, String metricName, String metricTags) {
var fullName = getFullName();
var httpMethod = opSession.getRequest().getMethod();
var pathTemplate = resolveUriTemplate(opSession);
@@ -241,8 +245,8 @@ public class RestOpInvoker extends MethodInvoker {
try {
tracerScope.close();
} finally {
- var elapsed = Duration.ofNanos(System.nanoTime() - startNanos);
- recorder.record(fullName, httpMethod, pathTemplate, status, elapsed, err);
+ var elapsed = Duration.ofNanos(System.nanoTime() - startNanos);
+ recorder.record(fullName, httpMethod, pathTemplate, status, elapsed, err, metricName, metricTags);
}
}
});
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestServerConstants.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestServerConstants.java
index 221cb485ff..23263fb722 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestServerConstants.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/RestServerConstants.java
@@ -83,6 +83,14 @@ public final class RestServerConstants {
*/
public static final String PROPERTY_asyncTimeoutMillis = "asyncTimeoutMillis";
+ /**
+ * The {@code "asyncCompletionExecutor"} annotation attribute name — names a {@link java.util.concurrent.Executor}
+ * bean (resolved from the resource's {@link org.apache.juneau.commons.inject.BeanStore}) that routes
+ * {@link java.util.concurrent.CompletableFuture} completion callbacks through a dedicated thread pool instead of
+ * the future's natural completion thread (TODO-118). Empty string (default) = no override.
+ */
+ public static final String PROPERTY_asyncCompletionExecutor = "asyncCompletionExecutor";
+
/** The {@code "clientVersionHeader"} annotation attribute name — used in {@code noInherit} matching. */
public static final String PROPERTY_clientVersionHeader = "clientVersionHeader";
@@ -212,6 +220,35 @@ public final class RestServerConstants {
/** The {@code "value"} annotation attribute name — used by {@code @RestOp}/verb annotations to hold the (optional method-prefixed) path; folded into {@link #PROPERTY_path}. */
public static final String PROPERTY_value = "value";
+ /**
+ * The {@code "observability"} annotation attribute name — tri-state per-resource / per-op observability control.
+ * {@code "true"} opts the resource in and requires a wired backend (startup-fail if missing); {@code "false"} short-circuits
+ * the observability block entirely; {@code ""} (default) inherits / uses the existing silent-no-op behavior.
+ *
+ * @see org.apache.juneau.rest.annotation.Rest#observability()
+ * @see org.apache.juneau.rest.annotation.RestOp#observability()
+ */
+ public static final String PROPERTY_observability = "observability";
+
+ /**
+ * The {@code "metricName"} annotation attribute name — per-op override for the metric name passed to
+ * {@link org.apache.juneau.rest.metrics.MetricsRecorder#record record()}. Empty string (default) uses
+ * the recorder's own default name derivation.
+ *
+ * @see org.apache.juneau.rest.annotation.RestOp#metricName()
+ */
+ public static final String PROPERTY_metricName = "metricName";
+
+ /**
+ * The {@code "metricTags"} annotation attribute name — per-op additional metric tags passed to
+ * {@link org.apache.juneau.rest.metrics.MetricsRecorder#record record()}. Format: comma-separated
+ * {@code key=value} pairs (e.g. {@code "team=payments,region=us-east"}). Empty string (default) means
+ * no additional tags.
+ *
+ * @see org.apache.juneau.rest.annotation.RestOp#metricTags()
+ */
+ public static final String PROPERTY_metricTags = "metricTags";
+
/**
* Servlet-request attribute key under which the per-request id minted or honored by
* {@code org.apache.juneau.rest.filter.RequestIdFilter} is stashed.
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/Rest.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/Rest.java
index f6f7d1521c..22f911bf08 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/Rest.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/Rest.java
@@ -741,6 +741,47 @@ public @interface Rest {
*/
String virtualThreads() default "";
+ /**
+ * Per-resource observability opt-in / opt-out control.
+ *
+ * <p>
+ * Controls whether the FINISHED-67 observability block ({@link org.apache.juneau.rest.metrics.MetricsRecorder} /
+ * {@link org.apache.juneau.rest.tracing.TracerHook}) fires for operations on this resource.
+ * Per-operation overrides are available via {@link RestOp#observability()} (and the verb annotations).
+ *
+ * <ul class='values'>
+ * <li><js>"true"</js> — strict opt-in: the resource <em>requires</em> a wired observability backend.
+ * If neither a {@code @Bean MetricsRecorder} nor a {@code @Bean TracerHook} is registered when the
+ * {@link org.apache.juneau.rest.RestContext} is built, construction fails with a precise error.
+ * All operations on this resource have observability enabled.
+ * <li><js>"false"</js> — explicit opt-out: the observability block is short-circuited for every
+ * operation on this resource, even when a wired backend is present. Also suppresses
+ * {@link org.apache.juneau.rest.tracing.TraceContextResponseProcessor} header injection for this resource.
+ * <li><js>""</js> (default) — inherits the existing behavior: the observability block runs per-op,
+ * but a missing {@code @Bean} silently falls back to the NoOp singleton — no startup error.
+ * </ul>
+ *
+ * <h5 class='section'>Notes:</h5><ul>
+ * <li class='note'>
+ * Supports <a class="doclink" href="https://juneau.apache.org/docs/topics/RestServerSvlVariables">SVL Variables</a>
+ * (e.g. <js>"$E{ENABLE_OBSERVABILITY,false}"</js>).
+ * <li class='note'>
+ * Per-operation overrides via {@link RestOp#observability()} (or verb annotations) take precedence over
+ * this resource-level setting.
+ * </ul>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='ja'>{@link RestOp#observability()}
+ * <li class='jc'>{@link org.apache.juneau.rest.metrics.MetricsRecorder}
+ * <li class='jc'>{@link org.apache.juneau.rest.tracing.TracerHook}
+ * <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/RestServerObservability">REST Server — Observability</a>
+ * </ul>
+ *
+ * @return The annotation value.
+ * @since 9.5.0
+ */
+ String observability() default "";
+
/**
* Configurable timeout (milliseconds) applied to {@link java.util.concurrent.CompletableFuture}-returning
* handlers by {@link org.apache.juneau.rest.processor.AsyncResponseProcessor}. Default is 30,000 ms.
@@ -769,6 +810,59 @@ public @interface Rest {
*/
String asyncTimeoutMillis() default "";
+ /**
+ * Names the {@link java.util.concurrent.Executor} bean used to route {@link java.util.concurrent.CompletableFuture}
+ * completion callbacks through a dedicated thread pool.
+ *
+ * <p>
+ * When set, the {@link org.apache.juneau.rest.processor.AsyncResponseProcessor} switches from
+ * {@code future.whenComplete(callback)} to {@code future.whenCompleteAsync(callback, executor)}, so the
+ * response-handler work runs on the named pool instead of the future's natural completion thread (often an
+ * I/O-driver or database-callback thread).
+ *
+ * <p>
+ * The value is the {@link org.apache.juneau.commons.inject.Bean#name() @Bean(name=...)} lookup key for an
+ * {@link java.util.concurrent.Executor} registered in the resource's bean store. Empty string (default)
+ * means no override — completion callbacks run on the future's natural thread (the existing behavior).
+ *
+ * <h5 class='section'>Example:</h5>
+ * <p class='bjava'>
+ * <ja>@Rest</ja>(path=<js>"/api"</js>, asyncCompletionExecutor=<js>"myCompletionPool"</js>)
+ * <jk>public class</jk> ApiResource <jk>extends</jk> RestServlet {
+ *
+ * <ja>@Bean</ja>(name=<js>"myCompletionPool"</js>)
+ * <jk>public</jk> Executor completionPool() {
+ * <jk>return</jk> Executors.newFixedThreadPool(8);
+ * }
+ *
+ * <ja>@RestGet</ja>(<js>"/orders/{id}"</js>)
+ * <jk>public</jk> CompletableFuture<Order> getOrder(<ja>@Path</ja> String id) {
+ * <jk>return</jk> orderService.fetchAsync(id); <jc>// callback runs on myCompletionPool</jc>
+ * }
+ * }
+ * </p>
+ *
+ * <p>
+ * Per-operation overrides are available via {@link RestOp#asyncCompletionExecutor()} (and the verb annotations).
+ * A reference to a bean name that does not resolve in the bean store causes a startup failure.
+ *
+ * <h5 class='section'>Notes:</h5><ul>
+ * <li class='note'>
+ * MDC propagation (TODO-117) still works when this executor is set — the
+ * {@link org.apache.juneau.rest.processor.MdcAsyncListener} wraps the callback <em>before</em> it is
+ * routed through the executor, so the MDC snapshot is restored on whichever thread the executor picks.
+ * </ul>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='jc'>{@link org.apache.juneau.rest.processor.AsyncResponseProcessor}
+ * <li class='ja'>{@link RestOp#asyncCompletionExecutor()}
+ * </ul>
+ *
+ * @return The annotation value.
+ * @since 9.5.0
+ */
+ String asyncCompletionExecutor() default "";
+
/**
* Specifies the compression encoders for this resource.
*
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestAnnotation.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestAnnotation.java
index deba03a0c8..3b7c4a1777 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestAnnotation.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestAnnotation.java
@@ -91,9 +91,11 @@ public class RestAnnotation {
private String messages = "";
private String path = "";
private String[] paths = {};
+ private String observability = "";
private String problemDetails = "";
private String virtualThreads = "";
private String asyncTimeoutMillis = "";
+ private String asyncCompletionExecutor = "";
private String renderResponseStackTraces = "";
private String roleGuard = "";
private String rolesDeclared = "";
@@ -520,6 +522,17 @@ public class RestAnnotation {
return this;
}
+ /**
+ * Sets the {@link Rest#observability()} property on this annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder observability(String value) {
+ observability = value;
+ return this;
+ }
+
/**
* Sets the {@link Rest#virtualThreads()} property on this annotation.
*
@@ -542,6 +555,17 @@ public class RestAnnotation {
return this;
}
+ /**
+ * Sets the {@link Rest#asyncCompletionExecutor()} property on this annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder asyncCompletionExecutor(String value) {
+ asyncCompletionExecutor = value;
+ return this;
+ }
+
/**
* Sets the {@link Rest#renderResponseStackTraces()} property on this annotation.
*
@@ -760,9 +784,11 @@ public class RestAnnotation {
private final String messages;
private final String path;
private final String[] paths;
+ private final String observability;
private final String problemDetails;
private final String virtualThreads;
private final String asyncTimeoutMillis;
+ private final String asyncCompletionExecutor;
private final String renderResponseStackTraces;
private final String roleGuard;
private final String rolesDeclared;
@@ -819,9 +845,11 @@ public class RestAnnotation {
partSerializer = b.partSerializer;
path = b.path;
paths = copyOf(b.paths);
+ observability = b.observability;
problemDetails = b.problemDetails;
virtualThreads = b.virtualThreads;
asyncTimeoutMillis = b.asyncTimeoutMillis;
+ asyncCompletionExecutor = b.asyncCompletionExecutor;
produces = copyOf(b.produces);
renderResponseStackTraces = b.renderResponseStackTraces;
responseProcessors = copyOf(b.responseProcessors);
@@ -1025,6 +1053,11 @@ public class RestAnnotation {
return queryParams;
}
+ @Override /* Overridden from Rest */
+ public String observability() {
+ return observability;
+ }
+
@Override /* Overridden from Rest */
public String problemDetails() {
return problemDetails;
@@ -1040,6 +1073,11 @@ public class RestAnnotation {
return asyncTimeoutMillis;
}
+ @Override /* Overridden from Rest */
+ public String asyncCompletionExecutor() {
+ return asyncCompletionExecutor;
+ }
+
@Override /* Overridden from Rest */
public String renderResponseStackTraces() {
return renderResponseStackTraces;
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestDelete.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestDelete.java
index 404652174f..cfbbe85ab9 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestDelete.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestDelete.java
@@ -445,6 +445,70 @@ public @interface RestDelete {
*/
String problemDetails() default "";
+ /**
+ * Per-operation observability opt-in / opt-out control.
+ *
+ * <p>
+ * Overrides the resource-level {@link Rest#observability()} setting for this specific operation.
+ *
+ * <ul class='values'>
+ * <li><js>"true"</js> — strict opt-in; requires a wired observability backend.
+ * <li><js>"false"</js> — explicit opt-out; short-circuits the observability block.
+ * <li><js>""</js> (default) — inherits the resource-level setting.
+ * </ul>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='ja'>{@link RestOp#observability()}
+ * </ul>
+ *
+ * @return The annotation value.
+ * @since 9.5.0
+ */
+ String observability() default "";
+
+ /**
+ * Per-operation override for {@link Rest#asyncCompletionExecutor() @Rest(asyncCompletionExecutor)}.
+ *
+ * <p>
+ * Names the {@link java.util.concurrent.Executor} bean used to route completion callbacks for this
+ * specific operation through a dedicated thread pool. Empty string (default) inherits from
+ * {@link Rest#asyncCompletionExecutor()}.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='ja'>{@link RestOp#asyncCompletionExecutor()}
+ * <li class='ja'>{@link Rest#asyncCompletionExecutor()}
+ * </ul>
+ *
+ * @return The annotation value.
+ * @since 9.5.0
+ */
+ String asyncCompletionExecutor() default "";
+
+ /**
+ * Per-operation metric name override. Empty string (default) uses the recorder's own name derivation.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='ja'>{@link RestOp#metricName()}
+ * </ul>
+ *
+ * @return The annotation value.
+ * @since 9.5.0
+ */
+ String metricName() default "";
+
+ /**
+ * Per-operation metric tags. Format: comma-separated {@code key=value} pairs.
+ * Empty string (default) means no additional tags.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='ja'>{@link RestOp#metricTags()}
+ * </ul>
+ *
+ * @return The annotation value.
+ * @since 9.5.0
+ */
+ String metricTags() default "";
+
/**
* Allowed serializer session option keys for this operation (ordered merge, prefix {@code -key} removes a key).
*
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestDeleteAnnotation.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestDeleteAnnotation.java
index 91b65bd17f..159b304d9b 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestDeleteAnnotation.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestDeleteAnnotation.java
@@ -61,6 +61,10 @@ public class RestDeleteAnnotation {
private String debug = "";
private String defaultAccept = "";
private String defaultCharset = "";
+ private String observability = "";
+ private String asyncCompletionExecutor = "";
+ private String metricName = "";
+ private String metricTags = "";
private String problemDetails = "";
private String rolesDeclared = "";
private String roleGuard = "";
@@ -248,6 +252,50 @@ public class RestDeleteAnnotation {
return this;
}
+ /**
+ * Sets the {@link RestDelete#observability()} property on this annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder observability(String value) {
+ observability = value;
+ return this;
+ }
+
+ /**
+ * Sets the {@link RestDelete#asyncCompletionExecutor()} property on this annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder asyncCompletionExecutor(String value) {
+ asyncCompletionExecutor = value;
+ return this;
+ }
+
+ /**
+ * Sets the {@link RestDelete#metricName()} property on this annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder metricName(String value) {
+ metricName = value;
+ return this;
+ }
+
+ /**
+ * Sets the {@link RestDelete#metricTags()} property on this annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder metricTags(String value) {
+ metricTags = value;
+ return this;
+ }
+
/**
* Sets the {@link RestDelete#allowedSerializerOptions()} property on this annotation.
*
@@ -357,6 +405,10 @@ public class RestDeleteAnnotation {
private final String debug;
private final String defaultAccept;
private final String defaultCharset;
+ private final String observability;
+ private final String asyncCompletionExecutor;
+ private final String metricName;
+ private final String metricTags;
private final String problemDetails;
private final String rolesDeclared;
private final String roleGuard;
@@ -389,6 +441,10 @@ public class RestDeleteAnnotation {
allowedSerializerOptions = copyOf(b.allowedSerializerOptions);
noInherit = copyOf(b.noInherit);
path = copyOf(b.path);
+ observability = b.observability;
+ asyncCompletionExecutor = b.asyncCompletionExecutor;
+ metricName = b.metricName;
+ metricTags = b.metricTags;
problemDetails = b.problemDetails;
roleGuard = b.roleGuard;
rolesDeclared = b.rolesDeclared;
@@ -472,6 +528,26 @@ public class RestDeleteAnnotation {
return path;
}
+ @Override /* Overridden from RestDelete */
+ public String observability() {
+ return observability;
+ }
+
+ @Override /* Overridden from RestDelete */
+ public String asyncCompletionExecutor() {
+ return asyncCompletionExecutor;
+ }
+
+ @Override /* Overridden from RestDelete */
+ public String metricName() {
+ return metricName;
+ }
+
+ @Override /* Overridden from RestDelete */
+ public String metricTags() {
+ return metricTags;
+ }
+
@Override /* Overridden from RestDelete */
public String problemDetails() {
return problemDetails;
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestGet.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestGet.java
index 475f17bb52..a43e62d556 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestGet.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestGet.java
@@ -463,6 +463,70 @@ public @interface RestGet {
*/
String problemDetails() default "";
+ /**
+ * Per-operation observability opt-in / opt-out control.
+ *
+ * <p>
+ * Overrides the resource-level {@link Rest#observability()} setting for this specific operation.
+ *
+ * <ul class='values'>
+ * <li><js>"true"</js> — strict opt-in; requires a wired observability backend.
+ * <li><js>"false"</js> — explicit opt-out; short-circuits the observability block.
+ * <li><js>""</js> (default) — inherits the resource-level setting.
+ * </ul>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='ja'>{@link RestOp#observability()}
+ * </ul>
+ *
+ * @return The annotation value.
+ * @since 9.5.0
+ */
+ String observability() default "";
+
+ /**
+ * Per-operation override for {@link Rest#asyncCompletionExecutor() @Rest(asyncCompletionExecutor)}.
+ *
+ * <p>
+ * Names the {@link java.util.concurrent.Executor} bean used to route completion callbacks for this
+ * specific operation through a dedicated thread pool. Empty string (default) inherits from
+ * {@link Rest#asyncCompletionExecutor()}.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='ja'>{@link RestOp#asyncCompletionExecutor()}
+ * <li class='ja'>{@link Rest#asyncCompletionExecutor()}
+ * </ul>
+ *
+ * @return The annotation value.
+ * @since 9.5.0
+ */
+ String asyncCompletionExecutor() default "";
+
+ /**
+ * Per-operation metric name override. Empty string (default) uses the recorder's own name derivation.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='ja'>{@link RestOp#metricName()}
+ * </ul>
+ *
+ * @return The annotation value.
+ * @since 9.5.0
+ */
+ String metricName() default "";
+
+ /**
+ * Per-operation metric tags. Format: comma-separated {@code key=value} pairs.
+ * Empty string (default) means no additional tags.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='ja'>{@link RestOp#metricTags()}
+ * </ul>
+ *
+ * @return The annotation value.
+ * @since 9.5.0
+ */
+ String metricTags() default "";
+
/**
* Supported accept media types.
*
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestGetAnnotation.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestGetAnnotation.java
index a8f818a011..303234d0ec 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestGetAnnotation.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestGetAnnotation.java
@@ -65,6 +65,10 @@ public class RestGetAnnotation {
private String debug = "";
private String defaultAccept = "";
private String defaultCharset = "";
+ private String observability = "";
+ private String asyncCompletionExecutor = "";
+ private String metricName = "";
+ private String metricTags = "";
private String problemDetails = "";
private String rolesDeclared = "";
private String roleGuard = "";
@@ -265,6 +269,50 @@ public class RestGetAnnotation {
return this;
}
+ /**
+ * Sets the {@link RestGet#observability()} property on this annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder observability(String value) {
+ observability = value;
+ return this;
+ }
+
+ /**
+ * Sets the {@link RestGet#asyncCompletionExecutor()} property on this annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder asyncCompletionExecutor(String value) {
+ asyncCompletionExecutor = value;
+ return this;
+ }
+
+ /**
+ * Sets the {@link RestGet#metricName()} property on this annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder metricName(String value) {
+ metricName = value;
+ return this;
+ }
+
+ /**
+ * Sets the {@link RestGet#metricTags()} property on this annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder metricTags(String value) {
+ metricTags = value;
+ return this;
+ }
+
/**
* Sets the {@link RestGet#produces()} property on this annotation.
*
@@ -400,6 +448,10 @@ public class RestGetAnnotation {
private final String debug;
private final String defaultAccept;
private final String defaultCharset;
+ private final String observability;
+ private final String asyncCompletionExecutor;
+ private final String metricName;
+ private final String metricTags;
private final String problemDetails;
private final String rolesDeclared;
private final String roleGuard;
@@ -434,6 +486,10 @@ public class RestGetAnnotation {
allowedSerializerOptions = copyOf(b.allowedSerializerOptions);
noInherit = copyOf(b.noInherit);
path = copyOf(b.path);
+ observability = b.observability;
+ asyncCompletionExecutor = b.asyncCompletionExecutor;
+ metricName = b.metricName;
+ metricTags = b.metricTags;
problemDetails = b.problemDetails;
produces = copyOf(b.produces);
roleGuard = b.roleGuard;
@@ -524,6 +580,26 @@ public class RestGetAnnotation {
return path;
}
+ @Override /* Overridden from RestGet */
+ public String observability() {
+ return observability;
+ }
+
+ @Override /* Overridden from RestGet */
+ public String asyncCompletionExecutor() {
+ return asyncCompletionExecutor;
+ }
+
+ @Override /* Overridden from RestGet */
+ public String metricName() {
+ return metricName;
+ }
+
+ @Override /* Overridden from RestGet */
+ public String metricTags() {
+ return metricTags;
+ }
+
@Override /* Overridden from RestGet */
public String problemDetails() {
return problemDetails;
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOp.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOp.java
index 3d3a394c00..38f85cc3e8 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOp.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOp.java
@@ -720,6 +720,87 @@ public @interface RestOp {
*/
String virtualThreads() default "";
+ /**
+ * Per-operation observability opt-in / opt-out control.
+ *
+ * <p>
+ * Overrides the resource-level {@link Rest#observability()} setting for this specific operation.
+ * Controls whether the FINISHED-67 observability block ({@link org.apache.juneau.rest.metrics.MetricsRecorder} /
+ * {@link org.apache.juneau.rest.tracing.TracerHook}) fires for this operation.
+ *
+ * <ul class='values'>
+ * <li><js>"true"</js> — strict opt-in: this operation <em>requires</em> a wired observability backend.
+ * If neither a {@code @Bean MetricsRecorder} nor a {@code @Bean TracerHook} is registered when the
+ * {@link org.apache.juneau.rest.RestContext} is built, construction fails with a precise error.
+ * <li><js>"false"</js> — explicit opt-out: the observability block is short-circuited for this operation,
+ * even when a wired backend is present. Also suppresses
+ * {@link org.apache.juneau.rest.tracing.TraceContextResponseProcessor} header injection.
+ * <li><js>""</js> (default) — inherits the resource-level {@link Rest#observability()} setting.
+ * If the resource-level setting is also empty, uses the existing behavior: the observability block
+ * runs per-op, but a missing backend silently falls back to the NoOp singleton.
+ * </ul>
+ *
+ * <h5 class='section'>Notes:</h5><ul>
+ * <li class='note'>
+ * Supports <a class="doclink" href="https://juneau.apache.org/docs/topics/RestServerSvlVariables">SVL Variables</a>
+ * (e.g. <js>"$E{ENABLE_OP_OBSERVABILITY,false}"</js>).
+ * <li class='note'>
+ * This per-operation setting takes full precedence over {@link Rest#observability()}.
+ * </ul>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='ja'>{@link Rest#observability()}
+ * <li class='jc'>{@link org.apache.juneau.rest.metrics.MetricsRecorder}
+ * <li class='jc'>{@link org.apache.juneau.rest.tracing.TracerHook}
+ * </ul>
+ *
+ * @return The annotation value.
+ * @since 9.5.0
+ */
+ String observability() default "";
+
+ /**
+ * Per-operation metric name override.
+ *
+ * <p>
+ * Overrides the metric name passed to {@link org.apache.juneau.rest.metrics.MetricsRecorder#record record()}
+ * for this operation. When empty (default), the recorder uses its own default name derivation (typically the
+ * fully-qualified operation name).
+ *
+ * <h5 class='section'>Notes:</h5><ul>
+ * <li class='note'>
+ * Supports <a class="doclink" href="https://juneau.apache.org/docs/topics/RestServerSvlVariables">SVL Variables</a>.
+ * <li class='note'>
+ * Has no effect when observability is disabled for this operation.
+ * </ul>
+ *
+ * @return The annotation value.
+ * @since 9.5.0
+ */
+ String metricName() default "";
+
+ /**
+ * Per-operation metric tags.
+ *
+ * <p>
+ * Additional metric tags to pass to {@link org.apache.juneau.rest.metrics.MetricsRecorder#record record()}.
+ * Format: comma-separated {@code key=value} pairs, e.g. {@code "team=payments,region=us-east"}.
+ * When empty (default), no additional tags are passed.
+ *
+ * <h5 class='section'>Notes:</h5><ul>
+ * <li class='note'>
+ * Supports <a class="doclink" href="https://juneau.apache.org/docs/topics/RestServerSvlVariables">SVL Variables</a>.
+ * <li class='note'>
+ * The recorder implementation is responsible for parsing the comma-separated {@code key=value} string.
+ * <li class='note'>
+ * Has no effect when observability is disabled for this operation.
+ * </ul>
+ *
+ * @return The annotation value.
+ * @since 9.5.0
+ */
+ String metricTags() default "";
+
/**
* Per-operation override for {@link Rest#asyncTimeoutMillis() @Rest(asyncTimeoutMillis)}.
*
@@ -740,6 +821,23 @@ public @interface RestOp {
*/
String asyncTimeoutMillis() default "";
+ /**
+ * Per-operation override for {@link Rest#asyncCompletionExecutor() @Rest(asyncCompletionExecutor)}.
+ *
+ * <p>
+ * Names the {@link java.util.concurrent.Executor} bean used to route completion callbacks for this
+ * specific operation through a dedicated thread pool. Empty string (default) inherits from
+ * {@link Rest#asyncCompletionExecutor()}.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='ja'>{@link Rest#asyncCompletionExecutor()}
+ * </ul>
+ *
+ * @return The annotation value.
+ * @since 9.5.0
+ */
+ String asyncCompletionExecutor() default "";
+
/**
* Supported accept media types.
*
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOpAnnotation.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOpAnnotation.java
index 1477b02416..a44520633f 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOpAnnotation.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOpAnnotation.java
@@ -71,9 +71,13 @@ public class RestOpAnnotation {
private String defaultContentType = "";
private String maxInput = "";
private String method = "";
+ private String observability = "";
+ private String metricName = "";
+ private String metricTags = "";
private String problemDetails = "";
private String virtualThreads = "";
private String asyncTimeoutMillis = "";
+ private String asyncCompletionExecutor = "";
private String rolesDeclared = "";
private String roleGuard = "";
private String summary = "";
@@ -352,6 +356,39 @@ public class RestOpAnnotation {
return this;
}
+ /**
+ * Sets the {@link RestOp#observability()} property on this annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder observability(String value) {
+ observability = value;
+ return this;
+ }
+
+ /**
+ * Sets the {@link RestOp#metricName()} property on this annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder metricName(String value) {
+ metricName = value;
+ return this;
+ }
+
+ /**
+ * Sets the {@link RestOp#metricTags()} property on this annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder metricTags(String value) {
+ metricTags = value;
+ return this;
+ }
+
/**
* Sets the {@link RestOp#virtualThreads()} property on this annotation.
*
@@ -374,6 +411,17 @@ public class RestOpAnnotation {
return this;
}
+ /**
+ * Sets the {@link RestOp#asyncCompletionExecutor()} property on this annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder asyncCompletionExecutor(String value) {
+ asyncCompletionExecutor = value;
+ return this;
+ }
+
/**
* Sets the {@link RestOp#produces()} property on this annotation.
*
@@ -512,9 +560,13 @@ public class RestOpAnnotation {
private final String defaultContentType;
private final String maxInput;
private final String method;
+ private final String observability;
+ private final String metricName;
+ private final String metricTags;
private final String problemDetails;
private final String virtualThreads;
private final String asyncTimeoutMillis;
+ private final String asyncCompletionExecutor;
private final String rolesDeclared;
private final String roleGuard;
private final String summary;
@@ -553,9 +605,13 @@ public class RestOpAnnotation {
method = b.method;
parsers = copyOf(b.parsers);
path = copyOf(b.path);
+ observability = b.observability;
+ metricName = b.metricName;
+ metricTags = b.metricTags;
problemDetails = b.problemDetails;
virtualThreads = b.virtualThreads;
asyncTimeoutMillis = b.asyncTimeoutMillis;
+ asyncCompletionExecutor = b.asyncCompletionExecutor;
produces = copyOf(b.produces);
allowedSerializerOptions = copyOf(b.allowedSerializerOptions);
allowedParserOptions = copyOf(b.allowedParserOptions);
@@ -663,6 +719,21 @@ public class RestOpAnnotation {
return path;
}
+ @Override /* Overridden from RestOp */
+ public String observability() {
+ return observability;
+ }
+
+ @Override /* Overridden from RestOp */
+ public String metricName() {
+ return metricName;
+ }
+
+ @Override /* Overridden from RestOp */
+ public String metricTags() {
+ return metricTags;
+ }
+
@Override /* Overridden from RestOp */
public String problemDetails() {
return problemDetails;
@@ -678,6 +749,11 @@ public class RestOpAnnotation {
return asyncTimeoutMillis;
}
+ @Override /* Overridden from RestOp */
+ public String asyncCompletionExecutor() {
+ return asyncCompletionExecutor;
+ }
+
@Override /* Overridden from RestOp */
public String[] produces() {
return produces;
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOptions.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOptions.java
index df6d1176cf..0b1939dd8d 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOptions.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOptions.java
@@ -462,6 +462,70 @@ public @interface RestOptions {
*/
String problemDetails() default "";
+ /**
+ * Per-operation observability opt-in / opt-out control.
+ *
+ * <p>
+ * Overrides the resource-level {@link Rest#observability()} setting for this specific operation.
+ *
+ * <ul class='values'>
+ * <li><js>"true"</js> — strict opt-in; requires a wired observability backend.
+ * <li><js>"false"</js> — explicit opt-out; short-circuits the observability block.
+ * <li><js>""</js> (default) — inherits the resource-level setting.
+ * </ul>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='ja'>{@link RestOp#observability()}
+ * </ul>
+ *
+ * @return The annotation value.
+ * @since 9.5.0
+ */
+ String observability() default "";
+
+ /**
+ * Per-operation override for {@link Rest#asyncCompletionExecutor() @Rest(asyncCompletionExecutor)}.
+ *
+ * <p>
+ * Names the {@link java.util.concurrent.Executor} bean used to route completion callbacks for this
+ * specific operation through a dedicated thread pool. Empty string (default) inherits from
+ * {@link Rest#asyncCompletionExecutor()}.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='ja'>{@link RestOp#asyncCompletionExecutor()}
+ * <li class='ja'>{@link Rest#asyncCompletionExecutor()}
+ * </ul>
+ *
+ * @return The annotation value.
+ * @since 9.5.0
+ */
+ String asyncCompletionExecutor() default "";
+
+ /**
+ * Per-operation metric name override. Empty string (default) uses the recorder's own name derivation.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='ja'>{@link RestOp#metricName()}
+ * </ul>
+ *
+ * @return The annotation value.
+ * @since 9.5.0
+ */
+ String metricName() default "";
+
+ /**
+ * Per-operation metric tags. Format: comma-separated {@code key=value} pairs.
+ * Empty string (default) means no additional tags.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='ja'>{@link RestOp#metricTags()}
+ * </ul>
+ *
+ * @return The annotation value.
+ * @since 9.5.0
+ */
+ String metricTags() default "";
+
/**
* Supported accept media types.
*
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOptionsAnnotation.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOptionsAnnotation.java
index 9fe35f092e..2ca75e7e01 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOptionsAnnotation.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestOptionsAnnotation.java
@@ -65,6 +65,10 @@ public class RestOptionsAnnotation {
private String debug = "";
private String defaultAccept = "";
private String defaultCharset = "";
+ private String observability = "";
+ private String asyncCompletionExecutor = "";
+ private String metricName = "";
+ private String metricTags = "";
private String problemDetails = "";
private String rolesDeclared = "";
private String roleGuard = "";
@@ -276,6 +280,50 @@ public class RestOptionsAnnotation {
return this;
}
+ /**
+ * Sets the {@link RestOptions#observability()} property on this annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder observability(String value) {
+ observability = value;
+ return this;
+ }
+
+ /**
+ * Sets the {@link RestOptions#asyncCompletionExecutor()} property on this annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder asyncCompletionExecutor(String value) {
+ asyncCompletionExecutor = value;
+ return this;
+ }
+
+ /**
+ * Sets the {@link RestOptions#metricName()} property on this annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder metricName(String value) {
+ metricName = value;
+ return this;
+ }
+
+ /**
+ * Sets the {@link RestOptions#metricTags()} property on this annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder metricTags(String value) {
+ metricTags = value;
+ return this;
+ }
+
/**
* Sets the {@link RestOptions#allowedSerializerOptions()} property on this annotation.
*
@@ -399,6 +447,10 @@ public class RestOptionsAnnotation {
private final String debug;
private final String defaultAccept;
private final String defaultCharset;
+ private final String observability;
+ private final String asyncCompletionExecutor;
+ private final String metricName;
+ private final String metricTags;
private final String problemDetails;
private final String rolesDeclared;
private final String roleGuard;
@@ -433,6 +485,10 @@ public class RestOptionsAnnotation {
allowedSerializerOptions = copyOf(b.allowedSerializerOptions);
noInherit = copyOf(b.noInherit);
path = copyOf(b.path);
+ observability = b.observability;
+ asyncCompletionExecutor = b.asyncCompletionExecutor;
+ metricName = b.metricName;
+ metricTags = b.metricTags;
problemDetails = b.problemDetails;
produces = copyOf(b.produces);
roleGuard = b.roleGuard;
@@ -523,6 +579,26 @@ public class RestOptionsAnnotation {
return path;
}
+ @Override /* Overridden from RestOptions */
+ public String observability() {
+ return observability;
+ }
+
+ @Override /* Overridden from RestOptions */
+ public String asyncCompletionExecutor() {
+ return asyncCompletionExecutor;
+ }
+
+ @Override /* Overridden from RestOptions */
+ public String metricName() {
+ return metricName;
+ }
+
+ @Override /* Overridden from RestOptions */
+ public String metricTags() {
+ return metricTags;
+ }
+
@Override /* Overridden from RestOptions */
public String problemDetails() {
return problemDetails;
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPatch.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPatch.java
index 388c94168b..934788f62b 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPatch.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPatch.java
@@ -614,6 +614,70 @@ public @interface RestPatch {
*/
String problemDetails() default "";
+ /**
+ * Per-operation observability opt-in / opt-out control.
+ *
+ * <p>
+ * Overrides the resource-level {@link Rest#observability()} setting for this specific operation.
+ *
+ * <ul class='values'>
+ * <li><js>"true"</js> — strict opt-in; requires a wired observability backend.
+ * <li><js>"false"</js> — explicit opt-out; short-circuits the observability block.
+ * <li><js>""</js> (default) — inherits the resource-level setting.
+ * </ul>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='ja'>{@link RestOp#observability()}
+ * </ul>
+ *
+ * @return The annotation value.
+ * @since 9.5.0
+ */
+ String observability() default "";
+
+ /**
+ * Per-operation override for {@link Rest#asyncCompletionExecutor() @Rest(asyncCompletionExecutor)}.
+ *
+ * <p>
+ * Names the {@link java.util.concurrent.Executor} bean used to route completion callbacks for this
+ * specific operation through a dedicated thread pool. Empty string (default) inherits from
+ * {@link Rest#asyncCompletionExecutor()}.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='ja'>{@link RestOp#asyncCompletionExecutor()}
+ * <li class='ja'>{@link Rest#asyncCompletionExecutor()}
+ * </ul>
+ *
+ * @return The annotation value.
+ * @since 9.5.0
+ */
+ String asyncCompletionExecutor() default "";
+
+ /**
+ * Per-operation metric name override. Empty string (default) uses the recorder's own name derivation.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='ja'>{@link RestOp#metricName()}
+ * </ul>
+ *
+ * @return The annotation value.
+ * @since 9.5.0
+ */
+ String metricName() default "";
+
+ /**
+ * Per-operation metric tags. Format: comma-separated {@code key=value} pairs.
+ * Empty string (default) means no additional tags.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='ja'>{@link RestOp#metricTags()}
+ * </ul>
+ *
+ * @return The annotation value.
+ * @since 9.5.0
+ */
+ String metricTags() default "";
+
/**
* Supported accept media types.
*
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPatchAnnotation.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPatchAnnotation.java
index e570af6312..272a98bf8c 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPatchAnnotation.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPatchAnnotation.java
@@ -67,6 +67,10 @@ public class RestPatchAnnotation {
private String defaultCharset = "";
private String defaultContentType = "";
private String maxInput = "";
+ private String observability = "";
+ private String asyncCompletionExecutor = "";
+ private String metricName = "";
+ private String metricTags = "";
private String problemDetails = "";
private String rolesDeclared = "";
private String roleGuard = "";
@@ -325,6 +329,50 @@ public class RestPatchAnnotation {
return this;
}
+ /**
+ * Sets the {@link RestPatch#observability()} property on this annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder observability(String value) {
+ observability = value;
+ return this;
+ }
+
+ /**
+ * Sets the {@link RestPatch#asyncCompletionExecutor()} property on this annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder asyncCompletionExecutor(String value) {
+ asyncCompletionExecutor = value;
+ return this;
+ }
+
+ /**
+ * Sets the {@link RestPatch#metricName()} property on this annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder metricName(String value) {
+ metricName = value;
+ return this;
+ }
+
+ /**
+ * Sets the {@link RestPatch#metricTags()} property on this annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder metricTags(String value) {
+ metricTags = value;
+ return this;
+ }
+
/**
* Sets the {@link RestPatch#produces()} property on this annotation.
*
@@ -462,6 +510,10 @@ public class RestPatchAnnotation {
private final String defaultCharset;
private final String defaultContentType;
private final String maxInput;
+ private final String observability;
+ private final String asyncCompletionExecutor;
+ private final String metricName;
+ private final String metricTags;
private final String problemDetails;
private final String rolesDeclared;
private final String roleGuard;
@@ -503,6 +555,10 @@ public class RestPatchAnnotation {
allowedSerializerOptions = copyOf(b.allowedSerializerOptions);
noInherit = copyOf(b.noInherit);
path = copyOf(b.path);
+ observability = b.observability;
+ asyncCompletionExecutor = b.asyncCompletionExecutor;
+ metricName = b.metricName;
+ metricTags = b.metricTags;
problemDetails = b.problemDetails;
produces = copyOf(b.produces);
roleGuard = b.roleGuard;
@@ -618,6 +674,26 @@ public class RestPatchAnnotation {
return path;
}
+ @Override /* Overridden from RestPatch */
+ public String observability() {
+ return observability;
+ }
+
+ @Override /* Overridden from RestPatch */
+ public String asyncCompletionExecutor() {
+ return asyncCompletionExecutor;
+ }
+
+ @Override /* Overridden from RestPatch */
+ public String metricName() {
+ return metricName;
+ }
+
+ @Override /* Overridden from RestPatch */
+ public String metricTags() {
+ return metricTags;
+ }
+
@Override /* Overridden from RestPatch */
public String problemDetails() {
return problemDetails;
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPost.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPost.java
index 1dcb16cded..dc700aa49f 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPost.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPost.java
@@ -613,6 +613,70 @@ public @interface RestPost {
*/
String problemDetails() default "";
+ /**
+ * Per-operation observability opt-in / opt-out control.
+ *
+ * <p>
+ * Overrides the resource-level {@link Rest#observability()} setting for this specific operation.
+ *
+ * <ul class='values'>
+ * <li><js>"true"</js> — strict opt-in; requires a wired observability backend.
+ * <li><js>"false"</js> — explicit opt-out; short-circuits the observability block.
+ * <li><js>""</js> (default) — inherits the resource-level setting.
+ * </ul>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='ja'>{@link RestOp#observability()}
+ * </ul>
+ *
+ * @return The annotation value.
+ * @since 9.5.0
+ */
+ String observability() default "";
+
+ /**
+ * Per-operation override for {@link Rest#asyncCompletionExecutor() @Rest(asyncCompletionExecutor)}.
+ *
+ * <p>
+ * Names the {@link java.util.concurrent.Executor} bean used to route completion callbacks for this
+ * specific operation through a dedicated thread pool. Empty string (default) inherits from
+ * {@link Rest#asyncCompletionExecutor()}.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='ja'>{@link RestOp#asyncCompletionExecutor()}
+ * <li class='ja'>{@link Rest#asyncCompletionExecutor()}
+ * </ul>
+ *
+ * @return The annotation value.
+ * @since 9.5.0
+ */
+ String asyncCompletionExecutor() default "";
+
+ /**
+ * Per-operation metric name override. Empty string (default) uses the recorder's own name derivation.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='ja'>{@link RestOp#metricName()}
+ * </ul>
+ *
+ * @return The annotation value.
+ * @since 9.5.0
+ */
+ String metricName() default "";
+
+ /**
+ * Per-operation metric tags. Format: comma-separated {@code key=value} pairs.
+ * Empty string (default) means no additional tags.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='ja'>{@link RestOp#metricTags()}
+ * </ul>
+ *
+ * @return The annotation value.
+ * @since 9.5.0
+ */
+ String metricTags() default "";
+
/**
* Supported accept media types.
*
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPostAnnotation.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPostAnnotation.java
index e5b528c3c8..641cdcf410 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPostAnnotation.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPostAnnotation.java
@@ -68,6 +68,10 @@ public class RestPostAnnotation {
private String defaultCharset = "";
private String defaultContentType = "";
private String maxInput = "";
+ private String observability = "";
+ private String asyncCompletionExecutor = "";
+ private String metricName = "";
+ private String metricTags = "";
private String problemDetails = "";
private String rolesDeclared = "";
private String roleGuard = "";
@@ -325,6 +329,50 @@ public class RestPostAnnotation {
return this;
}
+ /**
+ * Sets the {@link RestPost#observability()} property on this annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder observability(String value) {
+ observability = value;
+ return this;
+ }
+
+ /**
+ * Sets the {@link RestPost#asyncCompletionExecutor()} property on this annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder asyncCompletionExecutor(String value) {
+ asyncCompletionExecutor = value;
+ return this;
+ }
+
+ /**
+ * Sets the {@link RestPost#metricName()} property on this annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder metricName(String value) {
+ metricName = value;
+ return this;
+ }
+
+ /**
+ * Sets the {@link RestPost#metricTags()} property on this annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder metricTags(String value) {
+ metricTags = value;
+ return this;
+ }
+
/**
* Sets the {@link RestPost#produces()} property on this annotation.
*
@@ -462,6 +510,10 @@ public class RestPostAnnotation {
private final String defaultCharset;
private final String defaultContentType;
private final String maxInput;
+ private final String observability;
+ private final String asyncCompletionExecutor;
+ private final String metricName;
+ private final String metricTags;
private final String problemDetails;
private final String rolesDeclared;
private final String roleGuard;
@@ -503,6 +555,10 @@ public class RestPostAnnotation {
allowedSerializerOptions = copyOf(b.allowedSerializerOptions);
noInherit = copyOf(b.noInherit);
path = copyOf(b.path);
+ observability = b.observability;
+ asyncCompletionExecutor = b.asyncCompletionExecutor;
+ metricName = b.metricName;
+ metricTags = b.metricTags;
problemDetails = b.problemDetails;
produces = copyOf(b.produces);
roleGuard = b.roleGuard;
@@ -618,6 +674,26 @@ public class RestPostAnnotation {
return path;
}
+ @Override /* Overridden from RestPost */
+ public String observability() {
+ return observability;
+ }
+
+ @Override /* Overridden from RestPost */
+ public String asyncCompletionExecutor() {
+ return asyncCompletionExecutor;
+ }
+
+ @Override /* Overridden from RestPost */
+ public String metricName() {
+ return metricName;
+ }
+
+ @Override /* Overridden from RestPost */
+ public String metricTags() {
+ return metricTags;
+ }
+
@Override /* Overridden from RestPost */
public String problemDetails() {
return problemDetails;
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPut.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPut.java
index 321974539a..4b88177c26 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPut.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPut.java
@@ -613,6 +613,70 @@ public @interface RestPut {
*/
String problemDetails() default "";
+ /**
+ * Per-operation observability opt-in / opt-out control.
+ *
+ * <p>
+ * Overrides the resource-level {@link Rest#observability()} setting for this specific operation.
+ *
+ * <ul class='values'>
+ * <li><js>"true"</js> — strict opt-in; requires a wired observability backend.
+ * <li><js>"false"</js> — explicit opt-out; short-circuits the observability block.
+ * <li><js>""</js> (default) — inherits the resource-level setting.
+ * </ul>
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='ja'>{@link RestOp#observability()}
+ * </ul>
+ *
+ * @return The annotation value.
+ * @since 9.5.0
+ */
+ String observability() default "";
+
+ /**
+ * Per-operation override for {@link Rest#asyncCompletionExecutor() @Rest(asyncCompletionExecutor)}.
+ *
+ * <p>
+ * Names the {@link java.util.concurrent.Executor} bean used to route completion callbacks for this
+ * specific operation through a dedicated thread pool. Empty string (default) inherits from
+ * {@link Rest#asyncCompletionExecutor()}.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='ja'>{@link RestOp#asyncCompletionExecutor()}
+ * <li class='ja'>{@link Rest#asyncCompletionExecutor()}
+ * </ul>
+ *
+ * @return The annotation value.
+ * @since 9.5.0
+ */
+ String asyncCompletionExecutor() default "";
+
+ /**
+ * Per-operation metric name override. Empty string (default) uses the recorder's own name derivation.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='ja'>{@link RestOp#metricName()}
+ * </ul>
+ *
+ * @return The annotation value.
+ * @since 9.5.0
+ */
+ String metricName() default "";
+
+ /**
+ * Per-operation metric tags. Format: comma-separated {@code key=value} pairs.
+ * Empty string (default) means no additional tags.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='ja'>{@link RestOp#metricTags()}
+ * </ul>
+ *
+ * @return The annotation value.
+ * @since 9.5.0
+ */
+ String metricTags() default "";
+
/**
* Supported accept media types.
*
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPutAnnotation.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPutAnnotation.java
index edf10a160a..1c271b079e 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPutAnnotation.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/annotation/RestPutAnnotation.java
@@ -68,6 +68,10 @@ public class RestPutAnnotation {
private String defaultCharset = "";
private String defaultContentType = "";
private String maxInput = "";
+ private String observability = "";
+ private String asyncCompletionExecutor = "";
+ private String metricName = "";
+ private String metricTags = "";
private String problemDetails = "";
private String rolesDeclared = "";
private String roleGuard = "";
@@ -325,6 +329,50 @@ public class RestPutAnnotation {
return this;
}
+ /**
+ * Sets the {@link RestPut#observability()} property on this annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder observability(String value) {
+ observability = value;
+ return this;
+ }
+
+ /**
+ * Sets the {@link RestPut#asyncCompletionExecutor()} property on this annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder asyncCompletionExecutor(String value) {
+ asyncCompletionExecutor = value;
+ return this;
+ }
+
+ /**
+ * Sets the {@link RestPut#metricName()} property on this annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder metricName(String value) {
+ metricName = value;
+ return this;
+ }
+
+ /**
+ * Sets the {@link RestPut#metricTags()} property on this annotation.
+ *
+ * @param value The new value for this property.
+ * @return This object.
+ */
+ public Builder metricTags(String value) {
+ metricTags = value;
+ return this;
+ }
+
/**
* Sets the {@link RestPut#produces()} property on this annotation.
*
@@ -462,6 +510,10 @@ public class RestPutAnnotation {
private final String defaultCharset;
private final String defaultContentType;
private final String maxInput;
+ private final String observability;
+ private final String asyncCompletionExecutor;
+ private final String metricName;
+ private final String metricTags;
private final String problemDetails;
private final String rolesDeclared;
private final String roleGuard;
@@ -503,6 +555,10 @@ public class RestPutAnnotation {
allowedSerializerOptions = copyOf(b.allowedSerializerOptions);
noInherit = copyOf(b.noInherit);
path = copyOf(b.path);
+ observability = b.observability;
+ asyncCompletionExecutor = b.asyncCompletionExecutor;
+ metricName = b.metricName;
+ metricTags = b.metricTags;
problemDetails = b.problemDetails;
produces = copyOf(b.produces);
roleGuard = b.roleGuard;
@@ -618,6 +674,26 @@ public class RestPutAnnotation {
return path;
}
+ @Override /* Overridden from RestPut */
+ public String observability() {
+ return observability;
+ }
+
+ @Override /* Overridden from RestPut */
+ public String asyncCompletionExecutor() {
+ return asyncCompletionExecutor;
+ }
+
+ @Override /* Overridden from RestPut */
+ public String metricName() {
+ return metricName;
+ }
+
+ @Override /* Overridden from RestPut */
+ public String metricTags() {
+ return metricTags;
+ }
+
@Override /* Overridden from RestPut */
public String problemDetails() {
return problemDetails;
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/metrics/MetricsRecorder.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/metrics/MetricsRecorder.java
index 593a950f4d..f2bf205883 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/metrics/MetricsRecorder.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/metrics/MetricsRecorder.java
@@ -99,6 +99,12 @@ public interface MetricsRecorder {
* @param error The exception thrown by the handler, or <jk>null</jk> if the call completed normally.
* Bridges typically derive an {@code exception} tag from the throwable's simple class name, or
* {@code "None"} when null (mirrors Spring Boot's {@code WebMvcMetricsFilter} convention).
+ * @param metricName Per-op metric name override from {@link org.apache.juneau.rest.annotation.RestOp#metricName()}.
+ * Empty string (default) means the implementation should use its own default name derivation.
+ * Never <jk>null</jk>.
+ * @param metricTags Per-op additional metric tags from {@link org.apache.juneau.rest.annotation.RestOp#metricTags()}.
+ * Format: comma-separated {@code key=value} pairs (e.g. {@code "team=payments,region=us-east"}).
+ * Empty string (default) means no additional tags. Never <jk>null</jk>.
*/
- void record(String opName, String httpMethod, String uriTemplate, int statusCode, Duration elapsed, Throwable error);
+ void record(String opName, String httpMethod, String uriTemplate, int statusCode, Duration elapsed, Throwable error, String metricName, String metricTags);
}
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/metrics/NoOpMetricsRecorder.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/metrics/NoOpMetricsRecorder.java
index b1ea271350..6b01e72cfc 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/metrics/NoOpMetricsRecorder.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/metrics/NoOpMetricsRecorder.java
@@ -41,7 +41,7 @@ public final class NoOpMetricsRecorder implements MetricsRecorder {
private NoOpMetricsRecorder() {}
@Override /* MetricsRecorder */
- public void record(String opName, String httpMethod, String uriTemplate, int statusCode, Duration elapsed, Throwable error) {
+ public void record(String opName, String httpMethod, String uriTemplate, int statusCode, Duration elapsed, Throwable error, String metricName, String metricTags) {
// Intentionally empty — the default off-by-default behaviour is to drop every event.
}
}
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/AsyncResponseProcessor.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/AsyncResponseProcessor.java
index 8f4f6f56b6..a1911fb170 100644
--- a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/AsyncResponseProcessor.java
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/AsyncResponseProcessor.java
@@ -75,6 +75,14 @@ import org.apache.juneau.rest.*;
* cooperation guarantees that {@link CompletableFuture} provides. Handlers that need async dispatch
* must return {@link CompletableFuture} or {@link CompletionStage}.
*
+ * <h5 class='section'>Completion executor</h5>
+ * <p>
+ * When {@code @Rest(asyncCompletionExecutor="poolName")} or the per-op equivalent is set, this processor
+ * switches from {@code future.whenComplete(callback)} to {@code future.whenCompleteAsync(callback, executor)},
+ * routing the response-handler work through the named {@link java.util.concurrent.Executor} bean. The MDC
+ * snapshot (see below) is taken on the request thread <em>before</em> the executor routing so the MDC context
+ * is always available regardless of which thread the executor picks (TODO-118).
+ *
* <h5 class='section'>Thread-local caveats</h5>
* <p>
* Anything carried via {@link ThreadLocal} (SLF4J MDC, security contexts) does NOT survive the
@@ -208,9 +216,27 @@ public class AsyncResponseProcessor implements ResponseProcessor {
}
});
- cf.whenComplete((value, error) ->
- finalizeAsync(opSession, cf, asyncCtx, error, done, /* timeout */ false, timeoutMs, value)
+ // MDC bridge (TODO-117): snapshot request-thread MDC at dispatch time, then restore it on the
+ // completion thread so log statements inside the whenComplete callback see the same diagnostic
+ // context (requestId, userId, etc.) that was set by upstream filters.
+ // NOTE: the MDC wrap happens BEFORE the executor routing (TODO-118) so the snapshot is always
+ // available regardless of which thread the executor picks.
+ var mdcSnapshot = opSession.getRestContext().isMdcAsyncPropagation()
+ ? MdcAsyncListener.snapshot()
+ : null;
+
+ var callback = MdcAsyncListener.wrap(
+ (value, error) -> finalizeAsync(opSession, cf, asyncCtx, error, done, /* timeout */ false, timeoutMs, value),
+ mdcSnapshot
);
+
+ // TODO-118: if a per-op or resource-level completion executor is configured, route the callback
+ // through it via whenCompleteAsync; otherwise use the natural completion thread.
+ var executor = opSession.getContext().getAsyncCompletionExecutor();
+ if (executor != null)
+ cf.whenCompleteAsync(callback, executor);
+ else
+ cf.whenComplete(callback);
}
private void finalizeAsync(RestOpSession opSession, CompletableFuture<?> cf, AsyncContext asyncCtx,
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/MdcAsyncListener.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/MdcAsyncListener.java
new file mode 100644
index 0000000000..83956baf51
--- /dev/null
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/processor/MdcAsyncListener.java
@@ -0,0 +1,205 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.processor;
+
+import java.lang.reflect.*;
+import java.util.*;
+import java.util.function.*;
+import java.util.logging.*;
+
+/**
+ * Bridges SLF4J MDC context from the request (dispatch) thread to the {@link java.util.concurrent.CompletableFuture}
+ * completion thread.
+ *
+ * <h5 class='topic'>Problem</h5>
+ * <p>
+ * When a {@code @RestOp} method returns a {@link java.util.concurrent.CompletableFuture}, the {@link AsyncResponseProcessor}
+ * registers a {@link java.util.concurrent.CompletionStage#whenComplete(BiConsumer) whenComplete} callback that runs on
+ * whatever thread completes the future — typically a different thread from the one that processed the request.
+ * SLF4J's {@code MDC} is backed by a {@link ThreadLocal}, so any diagnostic context set by an upstream filter
+ * (e.g. {@code requestId}, {@code userId}) is invisible to log statements emitted inside that callback.
+ *
+ * <h5 class='topic'>Solution</h5>
+ * <p>
+ * {@code MdcAsyncListener} provides a single factory method, {@link #wrap(BiConsumer, Map)}, that wraps a
+ * {@code whenComplete} callback with the standard MDC snapshot-restore-clear pattern:
+ * <ol>
+ * <li>Snapshot the request thread's MDC map via {@code MDC.getCopyOfContextMap()} at dispatch time.
+ * <li>On entry to the completion callback: save the completion thread's current MDC (if any) and install
+ * the request-thread snapshot via {@code MDC.setContextMap(snapshot)}.
+ * <li>Callback body runs with the request-thread MDC visible.
+ * <li>In the {@code finally} block: restore the completion thread's original MDC state (if it had one) or
+ * call {@code MDC.clear()} — this removes exactly the keys the listener installed without disturbing
+ * any MDC state the completion thread itself had before the callback.
+ * </ol>
+ *
+ * <h5 class='topic'>Lazy skip</h5>
+ * <p>
+ * {@link #snapshot()} returns {@code null} when the request thread's MDC is empty or when SLF4J is absent.
+ * {@link #wrap(BiConsumer, Map)} returns the original callback unchanged when the snapshot is {@code null}, so no
+ * work is done on the completion thread for the common case of no MDC state.
+ *
+ * <h5 class='topic'>SLF4J detection</h5>
+ * <p>
+ * {@code MdcAsyncListener} uses {@link Class#forName(String)} to probe for {@code org.slf4j.MDC} at class-load
+ * time. SLF4J is NOT a compile-time dependency of {@code juneau-rest-server}. If SLF4J is absent from the
+ * runtime classpath, the class gracefully degrades: {@link #isAvailable()} returns {@code false},
+ * {@link #snapshot()} returns {@code null}, and {@link #wrap(BiConsumer, Map)} returns the original callback.
+ *
+ * <h5 class='topic'>Limitation</h5>
+ * <p>
+ * This class bridges SLF4J MDC only. Users of Log4j2's {@code ThreadContext} directly (without the SLF4J facade)
+ * will not benefit from this listener. That is documented as a v1 limitation; the SLF4J MDC is the lingua franca
+ * for structured logging context propagation in the JVM ecosystem.
+ *
+ * @see AsyncResponseProcessor
+ * @see org.apache.juneau.rest.RestContext#isMdcAsyncPropagation()
+ * @since 9.5.0
+ */
+public final class MdcAsyncListener {
+
+ private static final Logger LOG = Logger.getLogger(MdcAsyncListener.class.getName());
+
+ /** {@code true} if {@code org.slf4j.MDC} was found on the classpath at class-load time. */
+ private static final boolean AVAILABLE;
+
+ private static final Method GET_COPY_OF_CONTEXT_MAP;
+ private static final Method SET_CONTEXT_MAP;
+ private static final Method CLEAR;
+
+ static {
+ boolean available = false;
+ Method copyOf = null, set = null, clr = null;
+ try {
+ Class<?> mdc = Class.forName("org.slf4j.MDC");
+ copyOf = mdc.getMethod("getCopyOfContextMap");
+ set = mdc.getMethod("setContextMap", Map.class);
+ clr = mdc.getMethod("clear");
+ available = true;
+ } catch (Exception e) {
+ LOG.fine(() -> "SLF4J MDC not found on classpath — MdcAsyncListener is a no-op: " + e.getMessage());
+ }
+ AVAILABLE = available;
+ GET_COPY_OF_CONTEXT_MAP = copyOf;
+ SET_CONTEXT_MAP = set;
+ CLEAR = clr;
+ }
+
+ private MdcAsyncListener() {}
+
+ /**
+ * Returns whether SLF4J MDC is available on the runtime classpath.
+ *
+ * <p>
+ * When {@code false}, all methods on this class are no-ops.
+ *
+ * @return {@code true} if {@code org.slf4j.MDC} was found at class-load time.
+ */
+ public static boolean isAvailable() {
+ return AVAILABLE;
+ }
+
+ /**
+ * Takes a snapshot of the current thread's SLF4J MDC map.
+ *
+ * <p>
+ * Call this on the request/dispatch thread before submitting async work. The returned map is passed to
+ * {@link #wrap(BiConsumer, Map)} to decorate the completion callback.
+ *
+ * <p>
+ * Returns {@code null} when:
+ * <ul>
+ * <li>SLF4J is absent from the classpath, or
+ * <li>The current thread's MDC is {@code null} or empty (lazy-skip contract — no work is done on the
+ * completion thread when the request had no MDC context).
+ * </ul>
+ *
+ * @return A copy of the current MDC map, or {@code null}.
+ */
+ @SuppressWarnings("unchecked")
+ public static Map<String, String> snapshot() {
+ if (!AVAILABLE)
+ return null;
+ try {
+ Map<String, String> map = (Map<String, String>) GET_COPY_OF_CONTEXT_MAP.invoke(null);
+ return (map == null || map.isEmpty()) ? null : map;
+ } catch (Exception e) {
+ LOG.fine(() -> "MDC.getCopyOfContextMap() failed: " + e.getMessage());
+ return null;
+ }
+ }
+
+ /**
+ * Wraps a {@link BiConsumer} (typically a {@link java.util.concurrent.CompletableFuture#whenComplete
+ * whenComplete} callback) so the completion thread sees the request thread's MDC context.
+ *
+ * <p>
+ * The wrapping logic:
+ * <ol>
+ * <li>Save the completion thread's current MDC map (usually {@code null} for pool / virtual threads).
+ * <li>Install {@code requestMdc} on the completion thread via {@code MDC.setContextMap(requestMdc)}.
+ * <li>Invoke {@code action} inside a {@code try} block.
+ * <li>{@code finally}: if the completion thread had its own MDC, restore it; otherwise call
+ * {@code MDC.clear()} — either way only the keys the listener installed are removed.
+ * </ol>
+ *
+ * <p>
+ * If {@code requestMdc} is {@code null} (e.g. request thread had no MDC, or SLF4J absent), the original
+ * {@code action} is returned unchanged — zero overhead on the completion thread.
+ *
+ * @param <T> The future result type.
+ * @param action The callback to wrap.
+ * @param requestMdc The MDC snapshot taken on the dispatch thread (from {@link #snapshot()}), or {@code null}.
+ * @return A wrapped callback, or {@code action} itself when wrapping is not needed.
+ */
+ public static <T> BiConsumer<T, Throwable> wrap(BiConsumer<T, Throwable> action, Map<String, String> requestMdc) {
+ if (!AVAILABLE || requestMdc == null)
+ return action;
+
+ return (value, error) -> {
+ // Save the completion thread's pre-existing MDC (so we can restore it, not wipe it).
+ var before = snapshot();
+ try {
+ setContextMap(requestMdc);
+ action.accept(value, error);
+ } finally {
+ if (before != null)
+ setContextMap(before);
+ else
+ mdcClear();
+ }
+ };
+ }
+
+ @SuppressWarnings("java:S3011") // Reflective access to MDC methods — intentional; SLF4J is not a compile dep.
+ static void setContextMap(Map<String, String> map) {
+ try {
+ SET_CONTEXT_MAP.invoke(null, map);
+ } catch (Exception e) {
+ LOG.fine(() -> "MDC.setContextMap() failed: " + e.getMessage());
+ }
+ }
+
+ @SuppressWarnings("java:S3011") // Reflective access to MDC methods — intentional.
+ static void mdcClear() {
+ try {
+ CLEAR.invoke(null);
+ } catch (Exception e) {
+ LOG.fine(() -> "MDC.clear() failed: " + e.getMessage());
+ }
+ }
+}
diff --git a/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/tracing/TraceContextResponseProcessor.java b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/tracing/TraceContextResponseProcessor.java
new file mode 100644
index 0000000000..cf04f3f30a
--- /dev/null
+++ b/juneau-rest/juneau-rest-server/src/main/java/org/apache/juneau/rest/tracing/TraceContextResponseProcessor.java
@@ -0,0 +1,122 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.tracing;
+
+import java.io.*;
+import java.util.logging.*;
+
+import org.apache.juneau.http.response.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.processor.*;
+
+/**
+ * Writes the W3C Trace Context response headers ({@code traceparent} and, when present,
+ * {@code tracestate}) so a client calling this server can read the resulting trace identifiers back
+ * off the response.
+ *
+ * <h5 class='topic'>How it works</h5>
+ *
+ * <p>
+ * A {@link TracerHook} bridge (e.g. the OpenTelemetry bridge in {@code juneau-rest-server-otel})
+ * renders the W3C {@code traceparent} / {@code tracestate} values from the <i>server-started</i> span
+ * context at {@link TracerHook#startSpan(RestRequest) span-start} time — the only point where
+ * that context is reliably active — and stashes them as the request attributes
+ * {@link #ATTR_TRACEPARENT} / {@link #ATTR_TRACESTATE}. This processor runs later, during response
+ * rendering (after the span scope has already closed), reads those stashed strings, and writes them
+ * as response headers.
+ *
+ * <p>
+ * This capture-at-span-start / write-at-response-time split is deliberate: the framework opens and
+ * closes the tracing scope around the {@code @RestOp} handler in
+ * {@link RestOpInvoker#invokeOp(RestOpSession)}, which completes <i>before</i> response processors
+ * run, so {@code Span.current()} is no longer valid here. Keeping the rendered values on the request
+ * also keeps this processor free of any OpenTelemetry (or other tracer) dependency — it works
+ * with any {@code TracerHook} bridge that populates the two attributes.
+ *
+ * <h5 class='topic'>Off-by-default behavior</h5>
+ *
+ * <p>
+ * The first action in {@link #process(RestOpSession)} is a single request-attribute read. When no
+ * non-no-op {@code TracerHook} ran for the request, no {@link #ATTR_TRACEPARENT} attribute is present
+ * and the processor returns {@link #NEXT} immediately with no allocations — matching the
+ * off-by-default, zero-cost contract of the observability hooks. Registration of this processor is
+ * itself gated on {@code RestContext.responseTraceparent} (default {@code true}); setting it to
+ * {@code false} keeps the processor out of the chain entirely.
+ *
+ * <h5 class='topic'>Already-committed responses</h5>
+ *
+ * <p>
+ * If the response has already been committed (for example a streaming / SSE handler that flushed its
+ * headers before returning), the headers can no longer be set. In that case the processor logs a
+ * {@link Level#FINE FINE} message and skips the write — it never throws.
+ *
+ * <h5 class='section'>See Also:</h5><ul>
+ * <li class='jc'>{@link TracerHook}
+ * <li class='link'><a class="doclink" href="https://www.w3.org/TR/trace-context/">W3C Trace Context</a>
+ * <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/RestServerObservability">REST Server — Observability (Micrometer + OpenTelemetry)</a>
+ * </ul>
+ *
+ * @since 9.5.0
+ */
+public class TraceContextResponseProcessor implements ResponseProcessor {
+
+ /**
+ * Request-attribute key under which a {@link TracerHook} bridge stashes the rendered W3C
+ * {@code traceparent} value (e.g. {@code 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01})
+ * for this processor to emit as a response header.
+ */
+ public static final String ATTR_TRACEPARENT = "juneau.traceparent";
+
+ /**
+ * Request-attribute key under which a {@link TracerHook} bridge stashes the rendered W3C
+ * {@code tracestate} value (a comma-separated {@code key=value} list). Only set when the active
+ * trace state is non-empty.
+ */
+ public static final String ATTR_TRACESTATE = "juneau.tracestate";
+
+ private static final String HEADER_TRACEPARENT = "traceparent";
+ private static final String HEADER_TRACESTATE = "tracestate";
+
+ private static final Logger LOG = Logger.getLogger(TraceContextResponseProcessor.class.getName());
+
+ @Override /* Overridden from ResponseProcessor */
+ public int process(RestOpSession opSession) throws IOException, BasicHttpException {
+ var req = opSession.getRequest();
+
+ // NoOp short-circuit: no active tracer stashed a trace context for this request.
+ var traceparent = req.getAttribute(ATTR_TRACEPARENT).as(String.class).orElse(null);
+ if (traceparent == null || traceparent.isEmpty())
+ return NEXT;
+
+ var res = opSession.getResponse();
+
+ // Headers already flushed (e.g. streaming / SSE response) — cannot set; skip without throwing.
+ if (res.getHttpServletResponse().isCommitted()) {
+ LOG.log(Level.FINE, () -> "Response already committed; skipping traceparent/tracestate header injection.");
+ return NEXT;
+ }
+
+ res.setHeader(HEADER_TRACEPARENT, traceparent);
+
+ var tracestate = req.getAttribute(ATTR_TRACESTATE).as(String.class).orElse(null);
+ if (tracestate != null && ! tracestate.isEmpty())
+ res.setHeader(HEADER_TRACESTATE, tracestate);
+
+ // Headers only — let the downstream processors render the body.
+ return NEXT;
+ }
+}
diff --git a/juneau-rest/pom.xml b/juneau-rest/pom.xml
index a6ae8eaf5b..ca60da7b71 100644
--- a/juneau-rest/pom.xml
+++ b/juneau-rest/pom.xml
@@ -39,6 +39,7 @@
<module>juneau-rest-server-jwt</module>
<module>juneau-rest-server-saml</module>
<module>juneau-rest-server-oauth</module>
+ <module>juneau-rest-server-oidc-rp</module>
<module>juneau-rest-server-micrometer</module>
<module>juneau-rest-server-otel</module>
<module>juneau-rest-server-view-jsp</module>
diff --git a/juneau-utest/pom.xml b/juneau-utest/pom.xml
index bd4ca16572..1a30065ec0 100644
--- a/juneau-utest/pom.xml
+++ b/juneau-utest/pom.xml
@@ -297,6 +297,17 @@
<version>11.37.2</version>
<scope>test</scope>
</dependency>
+ <!--
+ juneau-rest-server-oidc-rp (OpenID Connect Relying Party login). Nimbus is `provided` on
+ the module's POM, so the upstream tree never sees it; juneau-utest pulls it explicitly in
+ `test` scope to drive the end-to-end stub-IdP tests.
+ -->
+ <dependency>
+ <groupId>org.apache.juneau</groupId>
+ <artifactId>juneau-rest-server-oidc-rp</artifactId>
+ <version>${project.version}</version>
+ <scope>test</scope>
+ </dependency>
<dependency>
<groupId>org.apache.juneau</groupId>
<artifactId>juneau-rest-server-springboot</artifactId>
diff --git a/juneau-utest/src/test/java/org/apache/juneau/bean/BeanPropertyMeta_AbstractSet_Test.java b/juneau-utest/src/test/java/org/apache/juneau/bean/BeanPropertyMeta_AbstractSet_Test.java
new file mode 100644
index 0000000000..49f0c1739c
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/bean/BeanPropertyMeta_AbstractSet_Test.java
@@ -0,0 +1,165 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.bean;
+
+import static org.apache.juneau.TestUtils.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.bean.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests for materialization of abstract {@link Collection} bean properties that have no setter and no
+ * {@code @BeanProp(type=...)} override.
+ *
+ * <p>
+ * Such fields are populated from a JSON array source by
+ * {@code BeanPropertyMeta.setPropertyValue(...)}, which instantiates a concrete collection via
+ * {@code createDefaultCollectionForAbstractType(...)}. These tests pin the shape-based default table
+ * ({@code Set}→{@code LinkedHashSet}, {@code SortedSet}→{@code TreeSet},
+ * {@code List}/raw {@code Collection}→{@code ArrayList}, {@code Deque}/{@code Queue}→{@code ArrayDeque}),
+ * the still-honored {@code @BeanProp(type=...)} override, and the best-effort-materialize-then-fallback
+ * behavior for custom abstract-set subclasses.
+ */
+class BeanPropertyMeta_AbstractSet_Test extends TestBase {
+
+ public enum Tag { URGENT, PRIORITY, LOW }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // a01: abstract Set<EnumType> field, no setter, no @BeanProp -> LinkedHashSet (order-preserving), elements coerced.
+ //------------------------------------------------------------------------------------------------------------------
+
+ public static class A01_Bean { public Set<Tag> tags; }
+
+ @Test void a01_setOfEnum_materializesLinkedHashSet() {
+ var a = json("{tags:['URGENT','PRIORITY']}", A01_Bean.class);
+ assertInstanceOf(LinkedHashSet.class, a.tags);
+ assertEquals(List.of(Tag.URGENT, Tag.PRIORITY), new ArrayList<>(a.tags)); // Insertion order preserved.
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // a02: abstract SortedSet<EnumType> field -> TreeSet (natural ordering of the enum).
+ //------------------------------------------------------------------------------------------------------------------
+
+ public static class A02_Bean { public SortedSet<Tag> tags; }
+
+ @Test void a02_sortedSetOfEnum_materializesTreeSet() {
+ var a = json("{tags:['PRIORITY','URGENT','LOW']}", A02_Bean.class);
+ assertInstanceOf(TreeSet.class, a.tags);
+ // TreeSet orders by enum ordinal: URGENT(0), PRIORITY(1), LOW(2).
+ assertEquals(List.of(Tag.URGENT, Tag.PRIORITY, Tag.LOW), new ArrayList<>(a.tags));
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // a03: abstract List<EnumType> field, no setter -> ArrayList.
+ //------------------------------------------------------------------------------------------------------------------
+
+ public static class A03_Bean { public List<Tag> tags; }
+
+ @Test void a03_listOfEnum_materializesArrayList() {
+ var a = json("{tags:['LOW','URGENT']}", A03_Bean.class);
+ assertInstanceOf(ArrayList.class, a.tags);
+ assertEquals(List.of(Tag.LOW, Tag.URGENT), a.tags);
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // a04: abstract Deque<String> field -> ArrayDeque.
+ //------------------------------------------------------------------------------------------------------------------
+
+ public static class A04_Bean { public Deque<String> items; }
+
+ @Test void a04_dequeOfString_materializesArrayDeque() {
+ var a = json("{items:['x','y']}", A04_Bean.class);
+ assertInstanceOf(ArrayDeque.class, a.items);
+ assertEquals(List.of("x", "y"), new ArrayList<>(a.items));
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // a05: @BeanProp(type=TreeSet.class) override still honored (wins over the LinkedHashSet shape default for Set).
+ //------------------------------------------------------------------------------------------------------------------
+
+ public static class A05_Bean {
+ @BeanProp(type=TreeSet.class, params={Tag.class})
+ public Set<Tag> tags;
+ }
+
+ @Test void a05_beanPropTypeOverride_honored() {
+ var a = json("{tags:['PRIORITY','URGENT']}", A05_Bean.class);
+ assertInstanceOf(TreeSet.class, a.tags); // Override wins; not the LinkedHashSet default.
+ // params={Tag.class} carries the element type, so elements are coerced and TreeSet orders by enum ordinal.
+ assertEquals(List.of(Tag.URGENT, Tag.PRIORITY), new ArrayList<>(a.tags));
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // a06: OQA-4 - a custom *instantiable* AbstractSet subclass (public no-arg ctor) is materialized as ITSELF.
+ //------------------------------------------------------------------------------------------------------------------
+
+ public static class A06_InstantiableSet<E> extends AbstractSet<E> {
+ private final Set<E> delegate = new LinkedHashSet<>();
+ @Override public boolean add(E e) { return delegate.add(e); }
+ @Override public Iterator<E> iterator() { return delegate.iterator(); }
+ @Override public int size() { return delegate.size(); }
+ }
+
+ public static class A06_Bean { public A06_InstantiableSet<Tag> tags; }
+
+ @Test void a06_instantiableCustomAbstractSet_materializedAsItself() {
+ var a = json("{tags:['URGENT','LOW']}", A06_Bean.class);
+ assertInstanceOf(A06_InstantiableSet.class, a.tags);
+ assertEquals(List.of(Tag.URGENT, Tag.LOW), new ArrayList<>(a.tags));
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // a07: OQA-4 - a genuinely *non-instantiable* abstract set field (java.util.AbstractSet) falls back to
+ // LinkedHashSet (the Set shape default; LinkedHashSet is-a AbstractSet, so the field assignment succeeds).
+ //------------------------------------------------------------------------------------------------------------------
+
+ public static class A07_Bean { public AbstractSet<Tag> tags; }
+
+ @Test void a07_nonInstantiableAbstractSet_fallsBackToLinkedHashSet() {
+ var a = json("{tags:['URGENT','PRIORITY']}", A07_Bean.class);
+ assertInstanceOf(LinkedHashSet.class, a.tags);
+ assertEquals(List.of(Tag.URGENT, Tag.PRIORITY), new ArrayList<>(a.tags));
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // a08: abstract Queue<String> field -> ArrayDeque (Queue shape default).
+ //------------------------------------------------------------------------------------------------------------------
+
+ public static class A08_Bean { public Queue<String> items; }
+
+ @Test void a08_queueOfString_materializesArrayDeque() {
+ var a = json("{items:['a','b']}", A08_Bean.class);
+ assertInstanceOf(ArrayDeque.class, a.items);
+ assertEquals(List.of("a", "b"), new ArrayList<>(a.items));
+ }
+
+ //------------------------------------------------------------------------------------------------------------------
+ // a09: raw abstract Collection field -> ArrayList (raw Collection default).
+ //------------------------------------------------------------------------------------------------------------------
+
+ public static class A09_Bean { public Collection<String> items; }
+
+ @Test void a09_rawCollection_materializesArrayList() {
+ var a = json("{items:['p','q']}", A09_Bean.class);
+ assertInstanceOf(ArrayList.class, a.items);
+ assertEquals(List.of("p", "q"), new ArrayList<>(a.items));
+ }
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/commons/inject/Value_Test.java b/juneau-utest/src/test/java/org/apache/juneau/commons/inject/Value_Test.java
index 58f82731ce..86de382689 100644
--- a/juneau-utest/src/test/java/org/apache/juneau/commons/inject/Value_Test.java
+++ b/juneau-utest/src/test/java/org/apache/juneau/commons/inject/Value_Test.java
@@ -54,7 +54,7 @@ class Value_Test extends TestBase {
var s = Settings.get();
for (var k : List.of(P_STRING, P_INT, P_BOOL, P_URI, P_INSTANT, "Value_Test.optional",
"Value_Test.timeout.ms", "Value_Test.field", "Value_Test.setter", "Value_Test.springStyle",
- "Value_Test.coerceFail"))
+ "Value_Test.coerceFail", "Value_Test.optionalSetter", "Value_Test.optionalCtor"))
s.unsetGlobal(k);
}
@@ -135,6 +135,30 @@ class Value_Test extends TestBase {
}
}
+ /** Setter that accepts an {@code Optional<String>} @Value parameter — worked example of the TODO-128 fix. */
+ public static class OptionalSetterBean {
+ Optional<String> maybe;
+
+ @Inject
+ public void setMaybe(@Value("${Value_Test.optionalSetter}") Optional<String> maybe) {
+ this.maybe = maybe;
+ }
+ }
+
+ /** Constructor that mixes a plain and an Optional @Value parameter — worked example of the TODO-128 fix. */
+ public static class OptionalConstructorBean {
+ final String plain;
+ final Optional<String> maybe;
+
+ @Inject
+ public OptionalConstructorBean(
+ @Value("${Value_Test.string:hello}") String plain,
+ @Value("${Value_Test.optionalCtor}") Optional<String> maybe) {
+ this.plain = plain;
+ this.maybe = maybe;
+ }
+ }
+
public static class ConflictFieldBean {
// Mutual-exclusion guard fires when both are present on the same site.
@Value("${Value_Test.field}")
@@ -275,6 +299,42 @@ class Value_Test extends TestBase {
assertEquals("from-settings", bean.field);
}
+ // TODO-128 worked example: Optional<T> setter parameter previously threw IAE at reflective invoke.
+ @Test
+ void c03_setter_optionalParam_missing_isEmpty() {
+ var bean = BeanInstantiator.of(OptionalSetterBean.class, beanStore).run();
+ assertNotNull(bean.maybe);
+ assertTrue(bean.maybe.isEmpty());
+ }
+
+ @Test
+ void c04_setter_optionalParam_present() {
+ Settings.get().setGlobal("Value_Test.optionalSetter", "setter-opt");
+ var bean = BeanInstantiator.of(OptionalSetterBean.class, beanStore).run();
+ assertNotNull(bean.maybe);
+ assertTrue(bean.maybe.isPresent());
+ assertEquals("setter-opt", bean.maybe.get());
+ }
+
+ // TODO-128 worked example: Optional<T> constructor parameter.
+ @Test
+ void b03_constructorParam_optionalMissing_isEmpty() {
+ var bean = BeanInstantiator.of(OptionalConstructorBean.class, beanStore).run();
+ assertEquals("hello", bean.plain);
+ assertNotNull(bean.maybe);
+ assertTrue(bean.maybe.isEmpty());
+ }
+
+ @Test
+ void b04_constructorParam_optionalPresent() {
+ Settings.get().setGlobal("Value_Test.optionalCtor", "ctor-opt");
+ var bean = BeanInstantiator.of(OptionalConstructorBean.class, beanStore).run();
+ assertEquals("hello", bean.plain);
+ assertNotNull(bean.maybe);
+ assertTrue(bean.maybe.isPresent());
+ assertEquals("ctor-opt", bean.maybe.get());
+ }
+
//====================================================================================================
// Mutual-exclusion with @Inject.
//====================================================================================================
diff --git a/juneau-utest/src/test/java/org/apache/juneau/commons/logging/Logger_Test.java b/juneau-utest/src/test/java/org/apache/juneau/commons/logging/Logger_Test.java
index b3e961aada..254a04f9b1 100644
--- a/juneau-utest/src/test/java/org/apache/juneau/commons/logging/Logger_Test.java
+++ b/juneau-utest/src/test/java/org/apache/juneau/commons/logging/Logger_Test.java
@@ -623,4 +623,24 @@ class Logger_Test extends TestBase {
assertSize(1, capture2.getRecords()); // Only new message
}
}
+
+ //====================================================================================================
+ // Registry stability under high logger-name volume
+ // Regression for the Java-25 Q_logging failure: a Cache-backed registry cleared itself once
+ // 1000+ distinct names existed, causing getLogger(X) to return a new instance for a name
+ // whose class had pinned the old instance in a static final field.
+ //====================================================================================================
+
+ @Test void h01_registryStable_afterManyDistinctNames() {
+ // Pin the instance before flooding the registry.
+ var pinned = Logger.getLogger("h01.pinned");
+
+ // Create enough distinct logger names to exceed the old Cache maxSize threshold (1000).
+ for (int i = 0; i < 1500; i++)
+ Logger.getLogger("h01.flood." + i);
+
+ // The registry must still return the exact same object for the original name.
+ assertSame(pinned, Logger.getLogger("h01.pinned"),
+ "Logger registry must be stable: getLogger(name) must always return the same instance");
+ }
}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/commons/reflect/ParameterInfo_OptionalValue_Test.java b/juneau-utest/src/test/java/org/apache/juneau/commons/reflect/ParameterInfo_OptionalValue_Test.java
new file mode 100644
index 0000000000..160c16000b
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/commons/reflect/ParameterInfo_OptionalValue_Test.java
@@ -0,0 +1,184 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.commons.reflect;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.commons.settings.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Acceptance tests for TODO-128: {@code @Value Optional<T>} wrapping parity between
+ * {@link ParameterInfo#resolveValue} and {@link FieldInfo#inject}.
+ *
+ * <p>
+ * Prior to this fix, declaring a parameter as {@code @Value("...") Optional<String>} caused
+ * {@link IllegalArgumentException} at reflective invoke because {@link ParameterInfo#resolveValue}
+ * returned the unwrapped scalar instead of an {@link Optional}.
+ */
+class ParameterInfo_OptionalValue_Test extends TestBase {
+
+ private static final String KEY = "ParameterInfo_OptionalValue_Test.key";
+
+ private BasicBeanStore beanStore;
+
+ @BeforeEach
+ void setUp() {
+ beanStore = new BasicBeanStore(null);
+ }
+
+ @AfterEach
+ void cleanup() {
+ Settings.get().unsetGlobal(KEY);
+ }
+
+ //====================================================================================================
+ // Fixtures
+ //====================================================================================================
+
+ public static class OptionalMethodBean {
+ Optional<String> result;
+
+ @Inject
+ public void inject(@Value("${" + KEY + "}") Optional<String> value) {
+ this.result = value;
+ }
+ }
+
+ public static class OptionalConstructorBean {
+ final Optional<String> value;
+
+ @Inject
+ public OptionalConstructorBean(@Value("${" + KEY + "}") Optional<String> value) {
+ this.value = value;
+ }
+ }
+
+ public static class PlainStringMethodBean {
+ String result;
+
+ @Inject
+ public void inject(@Value("${" + KEY + ":plain-default}") String value) {
+ this.result = value;
+ }
+ }
+
+ /** Mixed: one Optional, one plain String — verifies per-parameter wrapping. */
+ public static class MixedMethodBean {
+ Optional<String> optional;
+ String plain;
+
+ @Inject
+ public void inject(
+ @Value("${" + KEY + "}") Optional<String> optional,
+ @Value("${" + KEY + ":plain-default}") String plain) {
+ this.optional = optional;
+ this.plain = plain;
+ }
+ }
+
+ //====================================================================================================
+ // Scenario 1: @Value Optional<String> method parameter — resolved value present → Optional.of(...)
+ //====================================================================================================
+
+ @Test
+ void a01_optionalMethodParam_present() {
+ Settings.get().setGlobal(KEY, "hello");
+ var bean = BeanInstantiator.of(OptionalMethodBean.class, beanStore).run();
+ assertNotNull(bean.result, "result must not be null");
+ assertTrue(bean.result.isPresent(), "result must be present");
+ assertEquals("hello", bean.result.get());
+ }
+
+ //====================================================================================================
+ // Scenario 2: @Value Optional<String> method parameter — no key, no default → Optional.empty()
+ //====================================================================================================
+
+ @Test
+ void a02_optionalMethodParam_absent_isEmpty() {
+ var bean = BeanInstantiator.of(OptionalMethodBean.class, beanStore).run();
+ assertNotNull(bean.result, "result must not be null");
+ assertTrue(bean.result.isEmpty(), "result must be empty when key is absent");
+ }
+
+ //====================================================================================================
+ // Scenario 3: @Value Optional<String> constructor parameter — resolved value present → Optional.of(...)
+ //====================================================================================================
+
+ @Test
+ void a03_optionalConstructorParam_present() {
+ Settings.get().setGlobal(KEY, "world");
+ var bean = BeanInstantiator.of(OptionalConstructorBean.class, beanStore).run();
+ assertNotNull(bean.value, "value must not be null");
+ assertTrue(bean.value.isPresent(), "value must be present");
+ assertEquals("world", bean.value.get());
+ }
+
+ //====================================================================================================
+ // Scenario 4: @Value Optional<String> constructor parameter — absent → Optional.empty()
+ //====================================================================================================
+
+ @Test
+ void a04_optionalConstructorParam_absent_isEmpty() {
+ var bean = BeanInstantiator.of(OptionalConstructorBean.class, beanStore).run();
+ assertNotNull(bean.value, "value must not be null");
+ assertTrue(bean.value.isEmpty(), "value must be empty when key is absent");
+ }
+
+ //====================================================================================================
+ // Scenario 5: Plain String parameter (non-Optional) — unwrapped scalar, unchanged behavior
+ //====================================================================================================
+
+ @Test
+ void a05_plainStringParam_usesDefault() {
+ var bean = BeanInstantiator.of(PlainStringMethodBean.class, beanStore).run();
+ assertEquals("plain-default", bean.result);
+ }
+
+ @Test
+ void a05b_plainStringParam_fromSettings() {
+ Settings.get().setGlobal(KEY, "from-settings");
+ var bean = BeanInstantiator.of(PlainStringMethodBean.class, beanStore).run();
+ assertEquals("from-settings", bean.result);
+ }
+
+ //====================================================================================================
+ // Scenario 6: Mixed parameters (one Optional, one plain) — correct wrap per parameter
+ //====================================================================================================
+
+ @Test
+ void a06_mixedParams_optionalAbsent_plainDefault() {
+ var bean = BeanInstantiator.of(MixedMethodBean.class, beanStore).run();
+ assertNotNull(bean.optional, "optional must not be null");
+ assertTrue(bean.optional.isEmpty(), "optional must be empty when key is absent");
+ assertEquals("plain-default", bean.plain);
+ }
+
+ @Test
+ void a07_mixedParams_optionalPresent_plainFromSettings() {
+ Settings.get().setGlobal(KEY, "mixed-value");
+ var bean = BeanInstantiator.of(MixedMethodBean.class, beanStore).run();
+ assertNotNull(bean.optional, "optional must not be null");
+ assertTrue(bean.optional.isPresent(), "optional must be present");
+ assertEquals("mixed-value", bean.optional.get());
+ assertEquals("mixed-value", bean.plain);
+ }
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/microbench/observability/ObservabilityNoopBenchmark.java b/juneau-utest/src/test/java/org/apache/juneau/microbench/observability/ObservabilityNoopBenchmark.java
index b7ab2244b1..21aff98c1f 100644
--- a/juneau-utest/src/test/java/org/apache/juneau/microbench/observability/ObservabilityNoopBenchmark.java
+++ b/juneau-utest/src/test/java/org/apache/juneau/microbench/observability/ObservabilityNoopBenchmark.java
@@ -120,7 +120,7 @@ public class ObservabilityNoopBenchmark {
} finally {
scope.close();
var elapsed = Duration.ofNanos(System.nanoTime() - startNanos);
- recorder.record(OP_NAME, HTTP_METHOD, URI_TEMPLATE, STATUS_CODE, elapsed, null);
+ recorder.record(OP_NAME, HTTP_METHOD, URI_TEMPLATE, STATUS_CODE, elapsed, null, "", "");
}
bh.consume(scope);
}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/async/AsyncCompletionExecutor_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/async/AsyncCompletionExecutor_Test.java
new file mode 100644
index 0000000000..6666c546ab
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/async/AsyncCompletionExecutor_Test.java
@@ -0,0 +1,325 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.async;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.condition.JRE.*;
+
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.*;
+
+import org.apache.juneau.TestBase;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.json.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.junit.jupiter.api.*;
+import org.junit.jupiter.api.condition.*;
+import org.slf4j.*;
+
+/**
+ * Tests for {@link Rest#asyncCompletionExecutor()} / {@link RestOp#asyncCompletionExecutor()} (TODO-118).
+ *
+ * <p>
+ * The {@code MockRestClient} harness exercises the synchronous fallback path of
+ * {@link org.apache.juneau.rest.processor.AsyncResponseProcessor} (since
+ * {@code MockServletRequest.isAsyncSupported() == false}). The tests here therefore focus on:
+ *
+ * <ul>
+ * <li>Correct annotation parsing and bean resolution at context-build time.
+ * <li>Startup failure when the named bean does not exist.
+ * <li>Correct response content (regression) when an executor is configured.
+ * <li>Per-op override and inheritance semantics.
+ * <li>MDC propagation still works when an executor is configured (via the sync fallback).
+ * </ul>
+ *
+ * <p>
+ * The "executor actually used on the callback thread" contract is verified via the
+ * {@link CountingExecutor} helper: even in the sync path the {@code RestOpContext} carries the
+ * resolved executor reference, which can be inspected directly.
+ */
+@TestMethodOrder(MethodOrderer.MethodName.class)
+class AsyncCompletionExecutor_Test extends TestBase {
+
+ // -----------------------------------------------------------------------------------------------------------------
+ // Support
+ // -----------------------------------------------------------------------------------------------------------------
+
+ /** Executor that counts how many tasks were submitted and records the last thread name. */
+ static class CountingExecutor implements Executor {
+ final AtomicInteger count = new AtomicInteger();
+ final AtomicReference<String> lastThreadName = new AtomicReference<>("");
+ final String poolPrefix;
+
+ CountingExecutor(String poolPrefix) { this.poolPrefix = poolPrefix; }
+
+ @Override
+ public void execute(Runnable command) {
+ count.incrementAndGet();
+ var t = new Thread(() -> {
+ lastThreadName.set(Thread.currentThread().getName());
+ command.run();
+ }, poolPrefix + "-" + count.get());
+ t.start();
+ }
+ }
+
+ /**
+ * Reflective {@code Thread.isVirtual()} so the test class compiles on Java 17 source level.
+ * Returns {@code false} on Java < 21 where the method does not exist.
+ */
+ static boolean reflectIsVirtual(Thread t) {
+ try {
+ return (Boolean) Thread.class.getMethod("isVirtual").invoke(t);
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ // -----------------------------------------------------------------------------------------------------------------
+ // A: Happy path — @Rest(asyncCompletionExecutor) resolves a named bean; resource builds and responds correctly.
+ // -----------------------------------------------------------------------------------------------------------------
+
+ @Rest(asyncCompletionExecutor = "testPool")
+ public static class A {
+ @Bean(name = "testPool")
+ public Executor testPool() {
+ return new CountingExecutor("a-pool");
+ }
+
+ @RestGet("/hello")
+ public CompletableFuture<String> hello() {
+ return CompletableFuture.completedFuture("hello-from-executor-resource");
+ }
+ }
+
+ private static final MockRestClient CA = MockRestClient.buildLax(A.class);
+
+ @Test
+ void a01_happyPath_resourceBuilds() {
+ // Building the MockRestClient would throw if bean resolution failed at startup.
+ assertNotNull(CA);
+ }
+
+ @Test
+ void a02_happyPath_responseIsCorrect() throws Exception {
+ CA.get("/hello").run().assertStatus(200).assertContent().isContains("hello-from-executor-resource");
+ }
+
+ @Test
+ void a03_happyPath_executorIsConfiguredOnContext() throws Exception {
+ // Verify the resolved executor is non-null on the RestOpContext.
+ // We do this by building a fresh context with the same resource class and checking the executor.
+ var ctx = MockRestClient.buildLax(A.class);
+ assertNotNull(ctx, "MockRestClient should build without error");
+ // Response works correctly, implying the memoizer evaluated successfully.
+ ctx.get("/hello").run().assertStatus(200);
+ }
+
+ // -----------------------------------------------------------------------------------------------------------------
+ // B: Verb-level override — @RestGet(asyncCompletionExecutor) overrides @Rest-level.
+ // -----------------------------------------------------------------------------------------------------------------
+
+ @Rest(asyncCompletionExecutor = "resourcePool")
+ public static class B {
+ @Bean(name = "resourcePool")
+ public Executor resourcePool() { return new CountingExecutor("resource-pool"); }
+
+ @Bean(name = "opPool")
+ public Executor opPool() { return new CountingExecutor("op-pool"); }
+
+ @RestGet(path = "/inherited")
+ public CompletableFuture<String> inherited() {
+ return CompletableFuture.completedFuture("uses-resource-pool");
+ }
+
+ @RestGet(path = "/overridden", asyncCompletionExecutor = "opPool")
+ public CompletableFuture<String> overridden() {
+ return CompletableFuture.completedFuture("uses-op-pool");
+ }
+ }
+
+ private static final MockRestClient CB = MockRestClient.buildLax(B.class);
+
+ @Test
+ void b01_verbOverride_bothEndpointsRespond() throws Exception {
+ CB.get("/inherited").run().assertStatus(200).assertContent().isContains("uses-resource-pool");
+ CB.get("/overridden").run().assertStatus(200).assertContent().isContains("uses-op-pool");
+ }
+
+ @Test
+ void b02_verbOverride_resourceBuilds() {
+ assertNotNull(CB);
+ }
+
+ // -----------------------------------------------------------------------------------------------------------------
+ // C: No override — default behavior (null executor, natural thread) is preserved.
+ // -----------------------------------------------------------------------------------------------------------------
+
+ @Rest
+ public static class C {
+ @RestGet("/natural")
+ public CompletableFuture<String> natural() {
+ return CompletableFuture.completedFuture("natural-thread");
+ }
+ }
+
+ private static final MockRestClient CC = MockRestClient.buildLax(C.class);
+
+ @Test
+ void c01_noOverride_resourceBuilds() {
+ assertNotNull(CC);
+ }
+
+ @Test
+ void c02_noOverride_responseIsCorrect() throws Exception {
+ CC.get("/natural").run().assertStatus(200).assertContent().isContains("natural-thread");
+ }
+
+ // -----------------------------------------------------------------------------------------------------------------
+ // D: Unresolved bean name — resource construction must throw at startup.
+ // -----------------------------------------------------------------------------------------------------------------
+
+ @Rest(asyncCompletionExecutor = "nonExistentPool")
+ public static class D {
+ @RestGet("/x")
+ public CompletableFuture<String> x() { return CompletableFuture.completedFuture("x"); }
+ }
+
+ @Test
+ void d01_unresolvedBean_throwsAtStartup() {
+ var ex = assertThrows(Exception.class, () -> MockRestClient.buildLax(D.class));
+ // The exception chain should contain our startup-fail message.
+ var msg = unwrapCause(ex);
+ assertTrue(msg.contains("nonExistentPool"),
+ "Expected message mentioning the missing bean name; got: " + msg);
+ }
+
+ private static String unwrapCause(Throwable t) {
+ var sb = new StringBuilder();
+ while (t != null) {
+ sb.append(t.getMessage()).append('\n');
+ t = t.getCause();
+ }
+ return sb.toString();
+ }
+
+ // -----------------------------------------------------------------------------------------------------------------
+ // E: Virtual thread executor — supply Executors.newVirtualThreadPerTaskExecutor() on Java 21+.
+ // -----------------------------------------------------------------------------------------------------------------
+
+ @Rest(asyncCompletionExecutor = "vtPool")
+ public static class E {
+ @Bean(name = "vtPool")
+ public Executor vtPool() throws Exception {
+ // Reflective so the resource class compiles on Java 17.
+ return (Executor) Executors.class.getMethod("newVirtualThreadPerTaskExecutor").invoke(null);
+ }
+
+ @RestGet("/vt")
+ public CompletableFuture<String> vt() {
+ return CompletableFuture.completedFuture("virtual-thread-executor");
+ }
+ }
+
+ @Test
+ @EnabledForJreRange(min = JAVA_21)
+ void e01_virtualThreadExecutor_resourceBuilds() {
+ // If reflective creation succeeds (Java 21+), the resource must build without error.
+ var c = MockRestClient.buildLax(E.class);
+ assertNotNull(c);
+ }
+
+ @Test
+ @EnabledForJreRange(min = JAVA_21)
+ void e02_virtualThreadExecutor_responseIsCorrect() throws Exception {
+ var c = MockRestClient.buildLax(E.class);
+ c.get("/vt").run().assertStatus(200).assertContent().isContains("virtual-thread-executor");
+ }
+
+ // -----------------------------------------------------------------------------------------------------------------
+ // F: MDC propagation still works when an async-completion executor is configured.
+ // The sync-fallback path exercises the full response pipeline, including MDC context.
+ // -----------------------------------------------------------------------------------------------------------------
+
+ /** The MDC key set by a "filter" before the request. */
+ static final String MDC_KEY = "asyncExecTestId";
+
+ @Rest(asyncCompletionExecutor = "mdcTestPool")
+ public static class F {
+ @Bean(name = "mdcTestPool")
+ public Executor mdcTestPool() { return new CountingExecutor("mdc-pool"); }
+
+ @RestGet("/with-mdc")
+ public CompletableFuture<String> withMdc() {
+ // In the sync-fallback path, the future completes synchronously; MDC should be accessible.
+ return CompletableFuture.completedFuture("mdc-response");
+ }
+ }
+
+ private static final MockRestClient CF = MockRestClient.buildLax(F.class);
+
+ @AfterEach
+ void clearMdc() {
+ MDC.clear();
+ }
+
+ @Test
+ void f01_mdcPropagation_resourceBuilds() {
+ assertNotNull(CF);
+ }
+
+ @Test
+ void f02_mdcPropagation_responseIsCorrect() throws Exception {
+ MDC.put(MDC_KEY, "test-req-id");
+ CF.get("/with-mdc").run().assertStatus(200).assertContent().isContains("mdc-response");
+ }
+
+ // -----------------------------------------------------------------------------------------------------------------
+ // G: Op-level @RestGet override with no resource-level setting.
+ // -----------------------------------------------------------------------------------------------------------------
+
+ @Rest
+ public static class G {
+ @Bean(name = "opOnlyPool")
+ public Executor opOnlyPool() { return new CountingExecutor("g-pool"); }
+
+ @RestGet(path = "/with-exec", asyncCompletionExecutor = "opOnlyPool")
+ public CompletableFuture<String> withExec() {
+ return CompletableFuture.completedFuture("op-only-executor");
+ }
+
+ @RestGet(path = "/without-exec")
+ public CompletableFuture<String> withoutExec() {
+ return CompletableFuture.completedFuture("no-executor");
+ }
+ }
+
+ private static final MockRestClient CG = MockRestClient.buildLax(G.class);
+
+ @Test
+ void g01_opLevelOnly_resourceBuilds() {
+ assertNotNull(CG);
+ }
+
+ @Test
+ void g02_opLevelOnly_bothEndpointsRespond() throws Exception {
+ CG.get("/with-exec").run().assertStatus(200).assertContent().isContains("op-only-executor");
+ CG.get("/without-exec").run().assertStatus(200).assertContent().isContains("no-executor");
+ }
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/auth/jwt/JwksCache_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/auth/jwt/JwksCache_Test.java
index c4b34ce5fa..d0ed196168 100644
--- a/juneau-utest/src/test/java/org/apache/juneau/rest/auth/jwt/JwksCache_Test.java
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/auth/jwt/JwksCache_Test.java
@@ -22,6 +22,7 @@ import static org.junit.jupiter.api.Assertions.*;
import java.lang.reflect.*;
import java.time.*;
import java.util.*;
+import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
import org.apache.juneau.*;
@@ -58,12 +59,41 @@ class JwksCache_Test extends TestBase {
}
}
+ // -----------------------------------------------------------------------------------------
+ // Reflective helpers — updated to the 5-arg JwksCache constructor (OQ-10).
+ // Default overloads pass eager=true / cooldown=10s matching the Builder defaults.
+ // -----------------------------------------------------------------------------------------
+
private static JwksCache newCache(JWKSource<SecurityContext> src, Duration ttl, Clock clock) throws Exception {
- var ctor = JwksCache.class.getDeclaredConstructor(JWKSource.class, Duration.class, Clock.class);
+ return newCache(src, ttl, clock, true, Duration.ofSeconds(10));
+ }
+
+ private static JwksCache newCache(JWKSource<SecurityContext> src, Duration ttl, Clock clock,
+ boolean eager, Duration cooldown) throws Exception {
+ var ctor = JwksCache.class.getDeclaredConstructor(
+ JWKSource.class, Duration.class, Clock.class, boolean.class, Duration.class);
ctor.setAccessible(true);
- return (JwksCache) ctor.newInstance(src, ttl, clock);
+ return (JwksCache) ctor.newInstance(src, ttl, clock, eager, cooldown);
+ }
+
+ private static JWKSource<SecurityContext> invokeCacheCtor(JWKSource<SecurityContext> src, Duration ttl, Clock clock) throws Exception {
+ return invokeCacheCtor(src, ttl, clock, true, Duration.ofSeconds(10));
}
+ private static JWKSource<SecurityContext> invokeCacheCtor(JWKSource<SecurityContext> src, Duration ttl, Clock clock,
+ boolean eager, Duration cooldown) throws Exception {
+ Constructor<?> ctor = JwksCache.class.getDeclaredConstructor(
+ JWKSource.class, Duration.class, Clock.class, boolean.class, Duration.class);
+ ctor.setAccessible(true);
+ @SuppressWarnings("unchecked")
+ var cast = (JWKSource<SecurityContext>) ctor.newInstance(src, ttl, clock, eager, cooldown);
+ return cast;
+ }
+
+ // -----------------------------------------------------------------------------------------
+ // a-series: basic TTL / cache-hit / cache-miss mechanics.
+ // -----------------------------------------------------------------------------------------
+
@Test void a01_cacheHitWithinTtl_skipsUnderlyingFetch() throws Exception {
var rsa = generateRsa("kid-1");
var src = new CountingSource(new JWKSet(rsa.toPublicJWK()));
@@ -98,6 +128,10 @@ class JwksCache_Test extends TestBase {
assertEquals(2, src.fetches.get(), "lookup past TTL must re-fetch from upstream");
}
+ // -----------------------------------------------------------------------------------------
+ // b-series: graceful-degradation on JWKS-endpoint outage.
+ // -----------------------------------------------------------------------------------------
+
@Test void b01_gracefulDegradation_servesCachedKeysOnFetchFailure() throws Exception {
var rsa = generateRsa("kid-1");
var src = new CountingSource(new JWKSet(rsa.toPublicJWK()));
@@ -135,6 +169,12 @@ class JwksCache_Test extends TestBase {
assertThrows(KeySourceException.class, () -> cache.get(sel, null));
}
+ // -----------------------------------------------------------------------------------------
+ // c-series: key-rotation end-to-end through JwtTokenValidator.
+ // c01 — legacy/feature-off regression (eager disabled): mid-TTL kid miss must throw.
+ // c02 — feature-on: mid-TTL kid miss succeeds after eager refresh.
+ // -----------------------------------------------------------------------------------------
+
@Test void c01_jwksRotation_picksUpNewKeysAfterTtl() throws Exception {
var oldKey = generateRsa("kid-old");
var newKey = generateRsa("kid-new");
@@ -147,10 +187,11 @@ class JwksCache_Test extends TestBase {
@Override public Instant instant() { return clockHolder.get().instant(); }
};
+ // Eager refresh disabled — preserves pre-9.5.0 behavior for regression coverage.
var validator = JwtTokenValidator.create()
.issuer(DEFAULT_ISSUER)
.audience(DEFAULT_AUDIENCE)
- .jwkSource(invokeCacheCtor(src, Duration.ofMinutes(1), clock))
+ .jwkSource(invokeCacheCtor(src, Duration.ofMinutes(1), clock, false, Duration.ofSeconds(10)))
.clock(clock)
.build();
@@ -173,11 +214,261 @@ class JwksCache_Test extends TestBase {
assertEquals("alice", p2.getName());
}
- private static JWKSource<SecurityContext> invokeCacheCtor(JWKSource<SecurityContext> src, Duration ttl, Clock clock) throws Exception {
- Constructor<?> ctor = JwksCache.class.getDeclaredConstructor(JWKSource.class, Duration.class, Clock.class);
- ctor.setAccessible(true);
- @SuppressWarnings("unchecked")
- var cast = (JWKSource<SecurityContext>) ctor.newInstance(src, ttl, clock);
- return cast;
+ @Test void c02_eagerRefresh_freshCacheKidMiss_succeeds() throws Exception {
+ var oldKey = generateRsa("kid-old");
+ var newKey = generateRsa("kid-new");
+ var src = new CountingSource(new JWKSet(oldKey.toPublicJWK()));
+ var clock = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC);
+
+ // Eager refresh enabled (default).
+ var validator = JwtTokenValidator.create()
+ .issuer(DEFAULT_ISSUER)
+ .audience(DEFAULT_AUDIENCE)
+ .jwkSource(invokeCacheCtor(src, Duration.ofMinutes(5), clock))
+ .clock(clock)
+ .build();
+
+ // Warm the cache with kid-old.
+ var oldToken = signRsa(oldKey, defaultClaims(clock).build());
+ assertEquals("alice", validator.validate(oldToken).getName());
+ assertEquals(1, src.fetches.get());
+
+ // IdP rotates: publishes {kid-old, kid-new}.
+ src.set.set(new JWKSet(List.of(oldKey.toPublicJWK(), newKey.toPublicJWK())));
+
+ // kid-new token presented within TTL — eager refresh fires, token validates.
+ var newToken = signRsa(newKey, defaultClaims(clock).build());
+ var p = validator.validate(newToken);
+ assertEquals("alice", p.getName(), "kid-new token must validate after eager refresh");
+ assertEquals(2, src.fetches.get(), "exactly one eager fetch");
+ }
+
+ // -----------------------------------------------------------------------------------------
+ // d-series: JwksCache unit tests for the eager-refresh path.
+ // -----------------------------------------------------------------------------------------
+
+ /** Helper: mutable clock driven by a holder. */
+ private static Clock holderClock(AtomicReference<Clock> holder) {
+ return new Clock() {
+ @Override public ZoneId getZone() { return holder.get().getZone(); }
+ @Override public Clock withZone(ZoneId z) { return holder.get().withZone(z); }
+ @Override public Instant instant() { return holder.get().instant(); }
+ };
+ }
+
+ @Test void d01_eagerRefresh_freshCacheKidMiss_refetchesOnce() throws Exception {
+ var oldKey = generateRsa("kid-old");
+ var newKey = generateRsa("kid-new");
+ var src = new CountingSource(new JWKSet(oldKey.toPublicJWK()));
+ var clock = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC);
+ var cache = newCache(src, Duration.ofMinutes(5), clock);
+
+ // Warm with kid-old.
+ var allSel = new JWKSelector(new JWKMatcher.Builder().build());
+ var warm = cache.get(allSel, null);
+ assertFalse(warm.isEmpty());
+ assertEquals(1, src.fetches.get());
+
+ // IdP rotates: publishes kid-new.
+ src.set.set(new JWKSet(newKey.toPublicJWK()));
+
+ // kid-new selector within TTL: should trigger eager refresh.
+ var kidNewSel = new JWKSelector(new JWKMatcher.Builder().keyID("kid-new").build());
+ var hits = cache.get(kidNewSel, null);
+
+ assertFalse(hits.isEmpty(), "kid-new must be returned after eager refresh");
+ assertEquals(2, src.fetches.get(), "exactly one eager fetch");
+ }
+
+ @Test void d02_eagerRefreshDisabled_preservesLegacyBehavior() throws Exception {
+ var oldKey = generateRsa("kid-old");
+ var newKey = generateRsa("kid-new");
+ var src = new CountingSource(new JWKSet(oldKey.toPublicJWK()));
+ var clock = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC);
+ var cache = newCache(src, Duration.ofMinutes(5), clock, false, Duration.ofSeconds(10));
+
+ var allSel = new JWKSelector(new JWKMatcher.Builder().build());
+ cache.get(allSel, null);
+ src.set.set(new JWKSet(newKey.toPublicJWK()));
+
+ var kidNewSel = new JWKSelector(new JWKMatcher.Builder().keyID("kid-new").build());
+ var hits = cache.get(kidNewSel, null);
+
+ assertTrue(hits.isEmpty(), "kid-new must remain absent within TTL when eager is disabled");
+ assertEquals(1, src.fetches.get(), "no extra fetch when eager is disabled");
+ }
+
+ @Test void d03_eagerRefresh_cooldownThrottlesRepeatedMisses() throws Exception {
+ var oldKey = generateRsa("kid-old");
+ var newKey = generateRsa("kid-new");
+ var src = new CountingSource(new JWKSet(oldKey.toPublicJWK()));
+ var t0 = Instant.parse("2026-01-01T00:00:00Z");
+ var clockHolder = new AtomicReference<>(Clock.fixed(t0, ZoneOffset.UTC));
+ var clock = holderClock(clockHolder);
+ var cooldown = Duration.ofSeconds(30);
+ var cache = newCache(src, Duration.ofMinutes(5), clock, true, cooldown);
+
+ // Warm with kid-old.
+ cache.get(new JWKSelector(new JWKMatcher.Builder().build()), null);
+ assertEquals(1, src.fetches.get());
+
+ // Rotate: only kid-new published.
+ src.set.set(new JWKSet(newKey.toPublicJWK()));
+
+ var kidMissSel = new JWKSelector(new JWKMatcher.Builder().keyID("kid-miss").build());
+
+ // First miss within TTL: eager refresh fires (fetches == 2).
+ cache.get(kidMissSel, null);
+ assertEquals(2, src.fetches.get(), "first eager refresh must fire");
+
+ // Second miss immediately (within cooldown): must be skipped.
+ cache.get(kidMissSel, null);
+ assertEquals(2, src.fetches.get(), "second miss within cooldown must not trigger another fetch");
+
+ // Advance past cooldown: third miss should trigger another eager refresh.
+ clockHolder.set(Clock.fixed(t0.plus(cooldown).plusSeconds(1), ZoneOffset.UTC));
+ cache.get(kidMissSel, null);
+ assertEquals(3, src.fetches.get(), "miss after cooldown expiry must trigger another eager fetch");
+ }
+
+ @Test void d04_eagerRefresh_endpointOutageRetainsLastKnownGood() throws Exception {
+ var oldKey = generateRsa("kid-old");
+ var src = new CountingSource(new JWKSet(oldKey.toPublicJWK()));
+ var clock = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC);
+ var cache = newCache(src, Duration.ofMinutes(5), clock, true, Duration.ofSeconds(10));
+
+ // Warm with kid-old.
+ var oldSel = new JWKSelector(new JWKMatcher.Builder().keyID("kid-old").build());
+ var allSel = new JWKSelector(new JWKMatcher.Builder().build());
+ cache.get(allSel, null);
+ assertEquals(1, src.fetches.get());
+
+ // Make the endpoint fail.
+ src.fail = true;
+
+ // kid-miss selector within TTL: eager refresh fires but fails; last-known-good retained.
+ var kidMissSel = new JWKSelector(new JWKMatcher.Builder().keyID("kid-unknown").build());
+ var missing = cache.get(kidMissSel, null);
+ assertTrue(missing.isEmpty(), "unknown kid must return empty when eager refresh fails");
+ assertEquals(2, src.fetches.get(), "one failed eager refresh attempt");
+
+ // Old key must still be available from the retained cache.
+ var retained = cache.get(oldSel, null);
+ assertFalse(retained.isEmpty(), "last-known-good keys must still be served after a failed eager refresh");
+
+ // Immediate repeat miss must be throttled by cooldown (no additional fetch).
+ cache.get(kidMissSel, null);
+ assertEquals(2, src.fetches.get(), "cooldown must prevent a second eager fetch immediately after failure");
+ }
+
+ @Test void d05_eagerRefresh_stillMissingAfterRefresh_noDoubleAttempt() throws Exception {
+ var oldKey = generateRsa("kid-old");
+ var src = new CountingSource(new JWKSet(oldKey.toPublicJWK()));
+ var clock = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC);
+ var cache = newCache(src, Duration.ofMinutes(5), clock, true, Duration.ofSeconds(10));
+
+ // Warm with kid-old.
+ cache.get(new JWKSelector(new JWKMatcher.Builder().build()), null);
+ assertEquals(1, src.fetches.get());
+
+ // Rotate: source keeps kid-old only (simulates IdP not yet publishing kid-new).
+ var kidNewSel = new JWKSelector(new JWKMatcher.Builder().keyID("kid-new").build());
+
+ // First miss: eager refresh fires, returns kid-old set, but kid-new still absent → empty.
+ var first = cache.get(kidNewSel, null);
+ assertTrue(first.isEmpty(), "still-missing kid must produce empty after one refresh");
+ assertEquals(2, src.fetches.get(), "one eager fetch");
+
+ // Immediate repeat: must be throttled by cooldown.
+ cache.get(kidNewSel, null);
+ assertEquals(2, src.fetches.get(), "no extra fetch within cooldown after still-missing refresh");
+ }
+
+ @Test void d06_eagerRefresh_successResetsTtlWindow() throws Exception {
+ var oldKey = generateRsa("kid-old");
+ var newKey = generateRsa("kid-new");
+ var src = new CountingSource(new JWKSet(oldKey.toPublicJWK()));
+ var t0 = Instant.parse("2026-01-01T00:00:00Z");
+ var ttl = Duration.ofMinutes(5);
+ var clockHolder = new AtomicReference<>(Clock.fixed(t0, ZoneOffset.UTC));
+ var clock = holderClock(clockHolder);
+ var cache = newCache(src, ttl, clock, true, Duration.ofSeconds(10));
+
+ // Warm at t0.
+ var allSel = new JWKSelector(new JWKMatcher.Builder().build());
+ cache.get(allSel, null);
+ assertEquals(1, src.fetches.get());
+
+ // Rotate at t0+1min: IdP publishes {kid-old, kid-new}.
+ clockHolder.set(Clock.fixed(t0.plusSeconds(60), ZoneOffset.UTC));
+ src.set.set(new JWKSet(List.of(oldKey.toPublicJWK(), newKey.toPublicJWK())));
+
+ // Eager refresh fires at t0+1min (still within original TTL window).
+ var kidNewSel = new JWKSelector(new JWKMatcher.Builder().keyID("kid-new").build());
+ var hits = cache.get(kidNewSel, null);
+ assertFalse(hits.isEmpty(), "kid-new must be found after eager refresh");
+ assertEquals(2, src.fetches.get());
+
+ // Move to t0+4min (just before the ORIGINAL TTL boundary, but well within the RESET window).
+ // If the TTL was reset at t0+1min, the next periodic refresh is due at t0+6min.
+ clockHolder.set(Clock.fixed(t0.plusSeconds(240), ZoneOffset.UTC));
+ cache.get(allSel, null);
+ assertEquals(2, src.fetches.get(), "TTL window must be reset by the eager refresh; no extra fetch at t0+4min");
+ }
+
+ @Test void d07_eagerRefresh_singleInFlight_noThunderingHerd() throws Exception {
+ var oldKey = generateRsa("kid-old");
+ var newKey = generateRsa("kid-new");
+
+ // Latch that holds the eager-refresh delegate call until we release it.
+ var fetchLatch = new CountDownLatch(1);
+ var warmupDone = new AtomicBoolean(false);
+ var fetchCount = new AtomicInteger();
+
+ JWKSource<SecurityContext> src = (selector, ctx) -> {
+ fetchCount.incrementAndGet();
+ if (!warmupDone.get())
+ return selector.select(new JWKSet(oldKey.toPublicJWK()));
+ // Eager-refresh fetch: block until the test releases the latch.
+ try { fetchLatch.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
+ return selector.select(new JWKSet(List.of(oldKey.toPublicJWK(), newKey.toPublicJWK())));
+ };
+
+ var clock = Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC);
+ var cache = newCache(src, Duration.ofMinutes(5), clock, true, Duration.ofSeconds(10));
+
+ // Warm the cache.
+ cache.get(new JWKSelector(new JWKMatcher.Builder().build()), null);
+ assertEquals(1, fetchCount.get());
+ warmupDone.set(true);
+
+ // N threads simultaneously present a kid-new miss.
+ int n = 6;
+ var barrier = new CyclicBarrier(n);
+ var executor = Executors.newFixedThreadPool(n);
+ var kidNewSel = new JWKSelector(new JWKMatcher.Builder().keyID("kid-new").build());
+ var futures = new ArrayList<Future<List<JWK>>>();
+ for (int i = 0; i < n; i++) {
+ futures.add(executor.submit(() -> {
+ barrier.await(); // all threads start simultaneously
+ return cache.get(kidNewSel, null);
+ }));
+ }
+
+ // Allow threads to queue up on the blocked fetch, then release.
+ Thread.sleep(250);
+ fetchLatch.countDown();
+
+ executor.shutdown();
+ assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS));
+
+ int successCount = 0;
+ for (var f : futures) {
+ if (!f.get().isEmpty())
+ successCount++;
+ }
+
+ assertEquals(2, fetchCount.get(), "exactly one eager fetch regardless of concurrent misses");
+ assertEquals(n, successCount, "all concurrent threads must see the refreshed key");
}
}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/auth/jwt/JwtTokenValidator_Builder_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/auth/jwt/JwtTokenValidator_Builder_Test.java
index a195e0f221..5c1e776c2c 100644
--- a/juneau-utest/src/test/java/org/apache/juneau/rest/auth/jwt/JwtTokenValidator_Builder_Test.java
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/auth/jwt/JwtTokenValidator_Builder_Test.java
@@ -126,6 +126,50 @@ class JwtTokenValidator_Builder_Test extends TestBase {
);
}
+ @Test void b06_zeroEagerRefreshCooldown_rejected() {
+ assertThrows(IllegalArgumentException.class, () ->
+ JwtTokenValidator.create().jwksEagerRefreshCooldown(Duration.ZERO)
+ );
+ }
+
+ @Test void b07_negativeEagerRefreshCooldown_rejected() {
+ assertThrows(IllegalArgumentException.class, () ->
+ JwtTokenValidator.create().jwksEagerRefreshCooldown(Duration.ofSeconds(-1))
+ );
+ }
+
+ @Test void b08_eagerRefreshCooldownOverSixtySeconds_rejected() {
+ assertThrows(IllegalArgumentException.class, () ->
+ JwtTokenValidator.create().jwksEagerRefreshCooldown(Duration.ofSeconds(61))
+ );
+ }
+
+ @Test void b09_eagerRefreshCooldownExceedsTtl_rejectedAtBuild() throws Exception {
+ var rsa = generateRsa("kid-1");
+ // ttl = 20s, cooldown = 30s (≤ 60s but > ttl) → rejected at build().
+ assertThrows(IllegalStateException.class, () ->
+ JwtTokenValidator.create()
+ .issuer(DEFAULT_ISSUER)
+ .audience(DEFAULT_AUDIENCE)
+ .jwkSource(fixed(rsa))
+ .jwksCacheTtl(Duration.ofSeconds(20))
+ .jwksEagerRefreshCooldown(Duration.ofSeconds(30))
+ .build()
+ );
+ }
+
+ @Test void b10_eagerRefreshOnKidMiss_setterDisablesFeature() throws Exception {
+ var rsa = generateRsa("kid-1");
+ // Should build successfully with eager disabled.
+ var v = JwtTokenValidator.create()
+ .issuer(DEFAULT_ISSUER)
+ .audience(DEFAULT_AUDIENCE)
+ .jwkSource(fixed(rsa))
+ .jwksEagerRefreshOnKidMiss(false)
+ .build();
+ assertNotNull(v);
+ }
+
// -----------------------------------------------------------------------------------------
// Getters expose the configured state.
// -----------------------------------------------------------------------------------------
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/auth/jwt/JwtTokenValidator_ValueAdoption_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/auth/jwt/JwtTokenValidator_ValueAdoption_Test.java
index 6940add46e..dba93ee8ba 100644
--- a/juneau-utest/src/test/java/org/apache/juneau/rest/auth/jwt/JwtTokenValidator_ValueAdoption_Test.java
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/auth/jwt/JwtTokenValidator_ValueAdoption_Test.java
@@ -31,17 +31,23 @@ import org.junit.jupiter.api.*;
*/
class JwtTokenValidator_ValueAdoption_Test {
- private static final String SP = "juneau.jwt.jwksCacheTtl";
+ private static final String TTL_KEY = "juneau.jwt.jwksCacheTtl";
+ private static final String EAGER_KEY = "juneau.jwt.jwksEagerRefreshOnKidMiss";
+ private static final String COOLDOWN_KEY = "juneau.jwt.jwksEagerRefreshCooldown";
@AfterEach
void cleanup() {
- Settings.get().unsetGlobal(SP);
- System.clearProperty(SP);
+ for (var k : new String[]{TTL_KEY, EAGER_KEY, COOLDOWN_KEY}) {
+ Settings.get().unsetGlobal(k);
+ System.clearProperty(k);
+ }
}
+ // ---- jwksCacheTtl -----------------------------------------------------------------------
+
@Test
void a01_jwksCacheTtl_set() {
- System.setProperty(SP, "PT10M");
+ System.setProperty(TTL_KEY, "PT10M");
assertEquals(Duration.ofMinutes(10), JwtTokenValidator.create().jwksCacheTtl);
}
@@ -52,7 +58,45 @@ class JwtTokenValidator_ValueAdoption_Test {
@Test
void a03_jwksCacheTtl_setGlobal() {
- Settings.get().setGlobal(SP, "PT15M");
+ Settings.get().setGlobal(TTL_KEY, "PT15M");
assertEquals(Duration.ofMinutes(15), JwtTokenValidator.create().jwksCacheTtl);
}
+
+ // ---- jwksEagerRefreshOnKidMiss ----------------------------------------------------------
+
+ @Test
+ void b01_jwksEagerRefreshOnKidMiss_set() {
+ System.setProperty(EAGER_KEY, "false");
+ assertEquals(false, JwtTokenValidator.create().jwksEagerRefreshOnKidMiss);
+ }
+
+ @Test
+ void b02_jwksEagerRefreshOnKidMiss_unset() {
+ assertEquals(true, JwtTokenValidator.create().jwksEagerRefreshOnKidMiss);
+ }
+
+ @Test
+ void b03_jwksEagerRefreshOnKidMiss_setGlobal() {
+ Settings.get().setGlobal(EAGER_KEY, "false");
+ assertEquals(false, JwtTokenValidator.create().jwksEagerRefreshOnKidMiss);
+ }
+
+ // ---- jwksEagerRefreshCooldown -----------------------------------------------------------
+
+ @Test
+ void c01_jwksEagerRefreshCooldown_set() {
+ System.setProperty(COOLDOWN_KEY, "PT30S");
+ assertEquals(Duration.ofSeconds(30), JwtTokenValidator.create().jwksEagerRefreshCooldown);
+ }
+
+ @Test
+ void c02_jwksEagerRefreshCooldown_unset() {
+ assertEquals(Duration.ofSeconds(10), JwtTokenValidator.create().jwksEagerRefreshCooldown);
+ }
+
+ @Test
+ void c03_jwksEagerRefreshCooldown_setGlobal() {
+ Settings.get().setGlobal(COOLDOWN_KEY, "PT20S");
+ assertEquals(Duration.ofSeconds(20), JwtTokenValidator.create().jwksEagerRefreshCooldown);
+ }
}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/auth/oidc/rp/EphemeralStore_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/auth/oidc/rp/EphemeralStore_Test.java
new file mode 100644
index 0000000000..e7ac5a37e9
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/auth/oidc/rp/EphemeralStore_Test.java
@@ -0,0 +1,119 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.auth.oidc.rp;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.time.*;
+
+import org.apache.juneau.TestBase;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link EphemeralStore} — single-use, TTL-bounded, size-bounded state/nonce storage.
+ *
+ * @since 9.5.0
+ */
+class EphemeralStore_Test extends TestBase {
+
+ private static EphemeralStore store(Clock clock) {
+ return new EphemeralStore(Duration.ofMinutes(5), 100, clock);
+ }
+
+ @Test void a01_storeThenConsume_roundTrips() {
+ var s = store(Clock.systemUTC());
+ s.store("state-1", "nonce-1", "verifier-1", "/dashboard");
+ var p = s.consume("state-1");
+ assertTrue(p.isPresent());
+ assertEquals("nonce-1", p.get().nonce());
+ assertEquals("verifier-1", p.get().codeVerifier());
+ assertEquals("/dashboard", p.get().redirectTarget());
+ }
+
+ @Test void a02_consume_isSingleUse() {
+ var s = store(Clock.systemUTC());
+ s.store("state-1", "nonce-1", "verifier-1", null);
+ assertTrue(s.consume("state-1").isPresent());
+ assertTrue(s.consume("state-1").isEmpty(), "second consume of same state must miss (replay defense)");
+ }
+
+ @Test void a03_consume_unknownState_isEmpty() {
+ var s = store(Clock.systemUTC());
+ assertTrue(s.consume("never-stored").isEmpty());
+ }
+
+ @Test void a04_consume_nullState_isEmpty() {
+ var s = store(Clock.systemUTC());
+ assertTrue(s.consume(null).isEmpty());
+ }
+
+ @Test void b01_expiredEntry_isMissed() {
+ var base = Instant.parse("2026-01-01T00:00:00Z");
+ var clock = new MutableClock(base);
+ var s = new EphemeralStore(Duration.ofMinutes(5), 100, clock);
+ s.store("state-1", "nonce-1", "verifier-1", null);
+ clock.advance(Duration.ofMinutes(6));
+ assertTrue(s.consume("state-1").isEmpty(), "entry past TTL must be treated as a miss");
+ }
+
+ @Test void b02_notYetExpired_survives() {
+ var base = Instant.parse("2026-01-01T00:00:00Z");
+ var clock = new MutableClock(base);
+ var s = new EphemeralStore(Duration.ofMinutes(5), 100, clock);
+ s.store("state-1", "nonce-1", "verifier-1", null);
+ clock.advance(Duration.ofMinutes(4));
+ assertTrue(s.consume("state-1").isPresent());
+ }
+
+ @Test void c01_sizeCap_evictsEldest() {
+ var s = new EphemeralStore(Duration.ofMinutes(5), 3, Clock.systemUTC());
+ s.store("s1", "n", "v", null);
+ s.store("s2", "n", "v", null);
+ s.store("s3", "n", "v", null);
+ s.store("s4", "n", "v", null); // evicts s1
+ assertEquals(3, s.size());
+ assertTrue(s.consume("s1").isEmpty());
+ assertTrue(s.consume("s4").isPresent());
+ }
+
+ @Test void d01_rejectsNonPositiveTtl() {
+ assertThrows(IllegalArgumentException.class, () -> new EphemeralStore(Duration.ZERO, 100, Clock.systemUTC()));
+ }
+
+ @Test void d02_rejectsTtlAbove30Minutes() {
+ assertThrows(IllegalArgumentException.class, () -> new EphemeralStore(Duration.ofMinutes(31), 100, Clock.systemUTC()));
+ }
+
+ @Test void d03_rejectsNonPositiveMaxEntries() {
+ assertThrows(IllegalArgumentException.class, () -> new EphemeralStore(Duration.ofMinutes(5), 0, Clock.systemUTC()));
+ }
+
+ @Test void d04_rejectsBlankStateOnStore() {
+ var s = store(Clock.systemUTC());
+ assertThrows(IllegalArgumentException.class, () -> s.store("", "n", "v", null));
+ }
+
+ /** A test clock the test can advance manually. */
+ static final class MutableClock extends Clock {
+ private Instant now;
+ MutableClock(Instant start) { now = start; }
+ void advance(Duration d) { now = now.plus(d); }
+ @Override public ZoneId getZone() { return ZoneOffset.UTC; }
+ @Override public Clock withZone(ZoneId zone) { return this; }
+ @Override public Instant instant() { return now; }
+ }
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/auth/oidc/rp/IdTokenValidatorAdapter_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/auth/oidc/rp/IdTokenValidatorAdapter_Test.java
new file mode 100644
index 0000000000..edb7c16f1a
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/auth/oidc/rp/IdTokenValidatorAdapter_Test.java
@@ -0,0 +1,112 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.auth.oidc.rp;
+
+import static org.apache.juneau.rest.auth.oidc.rp.OidcTestSupport.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.net.URI;
+import java.time.*;
+import java.util.*;
+
+import org.apache.juneau.TestBase;
+import org.apache.juneau.rest.auth.AuthenticationException;
+import org.junit.jupiter.api.*;
+
+import com.nimbusds.jose.JWSAlgorithm;
+import com.nimbusds.jose.jwk.RSAKey;
+
+/**
+ * Tests for {@link IdTokenValidatorAdapter} — OIDC ID-token signature + claim validation via Nimbus.
+ *
+ * @since 9.5.0
+ */
+class IdTokenValidatorAdapter_Test extends TestBase {
+
+ private static final String ISS = "https://stub-idp.example.com";
+ private static final String CID = "web-app";
+
+ private RSAKey key;
+ private Instant now;
+
+ @BeforeEach void setup() throws Exception {
+ key = generateRsa("k1");
+ now = Instant.now(); // validation uses the real system clock, so anchor tokens to "now"
+ }
+
+ private IdTokenValidatorAdapter validator() {
+ return IdTokenValidatorAdapter.create().issuer(ISS).clientId(CID).jwkSet(publicJwks(key)).build();
+ }
+
+ @Test void a01_validToken_projectsClaims() throws Exception {
+ var jwt = signIdToken(key, ISS, CID, "alice", "sess-1", "nonce-1", now, Duration.ofMinutes(5), null);
+ var p = validator().validate(jwt, "nonce-1");
+ assertEquals("alice", p.getName());
+ assertEquals("sess-1", p.getClaim("sid", String.class).orElse(null));
+ }
+
+ @Test void b01_wrongNonce_rejected() throws Exception {
+ var jwt = signIdToken(key, ISS, CID, "alice", null, "nonce-1", now, Duration.ofMinutes(5), null);
+ assertThrows(AuthenticationException.class, () -> validator().validate(jwt, "different-nonce"));
+ }
+
+ @Test void b02_wrongIssuer_rejected() throws Exception {
+ var jwt = signIdToken(key, "https://evil.example.com", CID, "alice", null, "n", now, Duration.ofMinutes(5), null);
+ assertThrows(AuthenticationException.class, () -> validator().validate(jwt, "n"));
+ }
+
+ @Test void b03_wrongAudience_rejected() throws Exception {
+ var jwt = signIdToken(key, ISS, "some-other-client", "alice", null, "n", now, Duration.ofMinutes(5), null);
+ assertThrows(AuthenticationException.class, () -> validator().validate(jwt, "n"));
+ }
+
+ @Test void b04_expiredToken_rejected() throws Exception {
+ var past = now.minus(Duration.ofHours(1));
+ var jwt = signIdToken(key, ISS, CID, "alice", null, "n", past, Duration.ofMinutes(5), null);
+ assertThrows(AuthenticationException.class, () -> validator().validate(jwt, "n"));
+ }
+
+ @Test void b05_wrongSigningKey_rejected() throws Exception {
+ var attacker = generateRsa("k1"); // same kid, different key material
+ var jwt = signIdToken(attacker, ISS, CID, "alice", null, "n", now, Duration.ofMinutes(5), null);
+ assertThrows(AuthenticationException.class, () -> validator().validate(jwt, "n"));
+ }
+
+ @Test void b06_garbageToken_rejected() {
+ assertThrows(AuthenticationException.class, () -> validator().validate("not-a-jwt", "n"));
+ }
+
+ @Test void c01_noneAlgorithm_rejectedAtBuild() {
+ assertThrows(IllegalArgumentException.class,
+ () -> IdTokenValidatorAdapter.create().algorithms(new JWSAlgorithm("none")));
+ }
+
+ @Test void c02_requiresExactlyOneJwkSource() {
+ assertThrows(IllegalStateException.class,
+ () -> IdTokenValidatorAdapter.create().issuer(ISS).clientId(CID).build());
+ assertThrows(IllegalStateException.class,
+ () -> IdTokenValidatorAdapter.create().issuer(ISS).clientId(CID)
+ .jwkSet(publicJwks(key)).jwksUri(URI.create("https://idp/jwks")).build());
+ }
+
+ @Test void c03_requiresIssuerAndClientId() {
+ assertThrows(IllegalStateException.class,
+ () -> IdTokenValidatorAdapter.create().clientId(CID).jwkSet(publicJwks(key)).build());
+ assertThrows(IllegalStateException.class,
+ () -> IdTokenValidatorAdapter.create().issuer(ISS).jwkSet(publicJwks(key)).build());
+ }
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/auth/oidc/rp/InMemorySessionStore_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/auth/oidc/rp/InMemorySessionStore_Test.java
new file mode 100644
index 0000000000..c85a7f7aa3
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/auth/oidc/rp/InMemorySessionStore_Test.java
@@ -0,0 +1,113 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.auth.oidc.rp;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.time.*;
+import java.util.*;
+
+import org.apache.juneau.TestBase;
+import org.apache.juneau.rest.auth.ClaimsPrincipal;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link InMemorySessionStore} — server-side indexing + back-channel revocation.
+ *
+ * @since 9.5.0
+ */
+class InMemorySessionStore_Test extends TestBase {
+
+ private static OidcSession session(String id, String subject, String sid) {
+ return session(id, subject, sid, Instant.now());
+ }
+
+ private static OidcSession session(String id, String subject, String sid, Instant base) {
+ return new OidcSession(id, subject, Optional.ofNullable(sid),
+ new ClaimsPrincipal(subject, Map.of("sub", subject)),
+ Set.of("user"), Optional.empty(), base, base.plus(Duration.ofHours(8)));
+ }
+
+ @Test void a01_createThenLookup_roundTrips() {
+ var store = InMemorySessionStore.create();
+ var cookie = store.createSessionCookieValue(session("id-1", "alice", "sess-1"));
+ assertEquals("id-1", cookie);
+ assertTrue(store.lookup("id-1").isPresent());
+ assertEquals("alice", store.lookup("id-1").get().subject());
+ }
+
+ @Test void a02_lookupUnknown_isEmpty() {
+ assertTrue(InMemorySessionStore.create().lookup("nope").isEmpty());
+ }
+
+ @Test void a03_invalidate_removesSession() {
+ var store = InMemorySessionStore.create();
+ store.createSessionCookieValue(session("id-1", "alice", "sess-1"));
+ store.invalidate("id-1");
+ assertTrue(store.lookup("id-1").isEmpty());
+ }
+
+ @Test void b01_supportsServerSideRevocation() {
+ assertTrue(InMemorySessionStore.create().supportsServerSideRevocation());
+ }
+
+ @Test void b02_invalidateBySessionId_targetsSingleSession() {
+ var store = InMemorySessionStore.create();
+ store.createSessionCookieValue(session("id-1", "alice", "sess-1"));
+ store.createSessionCookieValue(session("id-2", "alice", "sess-2"));
+ assertEquals(1, store.invalidateBySessionId("sess-1"));
+ assertTrue(store.lookup("id-1").isEmpty());
+ assertTrue(store.lookup("id-2").isPresent());
+ }
+
+ @Test void b03_invalidateBySubject_targetsAllOfSubject() {
+ var store = InMemorySessionStore.create();
+ store.createSessionCookieValue(session("id-1", "alice", "sess-1"));
+ store.createSessionCookieValue(session("id-2", "alice", "sess-2"));
+ store.createSessionCookieValue(session("id-3", "bob", "sess-3"));
+ assertEquals(2, store.invalidateBySubject("alice"));
+ assertTrue(store.lookup("id-1").isEmpty());
+ assertTrue(store.lookup("id-2").isEmpty());
+ assertTrue(store.lookup("id-3").isPresent());
+ }
+
+ @Test void b04_invalidateByUnknownSubjectOrSid_returnsZero() {
+ var store = InMemorySessionStore.create();
+ assertEquals(0, store.invalidateBySubject("ghost"));
+ assertEquals(0, store.invalidateBySessionId("ghost"));
+ }
+
+ @Test void c01_expiredSession_missedAndRemoved() {
+ var base = Instant.parse("2026-01-01T00:00:00Z");
+ var clock = new EphemeralStore_Test.MutableClock(base);
+ var store = new InMemorySessionStore(100, clock);
+ store.createSessionCookieValue(session("id-1", "alice", "sess-1", base));
+ clock.advance(Duration.ofHours(9));
+ assertTrue(store.lookup("id-1").isEmpty());
+ assertEquals(0, store.size());
+ }
+
+ @Test void d01_sizeCap_evictsEldestAndUnindexes() {
+ var store = InMemorySessionStore.create(2);
+ store.createSessionCookieValue(session("id-1", "alice", "sess-1"));
+ store.createSessionCookieValue(session("id-2", "bob", "sess-2"));
+ store.createSessionCookieValue(session("id-3", "carol", "sess-3")); // evicts id-1
+ assertEquals(2, store.size());
+ assertTrue(store.lookup("id-1").isEmpty());
+ assertEquals(0, store.invalidateBySubject("alice"), "evicted session must also be unindexed");
+ }
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/auth/oidc/rp/OidcRelyingParty_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/auth/oidc/rp/OidcRelyingParty_Test.java
new file mode 100644
index 0000000000..1532768e09
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/auth/oidc/rp/OidcRelyingParty_Test.java
@@ -0,0 +1,290 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.auth.oidc.rp;
+
+import static org.apache.juneau.rest.auth.oidc.rp.OidcTestSupport.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.net.URI;
+import java.time.*;
+import java.util.*;
+
+import org.apache.juneau.TestBase;
+import org.apache.juneau.rest.auth.*;
+import org.apache.juneau.rest.auth.oidc.rp.OidcTestSupport.StubIdp;
+import org.apache.juneau.rest.mock.*;
+import org.junit.jupiter.api.*;
+
+import com.nimbusds.jose.jwk.RSAKey;
+
+import jakarta.servlet.http.Cookie;
+
+/**
+ * End-to-end tests for {@link OidcRelyingParty} driving login → callback → authorized-request
+ * → logout against an in-JVM {@link StubIdp}, plus refresh-token rotation and back-channel logout.
+ *
+ * @since 9.5.0
+ */
+class OidcRelyingParty_Test extends TestBase {
+
+ private static final String CID = "web-app";
+ private static final URI REDIRECT_URI = URI.create("https://app.example.com/auth/callback");
+ private static final URI AUTHZ = URI.create("https://stub-idp.example.com/authorize");
+ private static final URI END_SESSION = URI.create("https://stub-idp.example.com/logout");
+
+ private StubIdp idp;
+ private RSAKey key;
+
+ @BeforeEach void setup() throws Exception {
+ key = generateRsa("k1");
+ idp = new StubIdp();
+ }
+
+ @AfterEach void teardown() {
+ if (idp != null)
+ idp.close();
+ }
+
+ private OidcRelyingParty rp(SessionStore store) {
+ return OidcRelyingParty.create()
+ .metadata(idp.metadata(AUTHZ, END_SESSION))
+ .clientId(CID)
+ .clientSecret("client-secret")
+ .redirectUri(REDIRECT_URI)
+ .scope("openid", "profile")
+ .sessionStore(store)
+ .jwkSet(publicJwks(key))
+ .build();
+ }
+
+ /** Drives startLogin + completeLogin; returns the session cookie value. */
+ private String login(OidcRelyingParty rp, String sub, String sid, String redirectParam) throws Exception {
+ var loginUri = "/auth/login" + (redirectParam == null ? "" : "?redirect=" + redirectParam);
+ var req1 = MockServletRequest.create("GET", loginUri);
+ var res1 = MockServletResponse.create();
+ rp.startLogin(req1, res1);
+ var loc = res1.getHeader("Location");
+ assertNotNull(loc, "startLogin must redirect to the IdP");
+ var state = queryParam(loc, "state");
+ var nonce = queryParam(loc, "nonce");
+ assertNotNull(state);
+ assertNotNull(nonce);
+ idp.idToken = signIdToken(key, idp.issuer, CID, sub, sid, nonce, Instant.now(), Duration.ofMinutes(5),
+ Map.of("scope", "openid profile"));
+ var req2 = MockServletRequest.create("GET", REDIRECT_URI + "?code=abc123&state=" + state);
+ var res2 = MockServletResponse.create();
+ rp.completeLogin(req2, res2);
+ assertEquals("no-store", res2.getHeader("Cache-Control"));
+ return cookieValue(res2.getHeader("Set-Cookie"));
+ }
+
+ private Optional<AuthResult> authn(OidcRelyingParty rp, String cookie) throws Exception {
+ var req = MockServletRequest.create("GET", "/api/resource")
+ .cookies(new Cookie[]{ new Cookie(rp.cookieName(), cookie) });
+ return rp.authFilter().authenticate(req);
+ }
+
+ //-----------------------------------------------------------------------------------------------------------------
+ // Happy-path login
+ //-----------------------------------------------------------------------------------------------------------------
+
+ @Test void a01_fullLoginFlow_inMemoryStore() throws Exception {
+ var rp = rp(InMemorySessionStore.create());
+ var cookie = login(rp, "alice", "sess-1", null);
+ assertNotNull(cookie);
+ var auth = authn(rp, cookie);
+ assertTrue(auth.isPresent());
+ assertEquals("alice", auth.get().getPrincipal().getName());
+ assertTrue(auth.get().getRoles().contains("openid"));
+ assertTrue(auth.get().getRoles().contains("profile"));
+ }
+
+ @Test void a02_fullLoginFlow_signedCookieStore() throws Exception {
+ var rp = rp(SignedCookieSessionStore.create().signingKey("0123456789abcdef0123456789abcdef").build());
+ var cookie = login(rp, "alice", "sess-1", null);
+ var auth = authn(rp, cookie);
+ assertTrue(auth.isPresent());
+ assertEquals("alice", auth.get().getPrincipal().getName());
+ }
+
+ @Test void a03_completeLogin_setsSecureCookieFlags() throws Exception {
+ var rp = rp(InMemorySessionStore.create());
+ var req1 = MockServletRequest.create("GET", "/auth/login");
+ var res1 = MockServletResponse.create();
+ rp.startLogin(req1, res1);
+ var state = queryParam(res1.getHeader("Location"), "state");
+ var nonce = queryParam(res1.getHeader("Location"), "nonce");
+ idp.idToken = signIdToken(key, idp.issuer, CID, "alice", "sess-1", nonce, Instant.now(), Duration.ofMinutes(5), null);
+ var req2 = MockServletRequest.create("GET", REDIRECT_URI + "?code=abc&state=" + state);
+ var res2 = MockServletResponse.create();
+ rp.completeLogin(req2, res2);
+ var setCookie = res2.getHeader("Set-Cookie");
+ assertTrue(setCookie.contains("HttpOnly"));
+ assertTrue(setCookie.contains("Secure"));
+ assertTrue(setCookie.contains("SameSite=Lax"));
+ }
+
+ @Test void a04_redirectTarget_isHonored() throws Exception {
+ var rp = rp(InMemorySessionStore.create());
+ var req1 = MockServletRequest.create("GET", "/auth/login?redirect=/dashboard");
+ var res1 = MockServletResponse.create();
+ rp.startLogin(req1, res1);
+ var state = queryParam(res1.getHeader("Location"), "state");
+ var nonce = queryParam(res1.getHeader("Location"), "nonce");
+ idp.idToken = signIdToken(key, idp.issuer, CID, "alice", "sess-1", nonce, Instant.now(), Duration.ofMinutes(5), null);
+ var req2 = MockServletRequest.create("GET", REDIRECT_URI + "?code=abc&state=" + state);
+ var res2 = MockServletResponse.create();
+ rp.completeLogin(req2, res2);
+ assertEquals("/dashboard", res2.getHeader("Location"));
+ }
+
+ @Test void a05_openRedirect_isBlocked() throws Exception {
+ var rp = rp(InMemorySessionStore.create());
+ var req1 = MockServletRequest.create("GET", "/auth/login?redirect=https://evil.example.com");
+ var res1 = MockServletResponse.create();
+ rp.startLogin(req1, res1);
+ var state = queryParam(res1.getHeader("Location"), "state");
+ var nonce = queryParam(res1.getHeader("Location"), "nonce");
+ idp.idToken = signIdToken(key, idp.issuer, CID, "alice", "sess-1", nonce, Instant.now(), Duration.ofMinutes(5), null);
+ var req2 = MockServletRequest.create("GET", REDIRECT_URI + "?code=abc&state=" + state);
+ var res2 = MockServletResponse.create();
+ rp.completeLogin(req2, res2);
+ assertEquals("/", res2.getHeader("Location"), "absolute redirect target must be rejected (open-redirect defense)");
+ }
+
+ //-----------------------------------------------------------------------------------------------------------------
+ // Callback failure modes
+ //-----------------------------------------------------------------------------------------------------------------
+
+ @Test void b01_replayedState_isRejected() throws Exception {
+ var rp = rp(InMemorySessionStore.create());
+ var req1 = MockServletRequest.create("GET", "/auth/login");
+ var res1 = MockServletResponse.create();
+ rp.startLogin(req1, res1);
+ var state = queryParam(res1.getHeader("Location"), "state");
+ var nonce = queryParam(res1.getHeader("Location"), "nonce");
+ idp.idToken = signIdToken(key, idp.issuer, CID, "alice", "sess-1", nonce, Instant.now(), Duration.ofMinutes(5), null);
+ rp.completeLogin(MockServletRequest.create("GET", REDIRECT_URI + "?code=abc&state=" + state), MockServletResponse.create());
+ // Second callback with the same (already-consumed) state must fail.
+ assertThrows(AuthenticationException.class,
+ () -> rp.completeLogin(MockServletRequest.create("GET", REDIRECT_URI + "?code=abc&state=" + state), MockServletResponse.create()));
+ }
+
+ @Test void b02_unknownState_isRejected() throws Exception {
+ var rp = rp(InMemorySessionStore.create());
+ assertThrows(AuthenticationException.class,
+ () -> rp.completeLogin(MockServletRequest.create("GET", REDIRECT_URI + "?code=abc&state=never-issued"), MockServletResponse.create()));
+ }
+
+ @Test void b03_badNonce_isRejected() throws Exception {
+ var rp = rp(InMemorySessionStore.create());
+ var req1 = MockServletRequest.create("GET", "/auth/login");
+ var res1 = MockServletResponse.create();
+ rp.startLogin(req1, res1);
+ var state = queryParam(res1.getHeader("Location"), "state");
+ idp.idToken = signIdToken(key, idp.issuer, CID, "alice", "sess-1", "WRONG-NONCE", Instant.now(), Duration.ofMinutes(5), null);
+ assertThrows(AuthenticationException.class,
+ () -> rp.completeLogin(MockServletRequest.create("GET", REDIRECT_URI + "?code=abc&state=" + state), MockServletResponse.create()));
+ }
+
+ //-----------------------------------------------------------------------------------------------------------------
+ // Logout
+ //-----------------------------------------------------------------------------------------------------------------
+
+ @Test void c01_logout_clearsCookieAndRedirectsThroughEndSession() throws Exception {
+ var store = InMemorySessionStore.create();
+ var rp = rp(store);
+ var cookie = login(rp, "alice", "sess-1", null);
+ var req = MockServletRequest.create("GET", "/auth/logout").cookies(new Cookie[]{ new Cookie(rp.cookieName(), cookie) });
+ var res = MockServletResponse.create();
+ rp.logout(req, res);
+ assertTrue(res.getHeader("Set-Cookie").contains("Max-Age=0"), "logout must clear the cookie");
+ assertTrue(res.getHeader("Location").startsWith(END_SESSION.toString()), "logout should redirect through the IdP end-session endpoint");
+ assertTrue(authn(rp, cookie).isEmpty(), "session must be invalidated after logout");
+ }
+
+ //-----------------------------------------------------------------------------------------------------------------
+ // Refresh-token rotation
+ //-----------------------------------------------------------------------------------------------------------------
+
+ @Test void d01_refresh_rotatesTokenAndSession() throws Exception {
+ var rp = rp(InMemorySessionStore.create());
+ var cookie = login(rp, "alice", "sess-1", null);
+ var req = MockServletRequest.create("GET", "/auth/refresh").cookies(new Cookie[]{ new Cookie(rp.cookieName(), cookie) });
+ var res = MockServletResponse.create();
+ var refreshed = rp.refresh(req, res);
+ assertTrue(refreshed.isPresent());
+ assertEquals("rt-2", refreshed.get().token().orElseThrow().refreshToken().orElse(null), "rotating refresh token must be replaced");
+ assertNotNull(res.getHeader("Set-Cookie"));
+ }
+
+ @Test void d02_refresh_reuseRejectedInvalidatesSession() throws Exception {
+ var store = InMemorySessionStore.create();
+ var rp = rp(store);
+ var cookie = login(rp, "alice", "sess-1", null);
+ idp.failToken = true; // IdP rejects the refresh (rotated token reuse / revocation)
+ var req = MockServletRequest.create("GET", "/auth/refresh").cookies(new Cookie[]{ new Cookie(rp.cookieName(), cookie) });
+ var res = MockServletResponse.create();
+ var refreshed = rp.refresh(req, res);
+ assertTrue(refreshed.isEmpty());
+ assertTrue(authn(rp, cookie).isEmpty(), "a rejected refresh must invalidate the session (fail-closed)");
+ }
+
+ @Test void d03_refresh_signedCookieStore_noToken_returnsEmpty() throws Exception {
+ var rp = rp(SignedCookieSessionStore.create().signingKey("0123456789abcdef0123456789abcdef").build());
+ var cookie = login(rp, "alice", "sess-1", null);
+ var req = MockServletRequest.create("GET", "/auth/refresh").cookies(new Cookie[]{ new Cookie(rp.cookieName(), cookie) });
+ assertTrue(rp.refresh(req, MockServletResponse.create()).isEmpty(), "cookie store retains no token, so there is nothing to refresh");
+ }
+
+ //-----------------------------------------------------------------------------------------------------------------
+ // Back-channel logout
+ //-----------------------------------------------------------------------------------------------------------------
+
+ @Test void e01_backChannelLogout_bySid_invalidatesSession() throws Exception {
+ var rp = rp(InMemorySessionStore.create());
+ var cookie = login(rp, "alice", "sess-1", null);
+ assertTrue(authn(rp, cookie).isPresent());
+ var logoutToken = signLogoutToken(key, idp.issuer, CID, null, "sess-1");
+ assertEquals(1, rp.backChannelLogout(logoutToken));
+ assertTrue(authn(rp, cookie).isEmpty());
+ }
+
+ @Test void e02_backChannelLogout_bySub_invalidatesAllSessions() throws Exception {
+ var rp = rp(InMemorySessionStore.create());
+ var cookie1 = login(rp, "alice", "sess-1", null);
+ var cookie2 = login(rp, "alice", "sess-2", null);
+ var logoutToken = signLogoutToken(key, idp.issuer, CID, "alice", null);
+ assertEquals(2, rp.backChannelLogout(logoutToken));
+ assertTrue(authn(rp, cookie1).isEmpty());
+ assertTrue(authn(rp, cookie2).isEmpty());
+ }
+
+ @Test void e03_backChannelLogout_badSignature_isRejected() throws Exception {
+ var rp = rp(InMemorySessionStore.create());
+ login(rp, "alice", "sess-1", null);
+ var attacker = generateRsa("k1");
+ var forged = signLogoutToken(attacker, idp.issuer, CID, null, "sess-1");
+ assertThrows(AuthenticationException.class, () -> rp.backChannelLogout(forged));
+ }
+
+ @Test void e04_backChannelLogout_signedCookieStore_throws() throws Exception {
+ var rp = rp(SignedCookieSessionStore.create().signingKey("0123456789abcdef0123456789abcdef").build());
+ var logoutToken = signLogoutToken(key, idp.issuer, CID, null, "sess-1");
+ assertThrows(IllegalStateException.class, () -> rp.backChannelLogout(logoutToken));
+ }
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/auth/oidc/rp/OidcTestSupport.java b/juneau-utest/src/test/java/org/apache/juneau/rest/auth/oidc/rp/OidcTestSupport.java
new file mode 100644
index 0000000000..afad77af13
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/auth/oidc/rp/OidcTestSupport.java
@@ -0,0 +1,232 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.auth.oidc.rp;
+
+import java.io.*;
+import java.net.*;
+import java.nio.charset.*;
+import java.time.*;
+import java.util.*;
+
+import org.apache.juneau.rest.auth.oauth.oidc.OidcMetadata;
+
+import com.nimbusds.jose.*;
+import com.nimbusds.jose.crypto.*;
+import com.nimbusds.jose.jwk.*;
+import com.nimbusds.jose.jwk.gen.*;
+import com.nimbusds.jwt.*;
+import com.sun.net.httpserver.*;
+
+/**
+ * Test helpers + an in-JVM stub OpenID Connect provider for the {@code OidcRelyingParty} end-to-end
+ * tests. Lives in the test tree (never compiled into production), in {@code juneau-utest} to match the
+ * centralized-tests convention.
+ *
+ * <p>
+ * The stub serves a real {@code /token} (and {@code /userinfo}) endpoint over loopback so the Nimbus
+ * token exchange in {@code OAuthAuthorizationCodeFlow#exchange} performs a genuine HTTP round-trip.
+ * Discovery and JWKS are short-circuited by injecting {@link OidcMetadata} and a public {@link JWKSet}
+ * directly on the relying party (so no second listener is needed and the tests stay deterministic).
+ */
+final class OidcTestSupport {
+
+ static final String EVENT_BACKCHANNEL_LOGOUT = "http://schemas.openid.net/event/backchannel-logout";
+
+ private OidcTestSupport() {}
+
+ /** Generates a fresh RSA-2048 signing key with the given key id. */
+ static RSAKey generateRsa(String kid) throws JOSEException {
+ return new RSAKeyGenerator(2048).keyID(kid).algorithm(JWSAlgorithm.RS256).generate();
+ }
+
+ /** Public-only JWK set for the supplied key(s) (what an IdP would publish at its JWKS endpoint). */
+ static JWKSet publicJwks(JWK...keys) {
+ return new JWKSet(Arrays.asList(keys)).toPublicJWKSet();
+ }
+
+ /** Signs an OIDC ID token (RS256) with the standard claims plus an optional {@code sid}. */
+ static String signIdToken(RSAKey key, String issuer, String clientId, String subject, String sid, String nonce,
+ Instant now, Duration ttl, Map<String,Object> extraClaims) throws JOSEException {
+ var cb = new JWTClaimsSet.Builder()
+ .issuer(issuer)
+ .subject(subject)
+ .audience(clientId)
+ .issueTime(Date.from(now))
+ .expirationTime(Date.from(now.plus(ttl)))
+ .claim("nonce", nonce);
+ if (sid != null)
+ cb.claim("sid", sid);
+ if (extraClaims != null)
+ extraClaims.forEach(cb::claim);
+ return sign(key, JWSAlgorithm.RS256, null, cb.build());
+ }
+
+ /** Signs an OIDC back-channel {@code logout_token} (RS256) targeting a {@code sub} and/or {@code sid}. */
+ static String signLogoutToken(RSAKey key, String issuer, String clientId, String subject, String sid)
+ throws JOSEException {
+ var events = new LinkedHashMap<String,Object>();
+ events.put(EVENT_BACKCHANNEL_LOGOUT, new LinkedHashMap<>());
+ var now = Instant.now();
+ var cb = new JWTClaimsSet.Builder()
+ .issuer(issuer)
+ .audience(clientId)
+ .issueTime(Date.from(now))
+ .expirationTime(Date.from(now.plus(Duration.ofMinutes(2))))
+ .jwtID(UUID.randomUUID().toString())
+ .claim("events", events);
+ if (subject != null)
+ cb.subject(subject);
+ if (sid != null)
+ cb.claim("sid", sid);
+ return sign(key, JWSAlgorithm.RS256, new JOSEObjectType("logout+jwt"), cb.build());
+ }
+
+ private static String sign(RSAKey key, JWSAlgorithm alg, JOSEObjectType typ, JWTClaimsSet claims)
+ throws JOSEException {
+ var hb = new JWSHeader.Builder(alg).keyID(key.getKeyID());
+ if (typ != null)
+ hb.type(typ);
+ var jwt = new SignedJWT(hb.build(), claims);
+ jwt.sign(new RSASSASigner(key));
+ return jwt.serialize();
+ }
+
+ /** Extracts a single query parameter value from a URL string (URL-decoded). */
+ static String queryParam(String url, String name) {
+ var q = URI.create(url).getQuery();
+ if (q == null)
+ return null;
+ for (var pair : q.split("&")) {
+ var i = pair.indexOf('=');
+ var k = i < 0 ? pair : pair.substring(0, i);
+ if (k.equals(name))
+ return i < 0 ? "" : URLDecoder.decode(pair.substring(i + 1), StandardCharsets.UTF_8);
+ }
+ return null;
+ }
+
+ /** Parses the cookie value out of a {@code Set-Cookie} header (the bit between {@code =} and the first {@code ;}). */
+ static String cookieValue(String setCookieHeader) {
+ if (setCookieHeader == null)
+ return null;
+ var eq = setCookieHeader.indexOf('=');
+ var semi = setCookieHeader.indexOf(';');
+ return setCookieHeader.substring(eq + 1, semi < 0 ? setCookieHeader.length() : semi);
+ }
+
+ /**
+ * In-JVM stub IdP exposing a {@code /token} and {@code /userinfo} endpoint over loopback.
+ *
+ * <p>
+ * The {@code /token} response is driven by mutable fields the test sets between flow steps (the ID
+ * token must echo the nonce the relying party generated, which is only known after {@code startLogin}).
+ */
+ static final class StubIdp implements AutoCloseable {
+
+ private final HttpServer server;
+ final String issuer = "https://stub-idp.example.com";
+
+ /** The ID token returned by the next {@code /token} call. */
+ volatile String idToken;
+ /** The refresh token returned by an authorization-code {@code /token} call. */
+ volatile String refreshToken = "rt-1";
+ /** The refresh token returned by a refresh-grant {@code /token} call (rotation). */
+ volatile String rotatedRefreshToken = "rt-2";
+ /** When true, the {@code /token} endpoint returns an error (simulates revoked / reused refresh token). */
+ volatile boolean failToken;
+ /** Extra claims served by {@code /userinfo}. */
+ volatile Map<String,Object> userInfo = new LinkedHashMap<>();
+
+ StubIdp() throws IOException {
+ server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
+ server.createContext("/token", this::handleToken);
+ server.createContext("/userinfo", this::handleUserInfo);
+ server.start();
+ }
+
+ private void handleToken(HttpExchange ex) throws IOException {
+ var body = new String(ex.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
+ if (failToken) {
+ writeJson(ex, 400, "{\"error\":\"invalid_grant\"}");
+ return;
+ }
+ var isRefresh = body.contains("grant_type=refresh_token");
+ var rt = isRefresh ? rotatedRefreshToken : refreshToken;
+ var json = new StringBuilder("{")
+ .append("\"access_token\":\"at-").append(isRefresh ? "refreshed" : "initial").append("\",")
+ .append("\"token_type\":\"Bearer\",")
+ .append("\"expires_in\":3600,");
+ if (rt != null)
+ json.append("\"refresh_token\":\"").append(rt).append("\",");
+ if (idToken != null)
+ json.append("\"id_token\":\"").append(idToken).append("\",");
+ json.append("\"scope\":\"openid profile\"}");
+ writeJson(ex, 200, json.toString());
+ }
+
+ private void handleUserInfo(HttpExchange ex) throws IOException {
+ var sb = new StringBuilder("{\"sub\":\"alice\"");
+ userInfo.forEach((k, v) -> sb.append(",\"").append(k).append("\":\"").append(v).append("\""));
+ sb.append("}");
+ writeJson(ex, 200, sb.toString());
+ }
+
+ private static void writeJson(HttpExchange ex, int status, String body) throws IOException {
+ var bytes = body.getBytes(StandardCharsets.UTF_8);
+ ex.getResponseHeaders().add("Content-Type", "application/json");
+ ex.sendResponseHeaders(status, bytes.length);
+ try (var os = ex.getResponseBody()) {
+ os.write(bytes);
+ }
+ }
+
+ int port() {
+ return server.getAddress().getPort();
+ }
+
+ URI baseUri() {
+ return URI.create("http://127.0.0.1:" + port());
+ }
+
+ URI tokenEndpoint() {
+ return URI.create(baseUri() + "/token");
+ }
+
+ URI userinfoEndpoint() {
+ return URI.create(baseUri() + "/userinfo");
+ }
+
+ /** Builds an {@link OidcMetadata} record pointing the token/userinfo endpoints at this stub. */
+ OidcMetadata metadata(URI authorizationEndpoint, URI endSessionEndpoint) {
+ return new OidcMetadata(
+ URI.create(issuer),
+ tokenEndpoint(),
+ authorizationEndpoint,
+ null,
+ URI.create(baseUri() + "/jwks"),
+ userinfoEndpoint(),
+ endSessionEndpoint,
+ Set.of("openid", "profile", "email"),
+ Map.of());
+ }
+
+ @Override
+ public void close() {
+ server.stop(0);
+ }
+ }
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/auth/oidc/rp/SignedCookieSessionStore_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/auth/oidc/rp/SignedCookieSessionStore_Test.java
new file mode 100644
index 0000000000..c539bb26cc
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/auth/oidc/rp/SignedCookieSessionStore_Test.java
@@ -0,0 +1,107 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.auth.oidc.rp;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.time.*;
+import java.util.*;
+
+import org.apache.juneau.TestBase;
+import org.apache.juneau.rest.auth.ClaimsPrincipal;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link SignedCookieSessionStore} — stateless HMAC-signed cookie round-trip, tamper
+ * rejection, expiry, and the size cap.
+ *
+ * @since 9.5.0
+ */
+class SignedCookieSessionStore_Test extends TestBase {
+
+ private static final String KEY = "0123456789abcdef0123456789abcdef"; // 32 bytes
+
+ private static OidcSession session(Instant now, String sid) {
+ return new OidcSession("id-1", "alice", Optional.ofNullable(sid),
+ new ClaimsPrincipal("alice", Map.of("sub", "alice", "email", "alice@example.com")),
+ new LinkedHashSet<>(List.of("user", "admin")),
+ Optional.empty(), now, now.plus(Duration.ofHours(8)));
+ }
+
+ @Test void a01_roundTrip_recoversClaimsAndRoles() {
+ var store = SignedCookieSessionStore.create().signingKey(KEY).build();
+ var now = Instant.now();
+ var cookie = store.createSessionCookieValue(session(now, "sess-1"));
+ var s = store.lookup(cookie);
+ assertTrue(s.isPresent());
+ assertEquals("alice", s.get().subject());
+ assertEquals("alice", s.get().principal().getName());
+ assertEquals(Set.of("user", "admin"), s.get().roles());
+ assertEquals("sess-1", s.get().sid().orElse(null));
+ assertEquals("alice@example.com", s.get().principal().getClaim("email", String.class).orElse(null));
+ assertTrue(s.get().token().isEmpty(), "cookie store must NOT retain raw tokens");
+ }
+
+ @Test void b01_tamperedCookie_isRejected() {
+ var store = SignedCookieSessionStore.create().signingKey(KEY).build();
+ var cookie = store.createSessionCookieValue(session(Instant.now(), null));
+ var tampered = cookie.substring(0, cookie.length() - 3) + "AAA";
+ assertTrue(store.lookup(tampered).isEmpty());
+ }
+
+ @Test void b02_wrongKey_isRejected() {
+ var signer = SignedCookieSessionStore.create().signingKey(KEY).build();
+ var cookie = signer.createSessionCookieValue(session(Instant.now(), null));
+ var other = SignedCookieSessionStore.create().signingKey("ffffffffffffffffffffffffffffffff").build();
+ assertTrue(other.lookup(cookie).isEmpty());
+ }
+
+ @Test void b03_garbageCookie_isRejected() {
+ var store = SignedCookieSessionStore.create().signingKey(KEY).build();
+ assertTrue(store.lookup("not-a-jwt").isEmpty());
+ }
+
+ @Test void c01_expiredCookie_isRejected() {
+ var base = Instant.parse("2026-01-01T00:00:00Z");
+ var clock = new EphemeralStore_Test.MutableClock(base);
+ var store = SignedCookieSessionStore.create().signingKey(KEY).clock(clock).build();
+ var cookie = store.createSessionCookieValue(session(base, null));
+ clock.advance(Duration.ofHours(9));
+ assertTrue(store.lookup(cookie).isEmpty());
+ }
+
+ @Test void d01_doesNotSupportServerSideRevocation() {
+ var store = SignedCookieSessionStore.create().signingKey(KEY).build();
+ assertFalse(store.supportsServerSideRevocation());
+ assertThrows(UnsupportedOperationException.class, () -> store.invalidateBySubject("alice"));
+ assertThrows(UnsupportedOperationException.class, () -> store.invalidateBySessionId("sess-1"));
+ }
+
+ @Test void e01_overCapPayload_throws() {
+ var store = SignedCookieSessionStore.create().signingKey(KEY).maxCookieBytes(128).build();
+ assertThrows(IllegalStateException.class,
+ () -> store.createSessionCookieValue(session(Instant.now(), "sess-1")));
+ }
+
+ @Test void f01_shortKey_rejected() {
+ assertThrows(IllegalArgumentException.class, () -> SignedCookieSessionStore.create().signingKey("too-short"));
+ }
+
+ @Test void f02_missingKey_rejected() {
+ assertThrows(IllegalStateException.class, () -> SignedCookieSessionStore.create().build());
+ }
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/metrics/MetricsRecorder_Contract_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/metrics/MetricsRecorder_Contract_Test.java
index 113853628f..fd48ab2401 100644
--- a/juneau-utest/src/test/java/org/apache/juneau/rest/metrics/MetricsRecorder_Contract_Test.java
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/metrics/MetricsRecorder_Contract_Test.java
@@ -41,7 +41,7 @@ class MetricsRecorder_Contract_Test extends TestBase {
public final List<Event> events = new CopyOnWriteArrayList<>();
@Override
- public void record(String opName, String httpMethod, String uriTemplate, int statusCode, Duration elapsed, Throwable error) {
+ public void record(String opName, String httpMethod, String uriTemplate, int statusCode, Duration elapsed, Throwable error, String metricName, String metricTags) {
events.add(new Event(opName, httpMethod, uriTemplate, statusCode, elapsed, error));
}
@@ -175,7 +175,7 @@ class MetricsRecorder_Contract_Test extends TestBase {
}
@Test void d02_noOpRecorder_recordIsNoOp() {
- assertDoesNotThrow(() -> NoOpMetricsRecorder.INSTANCE.record("op", "GET", "/x", 200, Duration.ofMillis(1), null));
- assertDoesNotThrow(() -> NoOpMetricsRecorder.INSTANCE.record(null, null, null, 0, Duration.ZERO, new RuntimeException()));
+ assertDoesNotThrow(() -> NoOpMetricsRecorder.INSTANCE.record("op", "GET", "/x", 200, Duration.ofMillis(1), null, "", ""));
+ assertDoesNotThrow(() -> NoOpMetricsRecorder.INSTANCE.record(null, null, null, 0, Duration.ZERO, new RuntimeException(), "", ""));
}
}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/metrics/micrometer/MicrometerMetricsRecorder_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/metrics/micrometer/MicrometerMetricsRecorder_Test.java
index c3479fb652..9238a07b18 100644
--- a/juneau-utest/src/test/java/org/apache/juneau/rest/metrics/micrometer/MicrometerMetricsRecorder_Test.java
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/metrics/micrometer/MicrometerMetricsRecorder_Test.java
@@ -69,7 +69,7 @@ class MicrometerMetricsRecorder_Test extends TestBase {
@Test void a05_happyPath_registersTimerWithExpectedTags() {
var registry = new SimpleMeterRegistry();
var r = new MicrometerMetricsRecorder(registry);
- r.record("MyResource.get(java.lang.String)", "GET", "/users/{id}", 200, Duration.ofMillis(7), null);
+ r.record("MyResource.get(java.lang.String)", "GET", "/users/{id}", 200, Duration.ofMillis(7), null, "", "");
var timer = registry.find("http.server.requests")
.tag("method", "GET")
@@ -85,7 +85,7 @@ class MicrometerMetricsRecorder_Test extends TestBase {
@Test void a06_exceptionPath_recordsExceptionSimpleNameTag() {
var registry = new SimpleMeterRegistry();
var r = new MicrometerMetricsRecorder(registry);
- r.record("op", "POST", "/orders", 500, Duration.ofMillis(3), new IllegalStateException());
+ r.record("op", "POST", "/orders", 500, Duration.ofMillis(3), new IllegalStateException(), "", "");
var timer = registry.find("http.server.requests")
.tag("exception", "IllegalStateException")
@@ -97,8 +97,8 @@ class MicrometerMetricsRecorder_Test extends TestBase {
@Test void a07_separateTagSets_produceSeparateTimers() {
var registry = new SimpleMeterRegistry();
var r = new MicrometerMetricsRecorder(registry);
- r.record("op", "GET", "/a", 200, Duration.ofMillis(1), null);
- r.record("op", "GET", "/b", 200, Duration.ofMillis(2), null);
+ r.record("op", "GET", "/a", 200, Duration.ofMillis(1), null, "", "");
+ r.record("op", "GET", "/b", 200, Duration.ofMillis(2), null, "", "");
assertEquals(1, registry.find("http.server.requests").tag("uri", "/a").timer().count());
assertEquals(1, registry.find("http.server.requests").tag("uri", "/b").timer().count());
@@ -107,7 +107,7 @@ class MicrometerMetricsRecorder_Test extends TestBase {
@Test void a08_blankUri_emitsEmptyStringTag() {
var registry = new SimpleMeterRegistry();
var r = new MicrometerMetricsRecorder(registry);
- r.record("op", "GET", "", 200, Duration.ofMillis(1), null);
+ r.record("op", "GET", "", 200, Duration.ofMillis(1), null, "", "");
assertNotNull(registry.find("http.server.requests").tag("uri", "").timer());
}
@@ -168,7 +168,7 @@ class MicrometerMetricsRecorder_Test extends TestBase {
@Test void c01_prometheusScrapeContainsExpectedSample() {
var registry = new PrometheusMeterRegistry(PrometheusConfig.DEFAULT, new PrometheusRegistry(), io.micrometer.core.instrument.Clock.SYSTEM);
var r = new MicrometerMetricsRecorder(registry);
- r.record("op", "GET", "/users/{id}", 200, Duration.ofMillis(5), null);
+ r.record("op", "GET", "/users/{id}", 200, Duration.ofMillis(5), null, "", "");
String scrape = registry.scrape();
assertTrue(scrape.contains("http_server_requests_seconds_count"), "scrape output: " + scrape);
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/observability/RestObservabilityAttributes_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/observability/RestObservabilityAttributes_Test.java
new file mode 100644
index 0000000000..087cdbc3e7
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/observability/RestObservabilityAttributes_Test.java
@@ -0,0 +1,251 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.observability;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.time.*;
+import java.util.*;
+import java.util.concurrent.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.metrics.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests for TODO-115: per-resource / per-op observability opt-in attributes.
+ *
+ * <ul>
+ * <li>A — {@code @Rest(observability="false")} suppresses all metrics for the resource.
+ * <li>B — {@code @RestGet(observability="false")} suppresses metrics for that op only.
+ * <li>C — {@code @RestGet(observability="true")} on a resource that has no explicit resource-level setting
+ * still fires metrics when a backend is wired.
+ * <li>D — {@code @Rest(observability="false")} resource + {@code @RestGet(observability="true")} op override
+ * re-enables observability for that specific op.
+ * <li>E — Startup-fail when {@code @Rest(observability="true")} is set but no backend is wired.
+ * <li>F — {@code @RestGet(metricName)} and {@code @RestGet(metricTags)} reach {@code record(...)}.
+ * </ul>
+ */
+class RestObservabilityAttributes_Test extends TestBase {
+
+ /** Recording MetricsRecorder that captures every event including metricName/metricTags. */
+ public static final class RecordingMetricsRecorder implements MetricsRecorder {
+ public final List<Event> events = new CopyOnWriteArrayList<>();
+
+ @Override
+ public void record(String opName, String httpMethod, String uriTemplate, int statusCode, Duration elapsed, Throwable error, String metricName, String metricTags) {
+ events.add(new Event(opName, httpMethod, uriTemplate, statusCode, elapsed, error, metricName, metricTags));
+ }
+
+ public Event last() { return events.get(events.size() - 1); }
+ public void clear() { events.clear(); }
+ }
+
+ public static final class Event {
+ public final String opName, httpMethod, uriTemplate, metricName, metricTags;
+ public final int statusCode;
+ public final Duration elapsed;
+ public final Throwable error;
+
+ Event(String opName, String httpMethod, String uriTemplate, int statusCode, Duration elapsed, Throwable error, String metricName, String metricTags) {
+ this.opName = opName; this.httpMethod = httpMethod; this.uriTemplate = uriTemplate;
+ this.statusCode = statusCode; this.elapsed = elapsed; this.error = error;
+ this.metricName = metricName; this.metricTags = metricTags;
+ }
+ }
+
+ // -----------------------------------------------------------------------------------------------------------------
+ // A: @Rest(observability="false") — metrics are suppressed for the entire resource.
+ // -----------------------------------------------------------------------------------------------------------------
+
+ private static final RecordingMetricsRecorder A_REC = new RecordingMetricsRecorder();
+
+ @Rest(observability = "false")
+ public static class A extends RestServlet {
+ private static final long serialVersionUID = 1L;
+
+ @org.apache.juneau.commons.inject.Bean
+ public MetricsRecorder recorder() { return A_REC; }
+
+ @RestGet("/ping")
+ public String ping() { return "pong"; }
+ }
+
+ private static final MockRestClient CA = MockRestClient.buildLax(A.class);
+
+ @Test void a01_resourceLevelOptOut_suppressesMetrics() throws Exception {
+ A_REC.clear();
+ CA.get("/ping").run().assertStatus(200);
+ assertEquals(0, A_REC.events.size(), "@Rest(observability='false') must suppress the observability block");
+ }
+
+ // -----------------------------------------------------------------------------------------------------------------
+ // B: @RestGet(observability="false") — metrics are suppressed for that specific op only.
+ // Other ops on the same resource remain observable.
+ // -----------------------------------------------------------------------------------------------------------------
+
+ private static final RecordingMetricsRecorder B_REC = new RecordingMetricsRecorder();
+
+ @Rest
+ public static class B extends RestServlet {
+ private static final long serialVersionUID = 1L;
+
+ @org.apache.juneau.commons.inject.Bean
+ public MetricsRecorder recorder() { return B_REC; }
+
+ @RestGet(path = "/silent", observability = "false")
+ public String silent() { return "no-metrics"; }
+
+ @RestGet("/loud")
+ public String loud() { return "metrics-fire"; }
+ }
+
+ private static final MockRestClient CB = MockRestClient.buildLax(B.class);
+
+ @Test void b01_opLevelOptOut_suppressesMetricsForThatOp() throws Exception {
+ B_REC.clear();
+ CB.get("/silent").run().assertStatus(200);
+ assertEquals(0, B_REC.events.size(), "@RestGet(observability='false') must suppress that op's metrics");
+ }
+
+ @Test void b02_opLevelOptOut_doesNotAffectSiblingOps() throws Exception {
+ B_REC.clear();
+ CB.get("/loud").run().assertStatus(200);
+ assertEquals(1, B_REC.events.size(), "sibling op without observability opt-out must still record metrics");
+ }
+
+ // -----------------------------------------------------------------------------------------------------------------
+ // C: Default behavior (no observability attribute set) — metrics fire when a backend is wired.
+ // -----------------------------------------------------------------------------------------------------------------
+
+ private static final RecordingMetricsRecorder C_REC = new RecordingMetricsRecorder();
+
+ @Rest
+ public static class C extends RestServlet {
+ private static final long serialVersionUID = 1L;
+
+ @org.apache.juneau.commons.inject.Bean
+ public MetricsRecorder recorder() { return C_REC; }
+
+ @RestGet("/check")
+ public String check() { return "ok"; }
+ }
+
+ private static final MockRestClient CC = MockRestClient.buildLax(C.class);
+
+ @Test void c01_defaultBehavior_metricsFireWhenBackendWired() throws Exception {
+ C_REC.clear();
+ CC.get("/check").run().assertStatus(200);
+ assertEquals(1, C_REC.events.size(), "default (no observability attribute) must fire metrics when backend wired");
+ }
+
+ // -----------------------------------------------------------------------------------------------------------------
+ // D: @Rest(observability="false") resource + @RestGet(observability="true") op override.
+ // The op-level "true" re-enables observability for that specific op.
+ // -----------------------------------------------------------------------------------------------------------------
+
+ private static final RecordingMetricsRecorder D_REC = new RecordingMetricsRecorder();
+
+ @Rest(observability = "false")
+ public static class D extends RestServlet {
+ private static final long serialVersionUID = 1L;
+
+ @org.apache.juneau.commons.inject.Bean
+ public MetricsRecorder recorder() { return D_REC; }
+
+ @RestGet(path = "/silent", observability = "false")
+ public String silent() { return "still-silent"; }
+
+ @RestGet(path = "/loud", observability = "true")
+ public String loud() { return "re-enabled"; }
+ }
+
+ private static final MockRestClient CD = MockRestClient.buildLax(D.class);
+
+ @Test void d01_opLevelOptIn_overridesResourceLevelOptOut() throws Exception {
+ D_REC.clear();
+ CD.get("/loud").run().assertStatus(200);
+ assertEquals(1, D_REC.events.size(), "@RestGet(observability='true') must re-enable metrics even when resource says false");
+ }
+
+ @Test void d02_opLevelOptOut_still_suppressesWhenResourceAlreadySilent() throws Exception {
+ D_REC.clear();
+ CD.get("/silent").run().assertStatus(200);
+ assertEquals(0, D_REC.events.size(), "@RestGet(observability='false') on resource with observability='false' must suppress");
+ }
+
+ // -----------------------------------------------------------------------------------------------------------------
+ // E: Startup-fail — @Rest(observability="true") but no backend wired must throw during context build.
+ // -----------------------------------------------------------------------------------------------------------------
+
+ @Test void e01_startupFail_whenObservabilityTrueAndNoBackend() {
+ @Rest(observability = "true")
+ class EResource extends RestServlet {
+ private static final long serialVersionUID = 1L;
+ @RestGet("/x") public String x() { return "x"; }
+ }
+ assertThrows(Exception.class, () -> MockRestClient.buildLax(EResource.class),
+ "@Rest(observability='true') with no MetricsRecorder or TracerHook must fail at build time");
+ }
+
+ // -----------------------------------------------------------------------------------------------------------------
+ // F: metricName and metricTags are wired through to record().
+ // -----------------------------------------------------------------------------------------------------------------
+
+ private static final RecordingMetricsRecorder F_REC = new RecordingMetricsRecorder();
+
+ @Rest
+ public static class F extends RestServlet {
+ private static final long serialVersionUID = 1L;
+
+ @org.apache.juneau.commons.inject.Bean
+ public MetricsRecorder recorder() { return F_REC; }
+
+ @RestGet(path = "/tagged", metricName = "custom.timer", metricTags = "team=payments,region=us-east")
+ public String tagged() { return "tagged"; }
+
+ @RestGet("/plain")
+ public String plain() { return "plain"; }
+ }
+
+ private static final MockRestClient CF = MockRestClient.buildLax(F.class);
+
+ @Test void f01_metricName_reachesRecord() throws Exception {
+ F_REC.clear();
+ CF.get("/tagged").run().assertStatus(200);
+ assertEquals(1, F_REC.events.size());
+ assertEquals("custom.timer", F_REC.last().metricName, "metricName must reach MetricsRecorder.record()");
+ }
+
+ @Test void f02_metricTags_reachesRecord() throws Exception {
+ F_REC.clear();
+ CF.get("/tagged").run().assertStatus(200);
+ assertEquals(1, F_REC.events.size());
+ assertEquals("team=payments,region=us-east", F_REC.last().metricTags, "metricTags must reach MetricsRecorder.record()");
+ }
+
+ @Test void f03_plainOp_emptyMetricNameAndTags() throws Exception {
+ F_REC.clear();
+ CF.get("/plain").run().assertStatus(200);
+ assertEquals(1, F_REC.events.size());
+ assertEquals("", F_REC.last().metricName, "metricName must be empty when not set");
+ assertEquals("", F_REC.last().metricTags, "metricTags must be empty when not set");
+ }
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/observability/RestObservabilitySvl_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/observability/RestObservabilitySvl_Test.java
new file mode 100644
index 0000000000..0b010ccdf8
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/observability/RestObservabilitySvl_Test.java
@@ -0,0 +1,128 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.observability;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.time.*;
+import java.util.*;
+import java.util.concurrent.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.metrics.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.servlet.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests SVL variable support in observability annotation attributes.
+ *
+ * <p>
+ * The resource-level {@code @Rest(observability)} attribute is resolved at {@code RestContext} build time
+ * (the constructor pre-warms the memoizer via the startup-fail check). Op-level attributes
+ * ({@code metricName}, {@code metricTags}) are resolved lazily on first request.
+ *
+ * <p>
+ * Note: {@code MockRestClient} caches the {@link org.apache.juneau.rest.RestContext} per resource class.
+ * Each scenario uses a distinct static inner class so the SVL state at build time is deterministic.
+ */
+class RestObservabilitySvl_Test extends TestBase {
+
+ public static final class RecordingMetricsRecorder implements MetricsRecorder {
+ public final List<Event> events = new CopyOnWriteArrayList<>();
+
+ @Override
+ public void record(String opName, String httpMethod, String uriTemplate, int statusCode, Duration elapsed, Throwable error, String metricName, String metricTags) {
+ events.add(new Event(metricName, metricTags));
+ }
+
+ public Event last() { return events.get(events.size() - 1); }
+ public void clear() { events.clear(); }
+ }
+
+ public static final class Event {
+ public final String metricName, metricTags;
+ Event(String metricName, String metricTags) {
+ this.metricName = metricName; this.metricTags = metricTags;
+ }
+ }
+
+ // -----------------------------------------------------------------------------------------------------------------
+ // A: metricName and metricTags with literal values flow correctly to record().
+ // -----------------------------------------------------------------------------------------------------------------
+
+ private static final RecordingMetricsRecorder A_REC = new RecordingMetricsRecorder();
+
+ @Rest
+ public static class A extends RestServlet {
+ private static final long serialVersionUID = 1L;
+
+ @org.apache.juneau.commons.inject.Bean
+ public MetricsRecorder recorder() { return A_REC; }
+
+ @RestGet(path = "/op", metricName = "my.service.requests", metricTags = "svc=orders")
+ public String op() { return "ok"; }
+ }
+
+ private static final MockRestClient CA = MockRestClient.buildLax(A.class);
+
+ @Test void a01_literalMetricNameAndTags_resolvedCorrectly() throws Exception {
+ A_REC.clear();
+ CA.get("/op").run().assertStatus(200);
+ assertEquals("my.service.requests", A_REC.last().metricName,
+ "literal metricName must reach MetricsRecorder.record() unchanged");
+ assertEquals("svc=orders", A_REC.last().metricTags,
+ "literal metricTags must reach MetricsRecorder.record() unchanged");
+ }
+
+ // -----------------------------------------------------------------------------------------------------------------
+ // B: SVL variable in @Rest(observability) is resolved at RestContext build time.
+ // When the system property resolves to "false", metrics are suppressed for the entire resource.
+ // The static initializer sets the property before building the client and then clears it;
+ // this works because the resource-level observability attribute IS resolved eagerly (the
+ // startup-fail check pre-warms it inside the RestContext constructor).
+ // -----------------------------------------------------------------------------------------------------------------
+
+ private static final RecordingMetricsRecorder B_REC = new RecordingMetricsRecorder();
+
+ @Rest(observability = "$S{test.svl.obs.b,}")
+ public static class B extends RestServlet {
+ private static final long serialVersionUID = 1L;
+
+ @org.apache.juneau.commons.inject.Bean
+ public MetricsRecorder recorder() { return B_REC; }
+
+ @RestGet("/ping")
+ public String ping() { return "pong"; }
+ }
+
+ // Build the client while the property is "false" so the context captures observability=false.
+ private static final MockRestClient CB;
+ static {
+ System.setProperty("test.svl.obs.b", "false");
+ CB = MockRestClient.buildLax(B.class);
+ System.clearProperty("test.svl.obs.b");
+ }
+
+ @Test void b01_svlObservability_resolvedAtBuildTime_suppressesMetrics() throws Exception {
+ B_REC.clear();
+ CB.get("/ping").run().assertStatus(200);
+ assertEquals(0, B_REC.events.size(),
+ "$S{} SVL variable resolving to 'false' at context build time must suppress observability block");
+ }
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/observability/TraceContextResponseProcessor_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/observability/TraceContextResponseProcessor_Test.java
new file mode 100644
index 0000000000..a0bc0b23ba
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/observability/TraceContextResponseProcessor_Test.java
@@ -0,0 +1,202 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.observability;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.apache.juneau.commons.inject.*;
+import org.apache.juneau.commons.settings.*;
+import org.apache.juneau.http.annotation.*;
+import org.apache.juneau.rest.*;
+import org.apache.juneau.rest.annotation.*;
+import org.apache.juneau.rest.httppart.*;
+import org.apache.juneau.rest.mock.classic.*;
+import org.apache.juneau.rest.processor.*;
+import org.apache.juneau.rest.servlet.*;
+import org.apache.juneau.rest.tracing.*;
+import org.apache.juneau.rest.tracing.otel.*;
+import org.junit.jupiter.api.*;
+
+import io.opentelemetry.api.trace.propagation.*;
+import io.opentelemetry.context.propagation.*;
+import io.opentelemetry.sdk.*;
+import io.opentelemetry.sdk.testing.exporter.*;
+import io.opentelemetry.sdk.trace.*;
+import io.opentelemetry.sdk.trace.export.*;
+
+/**
+ * Tests for {@link TraceContextResponseProcessor} (TODO-114) — outgoing-response W3C trace-context
+ * header injection.
+ *
+ * <p>
+ * Reuses FINISHED-67's OpenTelemetry SDK + {@link InMemorySpanExporter} test-fixture pattern: a
+ * statically-wired SDK is installed before the {@code @Rest} resources build their
+ * {@link MockRestClient}s so the {@link OtelTracerHook} bridge captures the server-started span's
+ * context and stashes the rendered headers for the response processor to write.
+ */
+class TraceContextResponseProcessor_Test extends TestBase {
+
+ /** W3C {@code traceparent} value format: {@code version-traceId-spanId-flags}. */
+ private static final String W3C_TRACEPARENT_REGEX = "[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}";
+
+ // Static init so the SDK is wired before MockRestClient.buildLax() in the @Rest classes runs.
+ static final InMemorySpanExporter EXPORTER = InMemorySpanExporter.create();
+ static final OpenTelemetrySdk OTEL_SDK = OpenTelemetrySdk.builder()
+ .setTracerProvider(SdkTracerProvider.builder()
+ .addSpanProcessor(SimpleSpanProcessor.create(EXPORTER))
+ .build())
+ .setPropagators(ContextPropagators.create(W3CTraceContextPropagator.getInstance()))
+ .build();
+
+ @BeforeEach
+ void resetExporter() {
+ EXPORTER.reset();
+ }
+
+ @AfterEach
+ void cleanup() {
+ System.clearProperty("RestContext.responseTraceparent");
+ Settings.get().unsetGlobal("RestContext.responseTraceparent");
+ }
+
+ // -----------------------------------------------------------------------------------------------------------------
+ // A: Happy path — active tracer ⇒ traceparent header present and well-formed.
+ // -----------------------------------------------------------------------------------------------------------------
+
+ @Rest
+ public static class A extends RestServlet {
+ private static final long serialVersionUID = 1L;
+
+ @Bean
+ public TracerHook tracer() { return new OtelTracerHook(OTEL_SDK); }
+
+ @RestGet("/users/{id}")
+ public String get(@Path String id) { return "u:" + id; }
+ }
+
+ private static final MockRestClient CA = MockRestClient.buildLax(A.class);
+
+ @Test void a01_happyPath_traceparentHeaderPresent() throws Exception {
+ CA.get("/users/42").run()
+ .assertStatus(200)
+ .assertHeader("traceparent").isExists();
+ }
+
+ @Test void a02_headerFormat_matchesW3cRegex() throws Exception {
+ CA.get("/users/99").run()
+ .assertStatus(200)
+ .assertHeader("traceparent").isPattern(W3C_TRACEPARENT_REGEX);
+ }
+
+ // -----------------------------------------------------------------------------------------------------------------
+ // B: NoOp tracer — no @Bean TracerHook ⇒ no traceparent header.
+ // -----------------------------------------------------------------------------------------------------------------
+
+ @Rest
+ public static class B extends RestServlet {
+ private static final long serialVersionUID = 1L;
+
+ @RestGet("/ping")
+ public String ping() { return "pong"; }
+ }
+
+ private static final MockRestClient CB = MockRestClient.buildLax(B.class);
+
+ @Test void b01_noTracer_noTraceparentHeader() throws Exception {
+ CB.get("/ping").run()
+ .assertStatus(200)
+ .assertHeader("traceparent").isNull();
+ }
+
+ // -----------------------------------------------------------------------------------------------------------------
+ // C: Opt-out — RestContext.responseTraceparent=false keeps the processor unregistered even with a tracer.
+ // -----------------------------------------------------------------------------------------------------------------
+
+ @Rest
+ public static class C extends RestServlet {
+ private static final long serialVersionUID = 1L;
+
+ @Bean
+ public TracerHook tracer() { return new OtelTracerHook(OTEL_SDK); }
+
+ @RestGet("/opt")
+ public String opt() { return "x"; }
+ }
+
+ @Test void c01_optOut_noTraceparentHeaderDespiteActiveTracer() throws Exception {
+ // Set BEFORE building the resource so the @Value-driven default is resolved as false at init.
+ System.setProperty("RestContext.responseTraceparent", "false");
+ var c = MockRestClient.buildLax(C.class);
+ c.get("/opt").run()
+ .assertStatus(200)
+ .assertHeader("traceparent").isNull();
+ }
+
+ // -----------------------------------------------------------------------------------------------------------------
+ // D: tracestate companion header — emitted only when the active trace state is non-empty.
+ // -----------------------------------------------------------------------------------------------------------------
+
+ @Test void d01_incomingTracestate_emittedOnResponse() throws Exception {
+ var traceparent = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01";
+ CA.get("/users/7")
+ .header("traceparent", traceparent)
+ .header("tracestate", "foo=bar")
+ .run()
+ .assertStatus(200)
+ .assertHeader("traceparent").isPattern(W3C_TRACEPARENT_REGEX)
+ .assertHeader("tracestate").is("foo=bar");
+ }
+
+ @Test void d02_emptyTracestate_headerAbsent() throws Exception {
+ // No incoming tracestate ⇒ server span carries an empty trace state ⇒ no tracestate header written.
+ CA.get("/users/8").run()
+ .assertStatus(200)
+ .assertHeader("traceparent").isExists()
+ .assertHeader("tracestate").isNull();
+ }
+
+ // -----------------------------------------------------------------------------------------------------------------
+ // F: Response-already-committed edge — processor skips without throwing (MockServletResponse.isCommitted() is
+ // hard-wired false, so this branch is exercised with mocks).
+ // -----------------------------------------------------------------------------------------------------------------
+
+ @Test void f01_responseCommitted_skipsWithoutThrowing() {
+ var req = mock(RestRequest.class);
+ var res = mock(RestResponse.class);
+ var hsr = mock(jakarta.servlet.http.HttpServletResponse.class);
+ var opSession = mock(RestOpSession.class);
+
+ var tpAttr = mock(RequestAttribute.class);
+ when(tpAttr.as(String.class)).thenReturn(Optional.of("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"));
+ when(req.getAttribute(TraceContextResponseProcessor.ATTR_TRACEPARENT)).thenReturn(tpAttr);
+
+ when(opSession.getRequest()).thenReturn(req);
+ when(opSession.getResponse()).thenReturn(res);
+ when(res.getHttpServletResponse()).thenReturn(hsr);
+ when(hsr.isCommitted()).thenReturn(true);
+
+ var p = new TraceContextResponseProcessor();
+ var rc = assertDoesNotThrow(() -> p.process(opSession));
+ assertEquals(ResponseProcessor.NEXT, rc);
+ // Committed ⇒ no header write attempted.
+ verify(res, never()).setHeader(eq("traceparent"), anyString());
+ }
+}
diff --git a/juneau-utest/src/test/java/org/apache/juneau/rest/processor/MdcAsyncListener_Test.java b/juneau-utest/src/test/java/org/apache/juneau/rest/processor/MdcAsyncListener_Test.java
new file mode 100644
index 0000000000..0f2d6c350a
--- /dev/null
+++ b/juneau-utest/src/test/java/org/apache/juneau/rest/processor/MdcAsyncListener_Test.java
@@ -0,0 +1,217 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.rest.processor;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+import java.util.concurrent.atomic.*;
+import java.util.function.*;
+
+import org.apache.juneau.TestBase;
+import org.junit.jupiter.api.*;
+import org.slf4j.*;
+
+/**
+ * Tests for {@link MdcAsyncListener} — the SLF4J MDC bridge that propagates request-thread MDC to
+ * {@link CompletableFuture} completion threads (TODO-117).
+ *
+ * <p>
+ * All direct unit tests exercise {@link MdcAsyncListener} in isolation via its static factory methods.
+ * The integration tests verify the builder-knob opt-out path through the {@link AsyncResponseProcessor}
+ * stack using the {@code MockRestClient} harness.
+ */
+@TestMethodOrder(MethodOrderer.MethodName.class)
+class MdcAsyncListener_Test extends TestBase {
+
+ @AfterEach
+ void clearMdc() {
+ MDC.clear();
+ }
+
+ // -----------------------------------------------------------------------------------------------------------------
+ // MdcAsyncListener_HappyPath: snapshot / restore works — callback sees request-thread MDC.
+ // -----------------------------------------------------------------------------------------------------------------
+
+ @Test
+ void happyPath_01_slfjAvailable() {
+ assertTrue(MdcAsyncListener.isAvailable(), "SLF4J MDC must be on the test classpath");
+ }
+
+ @Test
+ void happyPath_02_snapshotCapturesCurrentMdc() {
+ MDC.put("requestId", "abc-123");
+ MDC.put("userId", "alice");
+
+ var snap = MdcAsyncListener.snapshot();
+
+ assertNotNull(snap);
+ assertEquals("abc-123", snap.get("requestId"));
+ assertEquals("alice", snap.get("userId"));
+ }
+
+ @Test
+ void happyPath_03_wrappedCallbackSeesMdc() {
+ MDC.put("requestId", "req-999");
+ var snap = MdcAsyncListener.snapshot();
+
+ MDC.clear(); // Simulate moving to a fresh completion thread.
+
+ var capturedRequestId = new AtomicReference<String>();
+ var wrapped = MdcAsyncListener.<String>wrap(
+ (v, e) -> capturedRequestId.set(MDC.get("requestId")),
+ snap
+ );
+
+ wrapped.accept("result", null);
+
+ assertEquals("req-999", capturedRequestId.get(), "Completion-thread callback must see the request-thread MDC.");
+ }
+
+ // -----------------------------------------------------------------------------------------------------------------
+ // MdcAsyncListener_NoSlf4j: when snapshot is null (empty MDC), wrap returns original action.
+ // -----------------------------------------------------------------------------------------------------------------
+
+ @Test
+ void noSlf4j_01_emptyMdcSnapshotIsNull() {
+ // MDC is clear — snapshot() must return null (lazy-skip contract).
+ assertNull(MdcAsyncListener.snapshot());
+ }
+
+ @Test
+ void noSlf4j_02_nullSnapshotReturnsOriginalAction() {
+ var original = (java.util.function.BiConsumer<String, Throwable>) (v, e) -> {};
+ var wrapped = MdcAsyncListener.wrap(original, null);
+ assertSame(original, wrapped, "wrap(action, null) must return the original action unchanged.");
+ }
+
+ // -----------------------------------------------------------------------------------------------------------------
+ // MdcAsyncListener_OptOut: builder knob mdcAsyncPropagation(false) disables propagation.
+ // -----------------------------------------------------------------------------------------------------------------
+
+ @Test
+ void optOut_01_nullSnapshotReturnsOriginalAction() {
+ // When propagation is disabled (or request thread has no MDC), the snapshot is null
+ // and wrap() returns the original action with no overhead.
+ var original = (java.util.function.BiConsumer<String, Throwable>) (v, e) -> {};
+ assertSame(original, MdcAsyncListener.wrap(original, null),
+ "wrap(action, null) must return the original action unchanged when propagation is off.");
+ }
+
+ @Test
+ void optOut_02_disabledPropagationDoesNotRestoreMdc() {
+ // Simulate disabled propagation: no snapshot is taken, wrap is called with null.
+ MDC.put("requestId", "should-not-propagate");
+ var requestSnap = MdcAsyncListener.snapshot();
+ MDC.clear(); // Completion thread starts empty.
+
+ // opt-out: pretend the code decided not to propagate — it would pass null to wrap().
+ var capturedOnCompletionThread = new AtomicReference<String>();
+ MdcAsyncListener.<String>wrap(
+ (v, e) -> capturedOnCompletionThread.set(MDC.get("requestId")),
+ /* disabled: */ null
+ ).accept("result", null);
+
+ assertNull(capturedOnCompletionThread.get(),
+ "Completion callback must not see MDC when propagation is disabled (null snapshot).");
+ // Snapshot was captured earlier just to consume it; not passed to wrap.
+ assertNotNull(requestSnap); // Confirm MDC was set on request thread.
+ }
+
+ // -----------------------------------------------------------------------------------------------------------------
+ // MdcAsyncListener_Cleanup: MDC cleared from completion thread after callback (no thread-pool leak).
+ // -----------------------------------------------------------------------------------------------------------------
+
+ @Test
+ void cleanup_01_mdcClearedAfterCallback() {
+ MDC.put("requestId", "to-be-cleared");
+ var snap = MdcAsyncListener.snapshot();
+ MDC.clear(); // Completion thread starts with no MDC.
+
+ MdcAsyncListener.<String>wrap((v, e) -> {}, snap).accept("ok", null);
+
+ // After the wrapped callback completes, the completion thread's MDC must be empty —
+ // no leak into the next task in the thread pool.
+ assertNull(MDC.get("requestId"), "MDC must be cleared after the wrapped callback runs.");
+ assertTrue(MDC.getCopyOfContextMap() == null || MDC.getCopyOfContextMap().isEmpty(),
+ "Completion thread must have an empty MDC after callback to prevent thread-pool contamination.");
+ }
+
+ @Test
+ void cleanup_02_preExistingCompletionThreadMdcIsPreserved() {
+ // Simulate a completion thread that already has its own MDC entries.
+ MDC.put("ownKey", "ownValue");
+ var completionThreadSnap = MdcAsyncListener.snapshot(); // Save for comparison.
+
+ // Request-thread snapshot (a different set of keys).
+ var requestSnap = Map.of("requestId", "req-789");
+
+ // After wrap completes, the completion thread's original MDC should be restored.
+ MdcAsyncListener.<String>wrap((v, e) -> {
+ assertEquals("req-789", MDC.get("requestId"), "Request MDC must be visible during callback.");
+ }, requestSnap).accept("ok", null);
+
+ // After callback: completion thread's original "ownKey" must be back; "requestId" must be gone.
+ assertEquals("ownValue", MDC.get("ownKey"), "Completion thread's own MDC must be restored.");
+ assertNull(MDC.get("requestId"), "Request-thread MDC must not leak into the completion thread.");
+ }
+
+ // -----------------------------------------------------------------------------------------------------------------
+ // MdcAsyncListener_ExceptionalCompletion: MDC still set during failure callback, and cleared after.
+ // -----------------------------------------------------------------------------------------------------------------
+
+ @Test
+ void exceptionalCompletion_01_mdcVisibleOnErrorPath() {
+ MDC.put("requestId", "err-path");
+ var snap = MdcAsyncListener.snapshot();
+ MDC.clear();
+
+ var capturedOnError = new AtomicReference<String>();
+ var wrapped = MdcAsyncListener.<String>wrap(
+ (v, e) -> capturedOnError.set(MDC.get("requestId")),
+ snap
+ );
+
+ // Simulate exceptional completion: value is null, throwable is present.
+ wrapped.accept(null, new RuntimeException("simulated failure"));
+
+ assertEquals("err-path", capturedOnError.get(), "MDC must be set during exceptional-completion callback.");
+ // Cleanup must still have run.
+ assertNull(MDC.get("requestId"), "MDC must be cleared after exceptional-completion callback.");
+ }
+
+ @Test
+ void exceptionalCompletion_02_mdcClearedEvenWhenCallbackThrows() {
+ MDC.put("requestId", "throw-path");
+ var snap = MdcAsyncListener.snapshot();
+ MDC.clear();
+
+ var wrapped = MdcAsyncListener.<String>wrap(
+ (v, e) -> { throw new RuntimeException("callback itself throws"); },
+ snap
+ );
+
+ // The wrapped BiConsumer must NOT re-throw (or if it does, MDC is cleared in finally).
+ // BiConsumer.accept is not declared to throw, so the RuntimeException propagates — but
+ // the important thing is the MDC is cleared even if it does.
+ RuntimeException thrown = assertThrows(RuntimeException.class, () -> wrapped.accept("x", null));
+ assertEquals("callback itself throws", thrown.getMessage());
+
+ // MDC must be cleared even though the callback threw.
+ assertNull(MDC.get("requestId"), "MDC must be cleared in the finally block even when callback throws.");
+ }
+}
diff --git a/juneau-utest/test-run-history.tsv b/juneau-utest/test-run-history.tsv
index 4818b939d6..8d62b9d16b 100644
--- a/juneau-utest/test-run-history.tsv
+++ b/juneau-utest/test-run-history.tsv
@@ -52,3 +52,4 @@ timestamp git_sha branch tests_run failures errors skipped surefire_sec wall_sec
2026-05-28T20:45:00Z 007293082747 master 125891 0 0 21 146
2026-05-28T21:29:51Z b0dc55154f95 master 125891 0 0 21 143
2026-05-29T10:45:54Z 3dc947b24d41 master 125897 0 0 21 151
+2026-05-29T13:48:36Z c59fc14fac5e master 126037 0 0 21 144