subjectToken) {
+
+ /**
+ * Creates a context before the subject token has been resolved (used by the request decorator).
+ * {@link AuthenticationRegistry} later derives the enriched copy via {@link #withSubjectToken(String)}.
+ */
+ public AuthenticationContext(String schemeName, AuthSchemeConfig schemeConfig,
+ WorkflowContext workflowContext, TaskContext taskContext) {
+ this(schemeName, schemeConfig, workflowContext, taskContext, Optional.empty());
+ }
+
+ /**
+ * Returns a copy of this context carrying the given subject token ({@code null} when none is available).
+ */
+ public AuthenticationContext withSubjectToken(String subjectToken) {
+ return new AuthenticationContext(schemeName, schemeConfig, workflowContext, taskContext,
+ Optional.ofNullable(subjectToken));
+ }
+
+ public String instanceId() {
+ return workflowContext.instance().id();
+ }
+}
diff --git a/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/AuthenticationMode.java b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/AuthenticationMode.java
new file mode 100644
index 000000000..dcc844c38
--- /dev/null
+++ b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/AuthenticationMode.java
@@ -0,0 +1,23 @@
+package io.quarkiverse.flow.oidc;
+
+/**
+ * How a workflow task authenticates against a downstream service.
+ */
+public enum AuthenticationMode {
+
+ /**
+ * Forward the caller's subject token unchanged (no exchange, no OIDC call).
+ */
+ TOKEN_PROPAGATION,
+
+ /**
+ * Swap the caller's subject token for a service-specific token via RFC 8693 token exchange,
+ * using a named {@code quarkus-oidc-client}.
+ */
+ TOKEN_EXCHANGE,
+
+ /**
+ * Service-to-service authentication using a named {@code quarkus-oidc-client}; no user context.
+ */
+ CLIENT_CREDENTIALS
+}
diff --git a/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/AuthenticationProvider.java b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/AuthenticationProvider.java
new file mode 100644
index 000000000..519e182ea
--- /dev/null
+++ b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/AuthenticationProvider.java
@@ -0,0 +1,25 @@
+package io.quarkiverse.flow.oidc;
+
+import java.util.Optional;
+
+/**
+ * Strategy that resolves the bearer token to attach to a downstream HTTP request.
+ *
+ *
+ * Implementations are CDI beans discovered by {@link AuthenticationRegistry}. Each strategy declares the
+ * {@link AuthenticationMode} it handles via {@link #supports(AuthenticationMode)}.
+ */
+public interface AuthenticationProvider {
+
+ /**
+ * Whether this provider handles the given mode.
+ */
+ boolean supports(AuthenticationMode mode);
+
+ /**
+ * Resolve the raw access token to attach (without the {@code Bearer } prefix), or empty if no token
+ * could be produced. Implementations should not throw on a missing subject token; returning empty lets
+ * the downstream service own the 401.
+ */
+ Optional resolveToken(AuthenticationContext context);
+}
diff --git a/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/AuthenticationRegistry.java b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/AuthenticationRegistry.java
new file mode 100644
index 000000000..c7970db35
--- /dev/null
+++ b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/AuthenticationRegistry.java
@@ -0,0 +1,51 @@
+package io.quarkiverse.flow.oidc;
+
+import java.util.Optional;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.inject.Instance;
+import jakarta.inject.Inject;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import io.quarkiverse.flow.oidc.config.AuthConfigResolver;
+
+/**
+ * Resolves the bearer token for a downstream call. The scheme carries no explicit mode, so the registry
+ * derives it via {@link AuthConfigResolver} (using whether a subject token is available) and routes to the
+ * {@link AuthenticationProvider} that supports the derived {@link AuthenticationMode}.
+ */
+@ApplicationScoped
+public class AuthenticationRegistry {
+
+ private static final Logger LOG = LoggerFactory.getLogger(AuthenticationRegistry.class);
+
+ @Inject
+ Instance providers;
+
+ @Inject
+ AuthConfigResolver authConfigResolver;
+
+ @Inject
+ SubjectTokenExtractor subjectTokenExtractor;
+
+ public Optional authenticate(AuthenticationContext context) {
+ // Extract the subject token exactly once: it drives mode resolution and is read by the providers.
+ String subjectToken = subjectTokenExtractor.extract(context).orElse(null);
+ AuthenticationMode mode = authConfigResolver.resolveMode(context.schemeName(), subjectToken != null);
+ AuthenticationContext enriched = context.withSubjectToken(subjectToken);
+ return providers.stream()
+ .filter(p -> p.supports(mode))
+ .findFirst()
+ .flatMap(p -> {
+ try {
+ return p.resolveToken(enriched);
+ } catch (RuntimeException e) {
+ LOG.warn("Flow OIDC: provider {} failed to resolve token for scheme '{}': {}",
+ p.getClass().getName(), context.schemeName(), e.getMessage());
+ throw e;
+ }
+ });
+ }
+}
diff --git a/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/AuthenticationRequestDecorator.java b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/AuthenticationRequestDecorator.java
new file mode 100644
index 000000000..87f111803
--- /dev/null
+++ b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/AuthenticationRequestDecorator.java
@@ -0,0 +1,143 @@
+package io.quarkiverse.flow.oidc;
+
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Optional;
+
+import jakarta.ws.rs.client.Invocation;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import io.quarkiverse.flow.oidc.config.FlowOidcConfig;
+import io.quarkiverse.flow.oidc.config.FlowOidcConfig.AuthSchemeConfig;
+import io.quarkus.arc.Arc;
+import io.quarkus.arc.InstanceHandle;
+import io.serverlessworkflow.api.types.CallHTTP;
+import io.serverlessworkflow.api.types.CallOpenAPI;
+import io.serverlessworkflow.api.types.Endpoint;
+import io.serverlessworkflow.api.types.ReferenceableAuthenticationPolicy;
+import io.serverlessworkflow.impl.TaskContext;
+import io.serverlessworkflow.impl.WorkflowContext;
+import io.serverlessworkflow.impl.executors.http.HttpRequestDecorator;
+
+/**
+ * Attaches an {@code Authorization} header to downstream HTTP requests based on the
+ * {@code quarkus.flow.oidc.auth.*} configuration.
+ */
+public class AuthenticationRequestDecorator implements HttpRequestDecorator {
+
+ public static final int PRIORITY = 100;
+
+ private static final Logger LOG = LoggerFactory.getLogger(AuthenticationRequestDecorator.class);
+ private final Optional flowOidcConfig;
+ private final Optional authenticationRegistry;
+ private final Optional subjectTokenExtractor;
+
+ public AuthenticationRequestDecorator() {
+ this.flowOidcConfig = lookup(FlowOidcConfig.class);
+ this.authenticationRegistry = lookup(AuthenticationRegistry.class);
+ this.subjectTokenExtractor = lookup(SubjectTokenExtractor.class);
+ }
+
+ @Override
+ public void decorate(Invocation.Builder request, WorkflowContext workflowContext, TaskContext taskContext) {
+ if (flowOidcConfig.isEmpty()) {
+ return;
+ }
+ FlowOidcConfig config = flowOidcConfig.get();
+
+ Optional> scheme = selectScheme(config, taskContext);
+ if (scheme.isEmpty()) {
+ return;
+ }
+ if (authenticationRegistry.isEmpty() || subjectTokenExtractor.isEmpty()) {
+ return;
+ }
+
+ AuthenticationContext ctx = new AuthenticationContext(
+ scheme.get().getKey(), scheme.get().getValue(), workflowContext, taskContext);
+
+ authenticationRegistry.get().authenticate(ctx)
+ .filter(token -> !token.isBlank())
+ .ifPresent(token -> request.header("Authorization", "Bearer " + token));
+ }
+
+ /**
+ * Select the scheme the task itself declares: read the named-authentication reference from the HTTP
+ * endpoint ({@code FuncDSL.use("")}) and match it against {@code quarkus.flow.oidc.auth.}.
+ * Returns empty when the task declares no reference or no matching scheme is configured.
+ */
+ static Optional> selectScheme(
+ FlowOidcConfig config, TaskContext taskContext) {
+ Optional schemeName = referencedSchemeName(taskContext);
+ return schemeName
+ .filter(name -> config.auth().containsKey(name))
+ .map(name -> Map.entry(name, config.auth().get(name)));
+ }
+
+ /**
+ * Extract the {@code use:} authentication-policy reference from an HTTP call task's endpoint, if any.
+ */
+ static Optional referencedSchemeName(TaskContext taskContext) {
+ try {
+
+ if (!(taskContext.task() instanceof CallHTTP) && !(taskContext.task() instanceof CallOpenAPI)) {
+ return Optional.empty();
+ }
+
+ if (taskContext.task() instanceof CallHTTP http) {
+ if (http.getWith() == null) {
+ return Optional.empty();
+ }
+ Endpoint endpoint = http.getWith().getEndpoint();
+ if (endpoint == null || endpoint.getEndpointConfiguration() == null) {
+ return Optional.empty();
+ }
+ ReferenceableAuthenticationPolicy auth = endpoint.getEndpointConfiguration().getAuthentication();
+ return getSchemeName(auth);
+ }
+
+ if (taskContext.task() instanceof CallOpenAPI openapi) {
+ if (openapi.getWith() == null) {
+ return Optional.empty();
+ }
+ ReferenceableAuthenticationPolicy authentication = openapi.getWith().getAuthentication();
+ if (authentication == null || authentication.getAuthenticationPolicyReference() == null) {
+ return Optional.empty();
+ }
+ return getSchemeName(authentication);
+ }
+ return Optional.empty();
+
+ } catch (RuntimeException e) {
+ LOG.debug("Flow OIDC: unable to read authentication reference from task: {}", e.getMessage());
+ return Optional.empty();
+ }
+ }
+
+ private static Optional getSchemeName(ReferenceableAuthenticationPolicy auth) {
+ if (auth == null || auth.getAuthenticationPolicyReference() == null) {
+ return Optional.empty();
+ }
+ return Optional.ofNullable(auth.getAuthenticationPolicyReference().getUse())
+ .filter(s -> !s.isBlank());
+ }
+
+ private static Optional lookup(Class type) {
+ if (!Arc.container().isRunning()) {
+ return Optional.empty();
+ }
+ try (InstanceHandle handle = Arc.container().instance(type)) {
+ return handle.isAvailable() ? Optional.ofNullable(handle.get()) : Optional.empty();
+ } catch (RuntimeException e) {
+ LOG.debug("Flow OIDC: unable to resolve {} from Arc: {}", type.getName(), e.getMessage());
+ return Optional.empty();
+ }
+ }
+
+ @Override
+ public int priority() {
+ return PRIORITY;
+ }
+}
diff --git a/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/PropagatedAuthContext.java b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/PropagatedAuthContext.java
new file mode 100644
index 000000000..8a694fb74
--- /dev/null
+++ b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/PropagatedAuthContext.java
@@ -0,0 +1,26 @@
+package io.quarkiverse.flow.oidc;
+
+import io.quarkus.security.identity.SecurityIdentity;
+
+public final class PropagatedAuthContext {
+
+ public record Snapshot(SecurityIdentity identity) {
+ }
+
+ private static final ThreadLocal CURRENT = new ThreadLocal<>();
+
+ private PropagatedAuthContext() {
+ }
+
+ static void set(Snapshot snapshot) {
+ CURRENT.set(snapshot);
+ }
+
+ static void clear() {
+ CURRENT.remove();
+ }
+
+ static Snapshot current() {
+ return CURRENT.get();
+ }
+}
diff --git a/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/QuarkusContextPropagator.java b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/QuarkusContextPropagator.java
new file mode 100644
index 000000000..08d7af473
--- /dev/null
+++ b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/QuarkusContextPropagator.java
@@ -0,0 +1,71 @@
+package io.quarkiverse.flow.oidc;
+
+import java.util.function.Supplier;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.inject.Instance;
+import jakarta.inject.Inject;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import io.quarkus.arc.Arc;
+import io.quarkus.arc.ManagedContext;
+import io.quarkus.arc.Unremovable;
+import io.quarkus.security.identity.CurrentIdentityAssociation;
+import io.quarkus.security.identity.SecurityIdentity;
+import io.serverlessworkflow.impl.ContextPropagator;
+import io.serverlessworkflow.impl.ContextSnapshot;
+
+@ApplicationScoped
+@Unremovable
+public class QuarkusContextPropagator implements ContextPropagator {
+
+ private static final Logger LOG = LoggerFactory.getLogger(QuarkusContextPropagator.class);
+
+ @Inject
+ Instance identityAssociation;
+
+ @Override
+ public ContextSnapshot capture() {
+ // Runs on the thread that calls start(). CDI request scope is only available here, so resolve the
+ // caller's identity and raw token now and carry them as plain values.
+ ManagedContext requestContext = Arc.container().requestContext();
+ if (!requestContext.isActive()) {
+ return ContextSnapshot.NOOP;
+ }
+ SecurityIdentity identity = currentIdentity();
+
+ if ((identity == null || identity.isAnonymous())) {
+ return ContextSnapshot.NOOP;
+ }
+ LOG.debug("Flow OIDC: captured caller authentication to propagate to task execution (principal='{}')",
+ identity.getPrincipal() != null ? identity.getPrincipal().getName() : "");
+
+ return new AuthSnapshot(new PropagatedAuthContext.Snapshot(identity));
+ }
+
+ private SecurityIdentity currentIdentity() {
+ try {
+ return identityAssociation.isResolvable() ? identityAssociation.get().getIdentity() : null;
+ } catch (RuntimeException e) {
+ LOG.debug("Flow OIDC: unable to read SecurityIdentity at start: {}", e.getMessage());
+ return null;
+ }
+ }
+
+ private record AuthSnapshot(PropagatedAuthContext.Snapshot snapshot) implements ContextSnapshot {
+
+ @Override
+ public Supplier wrap(Supplier supplier) {
+ return () -> {
+ PropagatedAuthContext.set(snapshot);
+ try {
+ return supplier.get();
+ } finally {
+ PropagatedAuthContext.clear();
+ }
+ };
+ }
+ }
+}
diff --git a/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/SchemeContext.java b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/SchemeContext.java
new file mode 100644
index 000000000..deb5e58be
--- /dev/null
+++ b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/SchemeContext.java
@@ -0,0 +1,6 @@
+package io.quarkiverse.flow.oidc;
+
+import io.quarkiverse.flow.oidc.config.FlowOidcConfig;
+
+public record SchemeContext(String name, FlowOidcConfig.AuthSchemeConfig config) {
+}
diff --git a/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/SubjectTokenExtractor.java b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/SubjectTokenExtractor.java
new file mode 100644
index 000000000..663c5a48a
--- /dev/null
+++ b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/SubjectTokenExtractor.java
@@ -0,0 +1,106 @@
+package io.quarkiverse.flow.oidc;
+
+import java.util.Optional;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.enterprise.inject.Instance;
+import jakarta.inject.Inject;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import io.quarkiverse.flow.oidc.config.FlowOidcConfig;
+import io.quarkus.security.credential.TokenCredential;
+import io.quarkus.security.identity.SecurityIdentity;
+import io.serverlessworkflow.impl.WorkflowContext;
+
+/**
+ * Extracts the user's subject token for propagation/exchange.
+ *
+ *
+ * Priority order:
+ *
+ * - Explicit workflow input: under {@code quarkus.flow.oidc.subject-token.input-key}
+ * (useful for non-HTTP / programmatic triggers);
+ * - {@code SecurityIdentity}: the
+ * {@code quarkus.flow.oidc.subject-token.security-identity-attribute} attribute, for HTTP-triggered
+ * workflows running on the request thread.
+ *
+ *
+ * Inbound application security ({@code quarkus-oidc}) and outbound token acquisition
+ * ({@code quarkus-oidc-client}) stay separate: this class only reads the inbound identity to obtain
+ * a subject token, it never authenticates the caller.
+ */
+@ApplicationScoped
+public class SubjectTokenExtractor {
+
+ static final Logger log = LoggerFactory.getLogger(SubjectTokenExtractor.class);
+
+ @Inject
+ FlowOidcConfig config;
+
+ @Inject
+ Instance securityIdentity;
+
+ public Optional extract(AuthenticationContext context) {
+ Optional input = fromWorkflowInput(context.workflowContext());
+ if (input.isPresent()) {
+ log.debug("Flow OIDC: subject token resolved from workflow input");
+ return input;
+ }
+
+ Optional fromIdentity = fromSecurityIdentity();
+ if (fromIdentity.isPresent()) {
+ log.debug("Flow OIDC: subject token resolved from SecurityIdentity attribute '{}'",
+ config.subjectToken().securityIdentityAttribute());
+ return fromIdentity;
+ }
+
+ return Optional.empty();
+ }
+
+ private Optional fromWorkflowInput(WorkflowContext workflowContext) {
+ String key = config.subjectToken().inputKey();
+ return workflowContext.instance().input().asMap()
+ .map(m -> m.get(key))
+ .map(Object::toString)
+ .filter(value -> !value.isBlank());
+ }
+
+ private Optional fromSecurityIdentity() {
+ try {
+ // try to get from snapshot or from current CDI
+ SecurityIdentity identity = propagatedIdentity().orElseGet(this::cdiIdentity);
+ if (identity == null || identity.isAnonymous()) {
+ return Optional.empty();
+ }
+
+ // try to get the token from SecurityIdentity's attribute given
+ // the "quarkus.flow.oidc.subjectToken" configuration
+ String attribute = config.subjectToken().securityIdentityAttribute();
+ Object value = identity.getAttribute(attribute);
+ if (value != null) {
+ return Optional.of(value.toString());
+ }
+
+ TokenCredential credential = identity.getCredential(TokenCredential.class);
+ if (credential != null) {
+ return Optional.of(credential.getToken());
+ }
+
+ return Optional.empty();
+ } catch (Exception e) {
+ log.debug("Flow OIDC: SecurityIdentity not available on this thread: {}", e.getMessage());
+ return Optional.empty();
+ }
+ }
+
+ private Optional propagatedIdentity() {
+ PropagatedAuthContext.Snapshot snapshot = PropagatedAuthContext.current();
+ return snapshot != null ? Optional.ofNullable(snapshot.identity()) : Optional.empty();
+ }
+
+ private SecurityIdentity cdiIdentity() {
+ return securityIdentity.isResolvable() ? securityIdentity.get() : null;
+ }
+}
diff --git a/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/cache/CachedToken.java b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/cache/CachedToken.java
new file mode 100644
index 000000000..939e7223a
--- /dev/null
+++ b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/cache/CachedToken.java
@@ -0,0 +1,24 @@
+package io.quarkiverse.flow.oidc.cache;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Set;
+
+/**
+ * An exchanged/acquired token plus the metadata needed for expiry and lifecycle tracking.
+ */
+public record CachedToken(
+ TokenCacheKey key,
+ String token,
+ Instant expiresAt,
+ Instant createdAt,
+ Set linkedInstances) {
+
+ public boolean isExpired() {
+ return expiresAt != null && Instant.now().isAfter(expiresAt);
+ }
+
+ public boolean isNearingExpiry(Duration threshold) {
+ return expiresAt != null && Instant.now().plus(threshold).isAfter(expiresAt);
+ }
+}
diff --git a/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/cache/InMemoryTokenCacheRepository.java b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/cache/InMemoryTokenCacheRepository.java
new file mode 100644
index 000000000..d776fc277
--- /dev/null
+++ b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/cache/InMemoryTokenCacheRepository.java
@@ -0,0 +1,88 @@
+package io.quarkiverse.flow.oidc.cache;
+
+import java.time.Duration;
+import java.util.Collection;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.stream.Collectors;
+
+import jakarta.annotation.PreDestroy;
+import jakarta.enterprise.context.ApplicationScoped;
+
+import io.quarkus.arc.DefaultBean;
+
+@DefaultBean
+@ApplicationScoped
+public class InMemoryTokenCacheRepository implements TokenCacheRepository {
+
+ private final ConcurrentHashMap tokens = new ConcurrentHashMap<>();
+ private final ConcurrentHashMap> instanceTokenLinks = new ConcurrentHashMap<>();
+
+ @Override
+ public void store(CachedToken token, String instanceId) {
+ TokenCacheKey key = token.key();
+ Set links = ConcurrentHashMap.newKeySet();
+ links.addAll(token.linkedInstances());
+ if (instanceId != null) {
+ links.add(instanceId);
+ instanceTokenLinks.computeIfAbsent(instanceId, k -> ConcurrentHashMap.newKeySet()).add(key);
+ }
+ tokens.put(key, new CachedToken(key, token.token(), token.expiresAt(), token.createdAt(), links));
+ }
+
+ @Override
+ public Optional get(TokenCacheKey key) {
+ CachedToken token = tokens.get(key);
+ if (token == null) {
+ return Optional.empty();
+ }
+ if (token.isExpired()) {
+ tokens.remove(key, token);
+ return Optional.empty();
+ }
+ return Optional.of(token);
+ }
+
+ @Override
+ public void evict(TokenCacheKey key) {
+ tokens.remove(key);
+ }
+
+ @Override
+ public Collection getTokensNearingExpiry(Duration threshold) {
+ return tokens.values().stream()
+ .filter(t -> !t.isExpired())
+ .filter(t -> t.isNearingExpiry(threshold))
+ .collect(Collectors.toList());
+ }
+
+ @Override
+ public void unlinkInstance(String instanceId) {
+ Set keys = instanceTokenLinks.remove(instanceId);
+ if (keys == null) {
+ return;
+ }
+ for (TokenCacheKey key : keys) {
+ tokens.computeIfPresent(key, (k, token) -> {
+ Set updated = ConcurrentHashMap.newKeySet();
+ updated.addAll(token.linkedInstances());
+ updated.remove(instanceId);
+ if (updated.isEmpty()) {
+ return null; // orphaned -> evict
+ }
+ return new CachedToken(k, token.token(), token.expiresAt(), token.createdAt(), updated);
+ });
+ }
+ }
+
+ @PreDestroy
+ void clear() {
+ tokens.clear();
+ instanceTokenLinks.clear();
+ }
+
+ public boolean contains(TokenCacheKey key) {
+ return get(key).isPresent();
+ }
+}
diff --git a/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/cache/TokenCacheKey.java b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/cache/TokenCacheKey.java
new file mode 100644
index 000000000..5d30ed642
--- /dev/null
+++ b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/cache/TokenCacheKey.java
@@ -0,0 +1,30 @@
+package io.quarkiverse.flow.oidc.cache;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HexFormat;
+
+/**
+ * Composite cache key for an exchanged token. The subject token is stored only as an SHA-256 hash so that
+ * raw user tokens never live in the cache key.
+ */
+public record TokenCacheKey(String authSchemeName, String subjectTokenHash, String audience) {
+
+ public static TokenCacheKey from(String authSchemeName, String subjectToken, String audience) {
+ return new TokenCacheKey(authSchemeName, sha256(subjectToken), audience == null ? "" : audience);
+ }
+
+ private static String sha256(String value) {
+ if (value == null) {
+ return "";
+ }
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8));
+ return HexFormat.of().formatHex(hash);
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("SHA-256 not available", e);
+ }
+ }
+}
diff --git a/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/cache/TokenCacheRepository.java b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/cache/TokenCacheRepository.java
new file mode 100644
index 000000000..7f6b0c8d1
--- /dev/null
+++ b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/cache/TokenCacheRepository.java
@@ -0,0 +1,36 @@
+package io.quarkiverse.flow.oidc.cache;
+
+import java.time.Duration;
+import java.util.Collection;
+import java.util.Optional;
+
+/**
+ * Storage abstraction for exchanged tokens.
+ */
+public interface TokenCacheRepository {
+
+ /**
+ * Store (or replace) a token, linking it to the given workflow instance.
+ */
+ void store(CachedToken token, String instanceId);
+
+ /**
+ * Return a non-expired token for the key, or empty.
+ */
+ Optional get(TokenCacheKey key);
+
+ /**
+ * Remove a token regardless of links.
+ */
+ void evict(TokenCacheKey key);
+
+ /**
+ * Unlink an instance from all its tokens, evicting any token left with no links.
+ */
+ void unlinkInstance(String instanceId);
+
+ /**
+ * All cached tokens within {@code threshold} of expiry, for proactive refresh.
+ */
+ Collection getTokensNearingExpiry(Duration threshold);
+}
diff --git a/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/cache/TokenRefreshMonitor.java b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/cache/TokenRefreshMonitor.java
new file mode 100644
index 000000000..f8c5a9315
--- /dev/null
+++ b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/cache/TokenRefreshMonitor.java
@@ -0,0 +1,80 @@
+package io.quarkiverse.flow.oidc.cache;
+
+import java.time.Duration;
+import java.util.Collection;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import io.quarkiverse.flow.oidc.config.FlowOidcConfig;
+
+/**
+ * Background monitor that proactively handles cached tokens nearing expiry, so a workflow task never sends a
+ * token that is about to expire mid-call. Runs on a single daemon thread at a configurable rate.
+ */
+@ApplicationScoped
+public class TokenRefreshMonitor {
+
+ private static final Logger LOG = LoggerFactory.getLogger(TokenRefreshMonitor.class);
+
+ @Inject
+ FlowOidcConfig config;
+
+ @Inject
+ TokenCacheRepository repository;
+
+ private ScheduledExecutorService scheduler;
+
+ @PostConstruct
+ void start() {
+ if (!config.tokenExchange().enabled()) {
+ return;
+ }
+ Duration rateSeconds = config.tokenExchange().monitorRateSeconds();
+ scheduler = Executors.newSingleThreadScheduledExecutor(daemonFactory());
+ scheduler.scheduleAtFixedRate(this::refreshNearingExpiry, rateSeconds.toSeconds(), rateSeconds.toSeconds(),
+ TimeUnit.SECONDS);
+ }
+
+ @PreDestroy
+ void stop() {
+ if (scheduler != null) {
+ scheduler.shutdownNow();
+ }
+ }
+
+ void refreshNearingExpiry() {
+ try {
+ Duration threshold = Duration.ofSeconds(config.tokenExchange().proactiveRefreshSeconds().toSeconds());
+ Collection tokens = repository.getTokensNearingExpiry(threshold);
+ tokens.forEach(this::evictToken);
+ } catch (RuntimeException e) {
+ LOG.warn("Flow OIDC: proactive refresh cycle failed: {}", e.getMessage());
+ }
+ }
+
+ private void evictToken(CachedToken token) {
+ repository.evict(token.key());
+ LOG.debug("Flow OIDC: evicted near-expiry token for scheme '{}' (will re-acquire on next use).",
+ token.key().authSchemeName());
+ }
+
+ private static ThreadFactory daemonFactory() {
+ AtomicInteger counter = new AtomicInteger();
+ return runnable -> {
+ Thread thread = new Thread(runnable, "flow-token-refresh-" + counter.getAndIncrement());
+ thread.setDaemon(true);
+ return thread;
+ };
+ }
+}
diff --git a/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/client/OidcClientProvider.java b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/client/OidcClientProvider.java
new file mode 100644
index 000000000..5b345160a
--- /dev/null
+++ b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/client/OidcClientProvider.java
@@ -0,0 +1,37 @@
+package io.quarkiverse.flow.oidc.client;
+
+import java.util.Optional;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+
+import io.quarkus.oidc.client.OidcClient;
+import io.quarkus.oidc.client.OidcClients;
+
+/**
+ * Resolves named {@code quarkus-oidc-client} instances for an auth scheme.
+ */
+@ApplicationScoped
+public class OidcClientProvider {
+
+ @Inject
+ OidcClients oidcClients;
+
+ /**
+ * Resolve the OIDC client for a scheme. Precedence: explicit {@code oidc-client-name} on the scheme,
+ * otherwise the scheme name itself, otherwise the default client.
+ */
+ public OidcClient resolve(String schemeName, Optional oidcClientName) {
+ String clientName = oidcClientName.filter(s -> !s.isBlank()).orElse(schemeName);
+ if (clientName == null || clientName.isBlank()) {
+ return oidcClients.getClient();
+ }
+ OidcClient client = oidcClients.getClient(clientName);
+ if (client == null) {
+ throw new IllegalStateException(
+ "OIDC client not configured: " + clientName
+ + ". Configure quarkus.oidc-client." + clientName + ".*");
+ }
+ return client;
+ }
+}
diff --git a/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/client/TokenExchangeClient.java b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/client/TokenExchangeClient.java
new file mode 100644
index 000000000..ca519af38
--- /dev/null
+++ b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/client/TokenExchangeClient.java
@@ -0,0 +1,43 @@
+package io.quarkiverse.flow.oidc.client;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import jakarta.enterprise.context.ApplicationScoped;
+
+import io.quarkus.oidc.client.OidcClient;
+import io.quarkus.oidc.client.Tokens;
+
+/**
+ * Thin wrapper over {@link OidcClient} for client-credentials and
+ * RFC 8693 token exchange.
+ */
+@ApplicationScoped
+public class TokenExchangeClient {
+
+ public static final String TOKEN_TYPE_ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token";
+
+ /**
+ * Client-credentials acquisition, no user context. Blocks until the token is materialized.
+ */
+ public Tokens clientCredentials(OidcClient client) {
+ return client.getTokens().await().indefinitely();
+ }
+
+ /**
+ * RFC 8693 token exchange. The configured {@code quarkus.oidc-client..grant.type} drives the
+ * {@code grant_type}; subject token, audience and scopes are passed as additional grant parameters.
+ * Blocks until the token is materialized.
+ */
+ public Tokens exchange(OidcClient client, String subjectToken, Optional audience,
+ Optional> scopes) {
+ Map params = new HashMap<>();
+ params.put("subject_token", subjectToken);
+ params.put("subject_token_type", TOKEN_TYPE_ACCESS_TOKEN);
+ audience.filter(a -> !a.isBlank()).ifPresent(a -> params.put("audience", a));
+ scopes.filter(s -> !s.isEmpty()).ifPresent(s -> params.put("scope", String.join(" ", s)));
+ return client.getTokens(params).await().indefinitely();
+ }
+}
diff --git a/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/config/AuthConfigResolver.java b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/config/AuthConfigResolver.java
new file mode 100644
index 000000000..654fc8185
--- /dev/null
+++ b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/config/AuthConfigResolver.java
@@ -0,0 +1,61 @@
+package io.quarkiverse.flow.oidc.config;
+
+import java.util.Optional;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+
+import io.quarkiverse.flow.oidc.AuthenticationMode;
+import io.quarkiverse.flow.oidc.config.FlowOidcConfig.AuthSchemeConfig;
+
+/**
+ * Derives the effective {@link AuthenticationMode} strategy for an auth scheme from configuration, since the
+ * scheme carries no explicit mode. Precedence: per-auth-scheme > global > smart default.
+ *
+ *
+ * - {@code token-propagation-enabled=true} ⇒ {@link AuthenticationMode#TOKEN_PROPAGATION} (wins over exchange);
+ * - otherwise exchange when {@link #isTokenExchangeEnabled} holds ⇒ {@link AuthenticationMode#TOKEN_EXCHANGE};
+ * - otherwise {@link AuthenticationMode#CLIENT_CREDENTIALS}.
+ *
+ */
+@ApplicationScoped
+public class AuthConfigResolver {
+
+ private final FlowOidcConfig config;
+
+ @Inject
+ public AuthConfigResolver(FlowOidcConfig config) {
+ this.config = config;
+ }
+
+ public AuthenticationMode resolveMode(String schemeName, boolean subjectTokenAvailable) {
+ if (isTokenPropagationEnabled(schemeName)) {
+ return AuthenticationMode.TOKEN_PROPAGATION;
+ }
+ if (isTokenExchangeEnabled(schemeName, subjectTokenAvailable)) {
+ return AuthenticationMode.TOKEN_EXCHANGE;
+ }
+ return AuthenticationMode.CLIENT_CREDENTIALS;
+ }
+
+ /**
+ * Determine whether token exchange applies for this scheme. A per-scheme {@code token-exchange-enabled}
+ * value forces exchange on/off; otherwise the smart default applies — global exchange only kicks in when
+ * a subject token is actually available, so subject-less schemes fall back to client-credentials.
+ */
+ public boolean isTokenExchangeEnabled(String schemeName, boolean subjectTokenAvailable) {
+ Optional schemeConfig = scheme(schemeName).flatMap(AuthSchemeConfig::tokenExchangeEnabled);
+ return schemeConfig.orElseGet(() -> config.tokenExchange().enabled() && subjectTokenAvailable);
+ }
+
+ /**
+ * Determine whether token propagation applies for this scheme. Propagation takes precedence over exchange.
+ */
+ public boolean isTokenPropagationEnabled(String schemeName) {
+ return scheme(schemeName).flatMap(AuthSchemeConfig::tokenPropagationEnabled).orElse(false);
+ }
+
+ private Optional scheme(String schemeName) {
+ return Optional.ofNullable(config.auth().get(schemeName));
+ }
+}
diff --git a/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/config/FlowOidcConfig.java b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/config/FlowOidcConfig.java
new file mode 100644
index 000000000..b72199bd8
--- /dev/null
+++ b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/config/FlowOidcConfig.java
@@ -0,0 +1,91 @@
+package io.quarkiverse.flow.oidc.config;
+
+import java.time.Duration;
+import java.util.Map;
+import java.util.Optional;
+
+import io.quarkus.runtime.annotations.ConfigPhase;
+import io.quarkus.runtime.annotations.ConfigRoot;
+import io.smallrye.config.ConfigMapping;
+import io.smallrye.config.WithDefault;
+
+@ConfigRoot(phase = ConfigPhase.RUN_TIME)
+@ConfigMapping(prefix = "quarkus.flow.oidc")
+public interface FlowOidcConfig {
+
+ /**
+ * Global token-exchange settings.
+ */
+ TokenExchangeConfig tokenExchange();
+
+ /**
+ * Subject-token extraction settings.
+ */
+ SubjectTokenConfig subjectToken();
+
+ /**
+ * Per-auth-scheme configuration, keyed by the named authentication the task references.
+ */
+ Map auth();
+
+ interface TokenExchangeConfig {
+
+ /**
+ * Whether token exchange is enabled globally. When {@code false}, schemes fall back to
+ * client-credentials unless they explicitly enable exchange or propagation.
+ */
+ @WithDefault("true")
+ boolean enabled();
+
+ /**
+ * How many seconds before expiry a cached token is proactively refreshed.
+ */
+ @WithDefault("PT300S")
+ Duration proactiveRefreshSeconds();
+
+ /**
+ * How often (seconds) the proactive-refresh monitor runs.
+ */
+ @WithDefault("PT60S")
+ Duration monitorRateSeconds();
+ }
+
+ interface SubjectTokenConfig {
+
+ /**
+ * Workflow input key holding the subject token (for programmatic / non-HTTP triggers).
+ */
+ @WithDefault("subjectToken")
+ String inputKey();
+
+ /**
+ * {@code SecurityIdentity} attribute holding the subject token (for HTTP-triggered workflows).
+ */
+ @WithDefault("access_token")
+ String securityIdentityAttribute();
+ }
+
+ interface AuthSchemeConfig {
+
+ /**
+ * Name of the {@code quarkus.oidc-client.} to use for exchange and client-credentials.
+ * Defaults to the scheme name.
+ */
+ Optional oidcClientName();
+
+ /**
+ * Force token exchange on/off for this scheme, overriding the global setting and smart default.
+ */
+ Optional tokenExchangeEnabled();
+
+ /**
+ * Per-scheme proactive-refresh threshold (seconds), overriding the global value.
+ */
+ Optional proactiveRefreshSeconds();
+
+ /**
+ * Forward the caller's subject token unchanged. Takes precedence over exchange when enabled.
+ */
+ Optional tokenPropagationEnabled();
+ }
+}
diff --git a/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/lifecycle/TokenCleanupListener.java b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/lifecycle/TokenCleanupListener.java
new file mode 100644
index 000000000..489af308e
--- /dev/null
+++ b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/lifecycle/TokenCleanupListener.java
@@ -0,0 +1,42 @@
+package io.quarkiverse.flow.oidc.lifecycle;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+
+import io.quarkiverse.flow.oidc.cache.TokenCacheRepository;
+import io.serverlessworkflow.impl.lifecycle.WorkflowCancelledEvent;
+import io.serverlessworkflow.impl.lifecycle.WorkflowCompletedEvent;
+import io.serverlessworkflow.impl.lifecycle.WorkflowExecutionListener;
+import io.serverlessworkflow.impl.lifecycle.WorkflowFailedEvent;
+
+/**
+ * Unlinks a workflow instance from its cached tokens when it terminates (completed/failed/cancelled), so
+ * orphaned tokens are evicted.
+ */
+@ApplicationScoped
+public class TokenCleanupListener implements WorkflowExecutionListener {
+
+ @Inject
+ TokenCacheRepository cache;
+
+ @Override
+ public void onWorkflowCompleted(WorkflowCompletedEvent ev) {
+ cleanup(ev.workflowContext().instanceData().id());
+ }
+
+ @Override
+ public void onWorkflowFailed(WorkflowFailedEvent ev) {
+ cleanup(ev.workflowContext().instanceData().id());
+ }
+
+ @Override
+ public void onWorkflowCancelled(WorkflowCancelledEvent ev) {
+ cleanup(ev.workflowContext().instanceData().id());
+ }
+
+ private void cleanup(String instanceId) {
+ if (instanceId != null) {
+ cache.unlinkInstance(instanceId);
+ }
+ }
+}
diff --git a/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/providers/CachedTokenSource.java b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/providers/CachedTokenSource.java
new file mode 100644
index 000000000..ca2726d7a
--- /dev/null
+++ b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/providers/CachedTokenSource.java
@@ -0,0 +1,53 @@
+package io.quarkiverse.flow.oidc.providers;
+
+import java.util.Optional;
+import java.util.function.Function;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+
+import io.quarkiverse.flow.oidc.AuthenticationContext;
+import io.quarkiverse.flow.oidc.cache.CachedToken;
+import io.quarkiverse.flow.oidc.cache.TokenCacheKey;
+import io.quarkiverse.flow.oidc.cache.TokenCacheRepository;
+import io.quarkiverse.flow.oidc.client.OidcClientProvider;
+import io.quarkus.oidc.client.OidcClient;
+import io.quarkus.oidc.client.Tokens;
+
+/**
+ * Shared cache-or-acquire flow for providers that obtain a token from a named {@code quarkus-oidc-client}:
+ * return the cached token when present, otherwise resolve the client, acquire a fresh token, cache it (linked
+ * to the workflow instance) and return its access token.
+ */
+@ApplicationScoped
+public class CachedTokenSource {
+
+ @Inject
+ OidcClientProvider oidcClientProvider;
+
+ @Inject
+ TokenCacheRepository cache;
+
+ /**
+ * @param context the current call context (carries the scheme name, its config and the instance id)
+ * @param subjectForKey the value that, with the scheme, identifies the cache entry — the subject token
+ * for exchange, or a fixed placeholder for client-credentials
+ * @param acquire how to obtain fresh tokens from the resolved {@link OidcClient} on a cache miss
+ * @return the access token to attach, or empty when none could be produced
+ */
+ public Optional getOrAcquire(AuthenticationContext context, String subjectForKey,
+ Function acquire) {
+ TokenCacheKey key = TokenCacheKey.from(context.schemeName(), subjectForKey, "");
+
+ Optional cached = cache.get(key);
+ if (cached.isPresent()) {
+ return Optional.of(cached.get().token());
+ }
+
+ OidcClient client = oidcClientProvider.resolve(context.schemeName(),
+ context.schemeConfig().oidcClientName());
+ Tokens tokens = acquire.apply(client);
+ cache.store(ProviderTokens.toCachedToken(key, tokens), context.instanceId());
+ return Optional.ofNullable(tokens.getAccessToken());
+ }
+}
\ No newline at end of file
diff --git a/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/providers/ClientCredentialsProvider.java b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/providers/ClientCredentialsProvider.java
new file mode 100644
index 000000000..e3ab6944e
--- /dev/null
+++ b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/providers/ClientCredentialsProvider.java
@@ -0,0 +1,37 @@
+package io.quarkiverse.flow.oidc.providers;
+
+import java.util.Optional;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+
+import io.quarkiverse.flow.oidc.AuthenticationContext;
+import io.quarkiverse.flow.oidc.AuthenticationMode;
+import io.quarkiverse.flow.oidc.AuthenticationProvider;
+import io.quarkiverse.flow.oidc.client.TokenExchangeClient;
+
+/**
+ * Service-to-service authentication via a named {@code quarkus-oidc-client}. No user context, the acquired
+ * token is cached per scheme so steady-state calls do not hit the token endpoint.
+ */
+@ApplicationScoped
+public class ClientCredentialsProvider implements AuthenticationProvider {
+
+ private static final String SUBJECT_PLACEHOLDER = "client-credentials";
+
+ @Inject
+ TokenExchangeClient tokenExchangeClient;
+
+ @Inject
+ CachedTokenSource cachedTokenSource;
+
+ @Override
+ public boolean supports(AuthenticationMode mode) {
+ return mode == AuthenticationMode.CLIENT_CREDENTIALS;
+ }
+
+ @Override
+ public Optional resolveToken(AuthenticationContext context) {
+ return cachedTokenSource.getOrAcquire(context, SUBJECT_PLACEHOLDER, tokenExchangeClient::clientCredentials);
+ }
+}
diff --git a/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/providers/ProviderTokens.java b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/providers/ProviderTokens.java
new file mode 100644
index 000000000..9ac622e34
--- /dev/null
+++ b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/providers/ProviderTokens.java
@@ -0,0 +1,29 @@
+package io.quarkiverse.flow.oidc.providers;
+
+import java.time.Instant;
+import java.util.Set;
+
+import io.quarkiverse.flow.oidc.cache.CachedToken;
+import io.quarkiverse.flow.oidc.cache.TokenCacheKey;
+import io.quarkus.oidc.client.Tokens;
+
+/**
+ * Helpers shared by the OIDC providers.
+ */
+final class ProviderTokens {
+
+ /**
+ * Fallback TTL when the token endpoint does not return an expiry, so a token is still briefly cached.
+ */
+ private static final long DEFAULT_TTL_SECONDS = 300L;
+
+ private ProviderTokens() {
+ }
+
+ static CachedToken toCachedToken(TokenCacheKey key, Tokens tokens) {
+ Instant expiresAt = tokens.getAccessTokenExpiresAt() != null
+ ? Instant.ofEpochSecond(tokens.getAccessTokenExpiresAt())
+ : Instant.now().plusSeconds(DEFAULT_TTL_SECONDS);
+ return new CachedToken(key, tokens.getAccessToken(), expiresAt, Instant.now(), Set.of());
+ }
+}
diff --git a/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/providers/TokenExchangeProvider.java b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/providers/TokenExchangeProvider.java
new file mode 100644
index 000000000..f9de81d62
--- /dev/null
+++ b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/providers/TokenExchangeProvider.java
@@ -0,0 +1,51 @@
+package io.quarkiverse.flow.oidc.providers;
+
+import java.util.Optional;
+
+import jakarta.enterprise.context.ApplicationScoped;
+import jakarta.inject.Inject;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import io.quarkiverse.flow.oidc.AuthenticationContext;
+import io.quarkiverse.flow.oidc.AuthenticationMode;
+import io.quarkiverse.flow.oidc.AuthenticationProvider;
+import io.quarkiverse.flow.oidc.client.TokenExchangeClient;
+
+/**
+ * Swaps the caller's subject token for a service-specific token via RFC 8693, using a named
+ * {@code quarkus-oidc-client}. Exchanged tokens are cached per (scheme, subject-token-hash, audience) and
+ * linked to the workflow instance so they are evicted when the instance terminates.
+ */
+@ApplicationScoped
+public class TokenExchangeProvider implements AuthenticationProvider {
+
+ private static final Logger log = LoggerFactory.getLogger(TokenExchangeProvider.class);
+
+ @Inject
+ TokenExchangeClient tokenExchangeClient;
+
+ @Inject
+ CachedTokenSource cachedTokenSource;
+
+ @Override
+ public boolean supports(AuthenticationMode mode) {
+ return mode == AuthenticationMode.TOKEN_EXCHANGE;
+ }
+
+ @Override
+ public Optional resolveToken(AuthenticationContext context) {
+ log.info("Running ClientCredentialsProvider");
+ Optional subjectToken = context.subjectToken();
+ if (subjectToken.isEmpty()) {
+ log.warn("Flow OIDC: no subject token for exchange scheme '{}'; request sent without Authorization.",
+ context.schemeName());
+ return Optional.empty();
+ }
+
+ String token = subjectToken.get();
+ return cachedTokenSource.getOrAcquire(context, token,
+ client -> tokenExchangeClient.exchange(client, token, Optional.empty(), Optional.empty()));
+ }
+}
diff --git a/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/providers/TokenPropagationProvider.java b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/providers/TokenPropagationProvider.java
new file mode 100644
index 000000000..13239a122
--- /dev/null
+++ b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/providers/TokenPropagationProvider.java
@@ -0,0 +1,23 @@
+package io.quarkiverse.flow.oidc.providers;
+
+import java.util.Optional;
+
+import jakarta.enterprise.context.ApplicationScoped;
+
+import io.quarkiverse.flow.oidc.AuthenticationContext;
+import io.quarkiverse.flow.oidc.AuthenticationMode;
+import io.quarkiverse.flow.oidc.AuthenticationProvider;
+
+@ApplicationScoped
+public class TokenPropagationProvider implements AuthenticationProvider {
+
+ @Override
+ public boolean supports(AuthenticationMode mode) {
+ return mode == AuthenticationMode.TOKEN_PROPAGATION;
+ }
+
+ @Override
+ public Optional resolveToken(AuthenticationContext context) {
+ return context.subjectToken();
+ }
+}
diff --git a/oidc/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/oidc/runtime/src/main/resources/META-INF/quarkus-extension.yaml
new file mode 100644
index 000000000..c8e6eff68
--- /dev/null
+++ b/oidc/runtime/src/main/resources/META-INF/quarkus-extension.yaml
@@ -0,0 +1,18 @@
+name: Flow OIDC
+description: OIDC token propagation and exchange for Quarkus Flow workflow tasks.
+artifact: ${project.groupId}:${project.artifactId}:${project.version}
+metadata:
+ keywords:
+ - workflow
+ - workflows
+ - cncf
+ - serverless
+ - oidc
+ - oauth2
+ - security
+ - token-exchange
+ guide: https://docs.quarkiverse.io/quarkus-flow/dev/
+ categories:
+ - "security"
+ - "integration"
+ status: "preview"
diff --git a/oidc/runtime/src/main/resources/META-INF/services/io.serverlessworkflow.impl.executors.http.HttpRequestDecorator b/oidc/runtime/src/main/resources/META-INF/services/io.serverlessworkflow.impl.executors.http.HttpRequestDecorator
new file mode 100644
index 000000000..d3c708834
--- /dev/null
+++ b/oidc/runtime/src/main/resources/META-INF/services/io.serverlessworkflow.impl.executors.http.HttpRequestDecorator
@@ -0,0 +1 @@
+io.quarkiverse.flow.oidc.AuthenticationRequestDecorator
diff --git a/pom.xml b/pom.xml
index f6ada514e..36ed56fdd 100644
--- a/pom.xml
+++ b/pom.xml
@@ -24,6 +24,7 @@
bom
quarkus-platform-checks
runner
+ oidc
docs-rag
@@ -43,7 +44,7 @@
3.33.2
3.6.0
- 7.23.0.Final
+ 8.0.0-SNAPSHOT
4.1.1
2.5.1