diff --git a/bom/pom.xml b/bom/pom.xml index dd5273f40..4cb2122fc 100644 --- a/bom/pom.xml +++ b/bom/pom.xml @@ -85,6 +85,16 @@ quarkus-flow-messaging-deployment ${project.version} + + io.quarkiverse.flow + quarkus-flow-oidc + ${project.version} + + + io.quarkiverse.flow + quarkus-flow-oidc-deployment + ${project.version} + io.quarkiverse.flow diff --git a/core/deployment/pom.xml b/core/deployment/pom.xml index b834e5faa..ebe1f6552 100644 --- a/core/deployment/pom.xml +++ b/core/deployment/pom.xml @@ -55,6 +55,12 @@ ${project.version} true + + io.quarkiverse.flow + quarkus-flow-oidc-deployment + ${project.version} + true + com.github.zafarkhaja java-semver diff --git a/core/runtime/pom.xml b/core/runtime/pom.xml index 1ad874c40..647fe1af1 100644 --- a/core/runtime/pom.xml +++ b/core/runtime/pom.xml @@ -85,6 +85,12 @@ ${project.version} true + + io.quarkiverse.flow + quarkus-flow-oidc + ${project.version} + true + @@ -150,6 +156,8 @@ ${project.groupId}:${project.artifactId}-messaging:${project.version} + ${project.groupId}:${project.artifactId}-oidc:${project.version} + ${project.groupId}:${project.artifactId}-deployment:${project.version} diff --git a/core/runtime/src/main/java/io/quarkiverse/flow/providers/MetadataPropagationRequestDecorator.java b/core/runtime/src/main/java/io/quarkiverse/flow/providers/MetadataPropagationRequestDecorator.java index 05894fca0..e7be4992a 100644 --- a/core/runtime/src/main/java/io/quarkiverse/flow/providers/MetadataPropagationRequestDecorator.java +++ b/core/runtime/src/main/java/io/quarkiverse/flow/providers/MetadataPropagationRequestDecorator.java @@ -27,4 +27,9 @@ public void decorate(Invocation.Builder requestBuilder, WorkflowContext workflow .header(X_FLOW_TASK_ID, taskContext.position().jsonPointer()); } } + + @Override + public int priority() { + return 50; + } } diff --git a/core/runtime/src/main/java/io/quarkiverse/flow/recorders/WorkflowApplicationCreator.java b/core/runtime/src/main/java/io/quarkiverse/flow/recorders/WorkflowApplicationCreator.java index e55fc0d24..4817f47df 100644 --- a/core/runtime/src/main/java/io/quarkiverse/flow/recorders/WorkflowApplicationCreator.java +++ b/core/runtime/src/main/java/io/quarkiverse/flow/recorders/WorkflowApplicationCreator.java @@ -29,6 +29,7 @@ import io.serverlessworkflow.api.types.CallHTTP; import io.serverlessworkflow.api.types.CallOpenAPI; import io.serverlessworkflow.api.types.TaskBase; +import io.serverlessworkflow.impl.ContextPropagator; import io.serverlessworkflow.impl.WorkflowApplication; import io.serverlessworkflow.impl.WorkflowApplication.Builder; import io.serverlessworkflow.impl.WorkflowModel; @@ -54,6 +55,10 @@ public class WorkflowApplicationCreator { @Inject QuarkusManagedExecutorServiceFactory executorServiceFactory; + @Inject + @Any + Instance contextPropagators; + @Inject JQScopeSupplier jqScopeSupplier; @@ -118,6 +123,7 @@ public WorkflowApplication create(boolean isMicrometerSupported) { injectAppId(builder); injectExecutorServiceFactory(builder); + injectContextPropagator(builder); injectJQExpressionFactory(builder); injectEventConsumers(builder); injectEventPublishers(builder); @@ -142,6 +148,19 @@ private void injectExecutorServiceFactory(Builder builder) { LOG.debug("Flow: Bound ExecutorServiceFactory bean: {}", executorServiceFactory.getClass().getName()); } + private void injectContextPropagator(Builder builder) { + if (contextPropagators.isResolvable()) { + ContextPropagator contextPropagator = contextPropagators.get(); + builder.withContextPropagator(contextPropagator); + LOG.debug("Flow: Bound ContextPropagator bean: {}", contextPropagator.getClass().getName()); + } else if (contextPropagators.isAmbiguous()) { + throw new IllegalStateException( + "Multiple ContextPropagator beans found. Provide exactly one."); + } else { + LOG.debug("Flow: No ContextPropagator bean found; caller context propagation disabled (NOOP)."); + } + } + private void injectCustomListeners(Builder builder) { final Set> internalListeners = Set.of( TraceLoggerExecutionListener.class, diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index dad415a15..c319010bf 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -23,6 +23,7 @@ ** External Integrations *** xref:http-openapi-tasks.adoc[Call HTTP and OpenAPI services] *** xref:http-client.adoc[Configure the HTTP client] +*** xref:oidc.adoc[Authenticate downstream calls with OIDC] *** xref:grpc.adoc[Call gRPC services with Quarkus channels] *** xref:fault-tolerance.adoc[Fault tolerance and resilience] *** xref:messaging.adoc[Use messaging and events] diff --git a/docs/modules/ROOT/pages/includes/quarkus-flow-oidc.adoc b/docs/modules/ROOT/pages/includes/quarkus-flow-oidc.adoc new file mode 100644 index 000000000..b4fb937fe --- /dev/null +++ b/docs/modules/ROOT/pages/includes/quarkus-flow-oidc.adoc @@ -0,0 +1,219 @@ +[.configuration-legend] +icon:lock[title=Fixed at build time] Configuration property fixed at build time - All other configuration properties are overridable at runtime +[.configuration-reference.searchable, cols="80,.^10,.^10"] +|=== + +h|[.header-title]##Configuration property## +h|Type +h|Default + +a| [[quarkus-flow-oidc_quarkus-flow-oidc-token-exchange-enabled]] [.property-path]##link:#quarkus-flow-oidc_quarkus-flow-oidc-token-exchange-enabled[`+++quarkus.flow.oidc.token-exchange.enabled+++`]## +ifdef::add-copy-button-to-config-props[] +config_property_copy_button:+++quarkus.flow.oidc.token-exchange.enabled+++[] +endif::add-copy-button-to-config-props[] + + +[.description] +-- +Whether token exchange is enabled globally. When `false`, schemes fall back to client-credentials unless they explicitly enable exchange or propagation. + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_FLOW_OIDC_TOKEN_EXCHANGE_ENABLED+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_FLOW_OIDC_TOKEN_EXCHANGE_ENABLED+++` +endif::add-copy-button-to-env-var[] +-- +|boolean +|`+++true+++` + +a| [[quarkus-flow-oidc_quarkus-flow-oidc-token-exchange-proactive-refresh-seconds]] [.property-path]##link:#quarkus-flow-oidc_quarkus-flow-oidc-token-exchange-proactive-refresh-seconds[`+++quarkus.flow.oidc.token-exchange.proactive-refresh-seconds+++`]## +ifdef::add-copy-button-to-config-props[] +config_property_copy_button:+++quarkus.flow.oidc.token-exchange.proactive-refresh-seconds+++[] +endif::add-copy-button-to-config-props[] + + +[.description] +-- +How many seconds before expiry a cached token is proactively refreshed. + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_FLOW_OIDC_TOKEN_EXCHANGE_PROACTIVE_REFRESH_SECONDS+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_FLOW_OIDC_TOKEN_EXCHANGE_PROACTIVE_REFRESH_SECONDS+++` +endif::add-copy-button-to-env-var[] +-- +|link:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html[Duration] link:#duration-note-anchor-quarkus-flow-oidc_quarkus-flow[icon:question-circle[title=More information about the Duration format]] +|`+++PT300S+++` + +a| [[quarkus-flow-oidc_quarkus-flow-oidc-token-exchange-monitor-rate-seconds]] [.property-path]##link:#quarkus-flow-oidc_quarkus-flow-oidc-token-exchange-monitor-rate-seconds[`+++quarkus.flow.oidc.token-exchange.monitor-rate-seconds+++`]## +ifdef::add-copy-button-to-config-props[] +config_property_copy_button:+++quarkus.flow.oidc.token-exchange.monitor-rate-seconds+++[] +endif::add-copy-button-to-config-props[] + + +[.description] +-- +How often (seconds) the proactive-refresh monitor runs. + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_FLOW_OIDC_TOKEN_EXCHANGE_MONITOR_RATE_SECONDS+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_FLOW_OIDC_TOKEN_EXCHANGE_MONITOR_RATE_SECONDS+++` +endif::add-copy-button-to-env-var[] +-- +|link:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html[Duration] link:#duration-note-anchor-quarkus-flow-oidc_quarkus-flow[icon:question-circle[title=More information about the Duration format]] +|`+++PT60S+++` + +a| [[quarkus-flow-oidc_quarkus-flow-oidc-subject-token-input-key]] [.property-path]##link:#quarkus-flow-oidc_quarkus-flow-oidc-subject-token-input-key[`+++quarkus.flow.oidc.subject-token.input-key+++`]## +ifdef::add-copy-button-to-config-props[] +config_property_copy_button:+++quarkus.flow.oidc.subject-token.input-key+++[] +endif::add-copy-button-to-config-props[] + + +[.description] +-- +Workflow input key holding the subject token (for programmatic / non-HTTP triggers). + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_FLOW_OIDC_SUBJECT_TOKEN_INPUT_KEY+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_FLOW_OIDC_SUBJECT_TOKEN_INPUT_KEY+++` +endif::add-copy-button-to-env-var[] +-- +|string +|`+++subjectToken+++` + +a| [[quarkus-flow-oidc_quarkus-flow-oidc-subject-token-security-identity-attribute]] [.property-path]##link:#quarkus-flow-oidc_quarkus-flow-oidc-subject-token-security-identity-attribute[`+++quarkus.flow.oidc.subject-token.security-identity-attribute+++`]## +ifdef::add-copy-button-to-config-props[] +config_property_copy_button:+++quarkus.flow.oidc.subject-token.security-identity-attribute+++[] +endif::add-copy-button-to-config-props[] + + +[.description] +-- +`SecurityIdentity` attribute holding the subject token (for HTTP-triggered workflows). + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_FLOW_OIDC_SUBJECT_TOKEN_SECURITY_IDENTITY_ATTRIBUTE+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_FLOW_OIDC_SUBJECT_TOKEN_SECURITY_IDENTITY_ATTRIBUTE+++` +endif::add-copy-button-to-env-var[] +-- +|string +|`+++access_token+++` + +a| [[quarkus-flow-oidc_quarkus-flow-oidc-auth-auth-oidc-client-name]] [.property-path]##link:#quarkus-flow-oidc_quarkus-flow-oidc-auth-auth-oidc-client-name[`+++quarkus.flow.oidc.auth."auth".oidc-client-name+++`]## +ifdef::add-copy-button-to-config-props[] +config_property_copy_button:+++quarkus.flow.oidc.auth."auth".oidc-client-name+++[] +endif::add-copy-button-to-config-props[] + + +[.description] +-- +Name of the `quarkus.oidc-client.` to use for exchange and client-credentials. Defaults to the scheme name. + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_FLOW_OIDC_AUTH__AUTH__OIDC_CLIENT_NAME+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_FLOW_OIDC_AUTH__AUTH__OIDC_CLIENT_NAME+++` +endif::add-copy-button-to-env-var[] +-- +|string +| + +a| [[quarkus-flow-oidc_quarkus-flow-oidc-auth-auth-token-exchange-enabled]] [.property-path]##link:#quarkus-flow-oidc_quarkus-flow-oidc-auth-auth-token-exchange-enabled[`+++quarkus.flow.oidc.auth."auth".token-exchange-enabled+++`]## +ifdef::add-copy-button-to-config-props[] +config_property_copy_button:+++quarkus.flow.oidc.auth."auth".token-exchange-enabled+++[] +endif::add-copy-button-to-config-props[] + + +[.description] +-- +Force token exchange on/off for this scheme, overriding the global setting and smart default. + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_FLOW_OIDC_AUTH__AUTH__TOKEN_EXCHANGE_ENABLED+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_FLOW_OIDC_AUTH__AUTH__TOKEN_EXCHANGE_ENABLED+++` +endif::add-copy-button-to-env-var[] +-- +|boolean +| + +a| [[quarkus-flow-oidc_quarkus-flow-oidc-auth-auth-proactive-refresh-seconds]] [.property-path]##link:#quarkus-flow-oidc_quarkus-flow-oidc-auth-auth-proactive-refresh-seconds[`+++quarkus.flow.oidc.auth."auth".proactive-refresh-seconds+++`]## +ifdef::add-copy-button-to-config-props[] +config_property_copy_button:+++quarkus.flow.oidc.auth."auth".proactive-refresh-seconds+++[] +endif::add-copy-button-to-config-props[] + + +[.description] +-- +Per-scheme proactive-refresh threshold (seconds), overriding the global value. + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_FLOW_OIDC_AUTH__AUTH__PROACTIVE_REFRESH_SECONDS+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_FLOW_OIDC_AUTH__AUTH__PROACTIVE_REFRESH_SECONDS+++` +endif::add-copy-button-to-env-var[] +-- +|int +| + +a| [[quarkus-flow-oidc_quarkus-flow-oidc-auth-auth-token-propagation-enabled]] [.property-path]##link:#quarkus-flow-oidc_quarkus-flow-oidc-auth-auth-token-propagation-enabled[`+++quarkus.flow.oidc.auth."auth".token-propagation-enabled+++`]## +ifdef::add-copy-button-to-config-props[] +config_property_copy_button:+++quarkus.flow.oidc.auth."auth".token-propagation-enabled+++[] +endif::add-copy-button-to-config-props[] + + +[.description] +-- +Forward the caller's subject token unchanged. Takes precedence over exchange when enabled. + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_FLOW_OIDC_AUTH__AUTH__TOKEN_PROPAGATION_ENABLED+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_FLOW_OIDC_AUTH__AUTH__TOKEN_PROPAGATION_ENABLED+++` +endif::add-copy-button-to-env-var[] +-- +|boolean +| + +|=== + +ifndef::no-duration-note[] +[NOTE] +[id=duration-note-anchor-quarkus-flow-oidc_quarkus-flow] +.About the Duration format +==== +To write duration values, use the standard `java.time.Duration` format. +See the link:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html#parse(java.lang.CharSequence)[Duration#parse() Java API documentation] for more information. + +You can also use a simplified format, starting with a number: + +* If the value is only a number, it represents time in seconds. +* If the value is a number followed by `ms`, it represents time in milliseconds. + +In other cases, the simplified format is translated to the `java.time.Duration` format for parsing: + +* If the value is a number followed by `h`, `m`, or `s`, it is prefixed with `PT`. +* If the value is a number followed by `d`, it is prefixed with `P`. +==== +endif::no-duration-note[] diff --git a/docs/modules/ROOT/pages/includes/quarkus-flow-oidc_quarkus.flow.adoc b/docs/modules/ROOT/pages/includes/quarkus-flow-oidc_quarkus.flow.adoc new file mode 100644 index 000000000..b4fb937fe --- /dev/null +++ b/docs/modules/ROOT/pages/includes/quarkus-flow-oidc_quarkus.flow.adoc @@ -0,0 +1,219 @@ +[.configuration-legend] +icon:lock[title=Fixed at build time] Configuration property fixed at build time - All other configuration properties are overridable at runtime +[.configuration-reference.searchable, cols="80,.^10,.^10"] +|=== + +h|[.header-title]##Configuration property## +h|Type +h|Default + +a| [[quarkus-flow-oidc_quarkus-flow-oidc-token-exchange-enabled]] [.property-path]##link:#quarkus-flow-oidc_quarkus-flow-oidc-token-exchange-enabled[`+++quarkus.flow.oidc.token-exchange.enabled+++`]## +ifdef::add-copy-button-to-config-props[] +config_property_copy_button:+++quarkus.flow.oidc.token-exchange.enabled+++[] +endif::add-copy-button-to-config-props[] + + +[.description] +-- +Whether token exchange is enabled globally. When `false`, schemes fall back to client-credentials unless they explicitly enable exchange or propagation. + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_FLOW_OIDC_TOKEN_EXCHANGE_ENABLED+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_FLOW_OIDC_TOKEN_EXCHANGE_ENABLED+++` +endif::add-copy-button-to-env-var[] +-- +|boolean +|`+++true+++` + +a| [[quarkus-flow-oidc_quarkus-flow-oidc-token-exchange-proactive-refresh-seconds]] [.property-path]##link:#quarkus-flow-oidc_quarkus-flow-oidc-token-exchange-proactive-refresh-seconds[`+++quarkus.flow.oidc.token-exchange.proactive-refresh-seconds+++`]## +ifdef::add-copy-button-to-config-props[] +config_property_copy_button:+++quarkus.flow.oidc.token-exchange.proactive-refresh-seconds+++[] +endif::add-copy-button-to-config-props[] + + +[.description] +-- +How many seconds before expiry a cached token is proactively refreshed. + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_FLOW_OIDC_TOKEN_EXCHANGE_PROACTIVE_REFRESH_SECONDS+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_FLOW_OIDC_TOKEN_EXCHANGE_PROACTIVE_REFRESH_SECONDS+++` +endif::add-copy-button-to-env-var[] +-- +|link:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html[Duration] link:#duration-note-anchor-quarkus-flow-oidc_quarkus-flow[icon:question-circle[title=More information about the Duration format]] +|`+++PT300S+++` + +a| [[quarkus-flow-oidc_quarkus-flow-oidc-token-exchange-monitor-rate-seconds]] [.property-path]##link:#quarkus-flow-oidc_quarkus-flow-oidc-token-exchange-monitor-rate-seconds[`+++quarkus.flow.oidc.token-exchange.monitor-rate-seconds+++`]## +ifdef::add-copy-button-to-config-props[] +config_property_copy_button:+++quarkus.flow.oidc.token-exchange.monitor-rate-seconds+++[] +endif::add-copy-button-to-config-props[] + + +[.description] +-- +How often (seconds) the proactive-refresh monitor runs. + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_FLOW_OIDC_TOKEN_EXCHANGE_MONITOR_RATE_SECONDS+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_FLOW_OIDC_TOKEN_EXCHANGE_MONITOR_RATE_SECONDS+++` +endif::add-copy-button-to-env-var[] +-- +|link:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html[Duration] link:#duration-note-anchor-quarkus-flow-oidc_quarkus-flow[icon:question-circle[title=More information about the Duration format]] +|`+++PT60S+++` + +a| [[quarkus-flow-oidc_quarkus-flow-oidc-subject-token-input-key]] [.property-path]##link:#quarkus-flow-oidc_quarkus-flow-oidc-subject-token-input-key[`+++quarkus.flow.oidc.subject-token.input-key+++`]## +ifdef::add-copy-button-to-config-props[] +config_property_copy_button:+++quarkus.flow.oidc.subject-token.input-key+++[] +endif::add-copy-button-to-config-props[] + + +[.description] +-- +Workflow input key holding the subject token (for programmatic / non-HTTP triggers). + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_FLOW_OIDC_SUBJECT_TOKEN_INPUT_KEY+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_FLOW_OIDC_SUBJECT_TOKEN_INPUT_KEY+++` +endif::add-copy-button-to-env-var[] +-- +|string +|`+++subjectToken+++` + +a| [[quarkus-flow-oidc_quarkus-flow-oidc-subject-token-security-identity-attribute]] [.property-path]##link:#quarkus-flow-oidc_quarkus-flow-oidc-subject-token-security-identity-attribute[`+++quarkus.flow.oidc.subject-token.security-identity-attribute+++`]## +ifdef::add-copy-button-to-config-props[] +config_property_copy_button:+++quarkus.flow.oidc.subject-token.security-identity-attribute+++[] +endif::add-copy-button-to-config-props[] + + +[.description] +-- +`SecurityIdentity` attribute holding the subject token (for HTTP-triggered workflows). + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_FLOW_OIDC_SUBJECT_TOKEN_SECURITY_IDENTITY_ATTRIBUTE+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_FLOW_OIDC_SUBJECT_TOKEN_SECURITY_IDENTITY_ATTRIBUTE+++` +endif::add-copy-button-to-env-var[] +-- +|string +|`+++access_token+++` + +a| [[quarkus-flow-oidc_quarkus-flow-oidc-auth-auth-oidc-client-name]] [.property-path]##link:#quarkus-flow-oidc_quarkus-flow-oidc-auth-auth-oidc-client-name[`+++quarkus.flow.oidc.auth."auth".oidc-client-name+++`]## +ifdef::add-copy-button-to-config-props[] +config_property_copy_button:+++quarkus.flow.oidc.auth."auth".oidc-client-name+++[] +endif::add-copy-button-to-config-props[] + + +[.description] +-- +Name of the `quarkus.oidc-client.` to use for exchange and client-credentials. Defaults to the scheme name. + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_FLOW_OIDC_AUTH__AUTH__OIDC_CLIENT_NAME+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_FLOW_OIDC_AUTH__AUTH__OIDC_CLIENT_NAME+++` +endif::add-copy-button-to-env-var[] +-- +|string +| + +a| [[quarkus-flow-oidc_quarkus-flow-oidc-auth-auth-token-exchange-enabled]] [.property-path]##link:#quarkus-flow-oidc_quarkus-flow-oidc-auth-auth-token-exchange-enabled[`+++quarkus.flow.oidc.auth."auth".token-exchange-enabled+++`]## +ifdef::add-copy-button-to-config-props[] +config_property_copy_button:+++quarkus.flow.oidc.auth."auth".token-exchange-enabled+++[] +endif::add-copy-button-to-config-props[] + + +[.description] +-- +Force token exchange on/off for this scheme, overriding the global setting and smart default. + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_FLOW_OIDC_AUTH__AUTH__TOKEN_EXCHANGE_ENABLED+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_FLOW_OIDC_AUTH__AUTH__TOKEN_EXCHANGE_ENABLED+++` +endif::add-copy-button-to-env-var[] +-- +|boolean +| + +a| [[quarkus-flow-oidc_quarkus-flow-oidc-auth-auth-proactive-refresh-seconds]] [.property-path]##link:#quarkus-flow-oidc_quarkus-flow-oidc-auth-auth-proactive-refresh-seconds[`+++quarkus.flow.oidc.auth."auth".proactive-refresh-seconds+++`]## +ifdef::add-copy-button-to-config-props[] +config_property_copy_button:+++quarkus.flow.oidc.auth."auth".proactive-refresh-seconds+++[] +endif::add-copy-button-to-config-props[] + + +[.description] +-- +Per-scheme proactive-refresh threshold (seconds), overriding the global value. + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_FLOW_OIDC_AUTH__AUTH__PROACTIVE_REFRESH_SECONDS+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_FLOW_OIDC_AUTH__AUTH__PROACTIVE_REFRESH_SECONDS+++` +endif::add-copy-button-to-env-var[] +-- +|int +| + +a| [[quarkus-flow-oidc_quarkus-flow-oidc-auth-auth-token-propagation-enabled]] [.property-path]##link:#quarkus-flow-oidc_quarkus-flow-oidc-auth-auth-token-propagation-enabled[`+++quarkus.flow.oidc.auth."auth".token-propagation-enabled+++`]## +ifdef::add-copy-button-to-config-props[] +config_property_copy_button:+++quarkus.flow.oidc.auth."auth".token-propagation-enabled+++[] +endif::add-copy-button-to-config-props[] + + +[.description] +-- +Forward the caller's subject token unchanged. Takes precedence over exchange when enabled. + + +ifdef::add-copy-button-to-env-var[] +Environment variable: env_var_with_copy_button:+++QUARKUS_FLOW_OIDC_AUTH__AUTH__TOKEN_PROPAGATION_ENABLED+++[] +endif::add-copy-button-to-env-var[] +ifndef::add-copy-button-to-env-var[] +Environment variable: `+++QUARKUS_FLOW_OIDC_AUTH__AUTH__TOKEN_PROPAGATION_ENABLED+++` +endif::add-copy-button-to-env-var[] +-- +|boolean +| + +|=== + +ifndef::no-duration-note[] +[NOTE] +[id=duration-note-anchor-quarkus-flow-oidc_quarkus-flow] +.About the Duration format +==== +To write duration values, use the standard `java.time.Duration` format. +See the link:https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Duration.html#parse(java.lang.CharSequence)[Duration#parse() Java API documentation] for more information. + +You can also use a simplified format, starting with a number: + +* If the value is only a number, it represents time in seconds. +* If the value is a number followed by `ms`, it represents time in milliseconds. + +In other cases, the simplified format is translated to the `java.time.Duration` format for parsing: + +* If the value is a number followed by `h`, `m`, or `s`, it is prefixed with `PT`. +* If the value is a number followed by `d`, it is prefixed with `P`. +==== +endif::no-duration-note[] diff --git a/docs/modules/ROOT/pages/oidc.adoc b/docs/modules/ROOT/pages/oidc.adoc new file mode 100644 index 000000000..40ba81d81 --- /dev/null +++ b/docs/modules/ROOT/pages/oidc.adoc @@ -0,0 +1,422 @@ += Authenticate downstream calls with OIDC and Token propagation +:page-role: howto +include::includes/attributes.adoc[] + +When a workflow calls a protected HTTP service, the service typically expects a bearer token +in the `Authorization` header. The `quarkus-flow-oidc` module handles this automatically. + +You reference a named scheme on the task (`FuncDSL.use("myScheme")`) and configure it in `application.properties`. +At runtime, Flow OIDC adds `Authorization: Bearer ` using one of three strategies: + +[cols="1,3,2", options="header"] +|=== +| Mode | What it does | Typical use + +| `client-credentials` +| Acquires a service-to-service token from a named `quarkus-oidc-client` via the link:https://datatracker.ietf.org/doc/html/rfc6749#section-4.4[OAuth 2.0 client credentials grant]. No user context. +| Background / scheduled workflows, system-to-system calls. + +| `token-propagation` +| Forwards the caller's subject token unchanged. No OIDC round-trip. +| Downstream service trusts the same issuer as the caller. + +| `token-exchange` +| Swaps the caller's subject token for a service-specific token via link:https://datatracker.ietf.org/doc/html/rfc8693[RFC 8693], using a named `quarkus-oidc-client`. +| Downstream service needs a different audience/scope than the inbound token. +|=== + +[IMPORTANT] +==== +Flow OIDC is about **outbound** authentication (the tokens your workflow *sends*). +Securing the **inbound** endpoint that *triggers* a workflow is still the job of your application code. +The two concerns stay separate: Flow OIDC only *reads* the inbound identity to obtain a subject token when it needs one, +in other words, *it never authenticates the caller*. +==== + +[NOTE] +==== +Every workflow that references a Flow OIDC scheme must declare an *empty* workflow-level `authentications` container with `.use(use -> use.authentications(auth -> {}))`. +This is what lets Flow OIDC's request decorator own the call and resolve schemes per task from configuration; without it, the scheme referenced by `use("")` is not applied. +You keep the container empty on purpose — scheme behaviour is driven entirely by `quarkus.flow.oidc.auth.*` in `application.properties`. See <> for a full example. +==== + +== Why this module exists + +Once workflows start calling secured APIs, a few problems show up that Flow OIDC is designed to handle: + +* **Long-running workflows** whose tokens would otherwise expire before the workflow completes. +* **Security isolation** — avoid forwarding the original user token to third-party services that shouldn't see it. +* **Multi-tenant / multi-issuer** setups that need different tokens or scopes per downstream service. +* **Service-to-service** calls that run with no user context at all. + +== Prerequisites + +* A Quarkus application with xref:getting-started.adoc[Quarkus Flow set up]. +* Familiarity with xref:http-openapi-tasks.adoc[calling HTTP and OpenAPI services]. +* An OIDC provider (Keycloak, etc.) reachable from your application. + +== 1. Add the dependency + +[source,xml] +.pom.xml +---- + + io.quarkus + quarkus-oidc-client + + + + io.quarkus + quarkus-oidc + +---- +<1> Enables the `quarkus-flow-oidc` module (activated by the presence of `quarkus-oidc-client`). +<2> Only used to secure and simulate the protected downstream; not required by `quarkus-flow-oidc` itself. + +== 2. Add a protected service to call + +The tutorial's workflow calls a downstream HTTP service that expects a bearer token. +Add this resource to your application so there is something to authenticate against: it is guarded by +`@Authenticated` (so unauthenticated calls are rejected) and logs the incoming `Authorization` header +so you can confirm Flow OIDC attached a token. + +[source,java] +.ProtectedOrderResource.java +---- +import io.quarkus.logging.Log; +import io.quarkus.security.Authenticated; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.core.Response; + +import java.util.List; + +@Path("/orders") +public class ProtectedOrderResource { + + @GET + @Authenticated + public Response orders(@HeaderParam("Authorization") String authorization) { + Log.infov("Receiving Authorization header: {0}", authorization); + return Response.ok(List.of( + "ORDER#001", + "ORDER#002" + )).build(); + } +} +---- + +This is the `http://localhost:8080/orders` endpoint that `BackendFlow` calls in <>. +It is guarded by `@Authenticated`, which relies on the `quarkus-oidc` extension added above. + +[[client-credentials]] +== 3. Your first authenticated call (client-credentials) + +This is the shortest end-to-end path: a workflow that calls a protected service using a service account, with no user involved. + +=== Step 1 — Start Keycloak with Dev Services + +When `quarkus-oidc-client` is on the classpath and no `auth-server-url` is configured, +Quarkus automatically starts a Keycloak container in dev and test mode and injects a ready-to-use OIDC client. + +[source,properties] +.Provided automatically by Keycloak Dev Services +---- +quarkus.oidc-client.auth-server-url=http://localhost:/realms/quarkus +quarkus.oidc-client.client-id=quarkus-app +quarkus.oidc-client.credentials.secret=secret +quarkus.oidc-client.token-path=/protocol/openid-connect/tokens +# Keycloak realm "quarkus" with seeded users alice/alice and bob/bob +---- + +These configure the *default* (unnamed) OIDC client. + +=== Step 2 — Define a named client and map the scheme to it + +A Flow OIDC scheme always resolves its client *by name*, so define a named `backend` client that reuses the Keycloak that Dev Services started, +then point the scheme at it: + +[source,properties] +.application.properties +---- +# Reuse the Keycloak started by Dev Services +quarkus.oidc-client.backend.auth-server-url=${quarkus.oidc-client.auth-server-url} +quarkus.oidc-client.backend.client-id=${quarkus.oidc-client.client-id} +quarkus.oidc-client.backend.credentials.secret=${quarkus.oidc-client.credentials.secret} +quarkus.oidc-client.backend.grant.type=client + +# Map the Flow OIDC scheme to that named client +quarkus.flow.oidc.auth.backend.oidc-client-name=backend +---- + +With no subject token present and no propagation enabled, Flow OIDC resolves this scheme to **client-credentials** automatically (see <>). + +=== Step 3 — Reference the scheme from the task + +Attach the scheme to the HTTP call with `FuncDSL.use("")`: + +[source,java] +.BackendFlow.java +---- +import io.quarkiverse.flow.Flow; +import io.serverlessworkflow.api.types.Workflow; +import io.serverlessworkflow.fluent.func.FuncWorkflowBuilder; +import jakarta.enterprise.context.ApplicationScoped; + +import java.net.URI; + +import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.call; +import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.http; +import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.use; + +@ApplicationScoped +public class BackendFlow extends Flow { + + @Override + public Workflow descriptor() { + return FuncWorkflowBuilder.workflow("backend-call", "quarkus-flow") + .use(use -> use.authentications(auth -> {})) // <1> + .tasks(call( + http("fetchOrders") + .GET() + .uri(URI.create("http://localhost:8080/orders"), + use("backend")))) // <2> + .build(); + } +} +---- +<1> *Required.* Declares an empty workflow-level `authentications` container. Flow OIDC resolves a scheme per task through its HTTP request decorator, and the decorator only owns the call when the referenced name is *config-only* that is, not declared under `use.authentications`. + +<2> `FuncDSL.use("backend")` matches `quarkus.flow.oidc.auth.backend.*`. +Flow OIDC acquires a token from the `backend` oidc-client and adds the `Authorization` header before the request leaves. + +That's it. When `fetchOrders` runs, the outgoing request carries `Authorization: Bearer `. + +== 4. How-to: forward the caller's token (token-propagation) + +Token propagation relays the caller's existing access token to the downstream service, **unchanged**. +There is no OIDC round-trip, no `quarkus-oidc-client`, and no token caching, +Flow OIDC simply copies the subject token onto the outgoing `Authorization` header. + +[WARNING] +==== +Propagation forwards the *raw* user token. +Only enable it for services you trust, since they receive a credential that can act as the user for the lifetime of that token. +When in doubt, exchange the token instead so the downstream gets a narrowly-scoped, audience-restricted token. +==== + +=== 4.1 Enable it + +Set `token-propagation-enabled` on the scheme and reference it from the task with `use(...)`. + +[source,properties] +.application.properties +---- +quarkus.flow.oidc.auth.backend.token-propagation-enabled=true +---- + +Propagation always wins over exchange: if a scheme has both `token-propagation-enabled=true` and exchange configured, the token is propagated (see <>). + +=== 4.2 How a request flows + +[source,text] +---- +caller (user token) Flow OIDC downstream + │ triggers workflow │ │ + ├───────────────────────────────► │ + │ │ extract subject token │ + │ │ (input key │ identity) │ + │ │ │ + │ POST /flow │ Authorization: Bearer + │ ├────────────────────────►│ + │ │ │ +---- + +=== 4.3 Where the subject token comes from + +Propagation needs the caller's token. +Flow OIDC resolves it from two sources, in order (see <> for the configurable keys): + +- a. **HTTP-triggered workflows** — read from the request's `SecurityIdentity`. + +Secure the endpoint that starts the workflow with `quarkus-oidc`. +Flow OIDC reads the `access_token` attribute of the resolved identity. + +- b. **Programmatic / non-HTTP triggers** — pass the token as workflow input. + +For workflows started outside an HTTP request (messaging, scheduler, tests), put the token under the `subjectToken` input key: + +[source,java] +---- +@Inject +JsonWebToken jwt; + +@POST +@Authenticated +public Response trigger() { + WorkflowModel model = backendFlow.instance( + Map.of("subjectToken", jwt.getRawToken()) + ).start().join(); + return Response.ok(model.asJavaObject()).build(); +} +---- + +The input key takes precedence over the `SecurityIdentity` attribute, so an explicit `subjectToken` always wins. + +=== 4.4 Edge cases and caveats + +[NOTE] +==== +* **No subject token found** — Flow OIDC adds no `Authorization` header and the request goes out unauthenticated; +* **Off-request-thread execution** — `SecurityIdentity` is request-scoped. If the workflow runs asynchronously (e.g. `start()` without joining, or resumed on another thread), the identity may no longer be available. +For async or durable workflows, pass the token explicitly via the `subjectToken` input instead of relying on the identity. +* **Token expiry** — the propagated token lives and dies with the caller's session; Flow OIDC does not refresh it. +Long-running workflows that may outlive the token should use exchange or client-credentials. +==== + +[#token-exchange] +== 5. How-to: exchange the token for another audience (token-exchange) + +Use this when the downstream service requires a token with a **different audience or scope** than the inbound one. +Flow OIDC performs an RFC 8693 exchange through a named oidc-client. + +Token exchange is enabled globally by default and kicks in automatically whenever a subject token is available and propagation is *not* enabled. +To be explicit (and to pin the oidc-client used for the exchange): + +[source,properties] +.application.properties +---- +quarkus.oidc-client.exchange.auth-server-url=https://auth.example.com/realms/apps +quarkus.oidc-client.exchange.client-id=flow-exchange +quarkus.oidc-client.exchange.credentials.secret=${EXCHANGE_SECRET} + +quarkus.flow.oidc.auth.billing.oidc-client-name=exchange +quarkus.flow.oidc.auth.billing.token-exchange-enabled=true +---- + +Reference the scheme with `use("billing")`. +On the first call, Flow OIDC exchanges the caller's subject token for a service-specific one and caches the result per `(scheme, subject-token, audience)`. +The cached entry is bound to the workflow instance and evicted when the instance terminates. + +To turn exchange off globally (schemes then fall back to client-credentials unless they opt in individually): + +[source,properties] +---- +quarkus.flow.oidc.token-exchange.enabled=false +---- + +[[multiple-oidc-servers]] +== 6. How-to: target multiple OIDC servers (multi-tenant) + +Each scheme resolves its own `quarkus-oidc-client`, so different tasks can authenticate against different issuers in the same workflow. +Configure one named oidc-client per provider and point each scheme at the right one with `oidc-client-name`: + +[source,properties] +.application.properties +---- +# Provider A +quarkus.oidc-client.idp-a.auth-server-url=https://idp-a.example.com/realms/acme +quarkus.oidc-client.idp-a.client-id=workflow-runtime +quarkus.oidc-client.idp-a.credentials.secret=${IDP_A_SECRET} + +# Provider B +quarkus.oidc-client.idp-b.auth-server-url=https://idp-b.example.com/realms/other +quarkus.oidc-client.idp-b.client-id=workflow-client +quarkus.oidc-client.idp-b.credentials.secret=${IDP_B_SECRET} + +# Map each scheme to its provider +quarkus.flow.oidc.auth.service-a.oidc-client-name=idp-a +quarkus.flow.oidc.auth.service-b.oidc-client-name=idp-b +---- + +The task chooses the provider purely by which scheme it references — `use("service-a")` vs `use("service-b")`. +If `oidc-client-name` is omitted, the scheme name itself is used as the oidc-client name; +if no client is configured under that name, the call fails fast with a clear error pointing at the missing `quarkus.oidc-client..*` configuration. + +[#resolution] +== 7. How a scheme's mode is resolved + +A scheme does **not** name its mode explicitly. +Flow OIDC derives it at call time, with this precedence: + +. `token-propagation-enabled=true` → **token-propagation** (always wins). +. Otherwise, exchange applies → **token-exchange**, when: +.. the scheme sets `token-exchange-enabled=true`; or +.. exchange is enabled globally *and* a subject token is available. +. Otherwise → **client-credentials**. + +A per-scheme `token-exchange-enabled` value always overrides the global `quarkus.flow.oidc.token-exchange.enabled` flag. + +[#subject-token] +== 8. Where the subject token comes from + +For propagation and exchange, Flow OIDC needs the caller's token. +It looks, in order: + +. **Workflow input** under the key `quarkus.flow.oidc.subject-token.input-key` (default `subjectToken`) — ideal for programmatic triggers. +. **`SecurityIdentity`** attribute named by `quarkus.flow.oidc.subject-token.security-identity-attribute` (default `access_token`) — for HTTP-triggered workflows running on the request thread. + +If no subject token is found, exchange/propagation produces no header and the request goes out unauthenticated — the downstream service owns the resulting `401`. + +== 9. Token caching and lifecycle + +Client-credentials and exchanged tokens are cached so steady-state calls don't hit the token endpoint on every execution. Propagation is *not* cached — it just forwards the caller's token. + +**How entries are keyed.** Each cache entry is keyed by `(scheme, subject-token, audience)`. The subject token is stored only as a SHA-256 hash, so raw user tokens never live in the cache. This lets the same user reuse one exchanged token across workflow instances while keeping different users — and different audiences — isolated. + +**Instance linking and eviction.** A cached token is linked to the workflow instances using it. When an instance terminates (completed, failed, or cancelled), it is unlinked; once no instances reference a token, it is evicted. Tokens therefore don't outlive the work that needed them. + +**Proactive expiry handling.** A single daemon thread periodically scans for tokens nearing expiry and **evicts** them, so the next call transparently re-acquires a fresh token instead of sending one about to expire mid-request. (The current phase evicts-and-re-acquires rather than refreshing in place; this is by design.) + +[source,properties] +.application.properties +---- +# Evict a cached token this long before it expires (default PT300S = 5 min) +quarkus.flow.oidc.token-exchange.proactive-refresh-seconds=PT300S + +# How often the background monitor scans for near-expiry tokens (default PT60S) +quarkus.flow.oidc.token-exchange.monitor-rate-seconds=PT60S + +# Per-scheme override of the proactive threshold (seconds) +quarkus.flow.oidc.auth.billing.proactive-refresh-seconds=120 +---- + +[NOTE] +==== +The cache is **in-memory** and per-application: it is cleared on restart and not shared across instances. Persistent and distributed token caches are planned for a later phase. +==== + +== 10. Troubleshooting + +[cols="1,2", options="header"] +|=== +| Symptom | Likely cause and fix + +| Downstream returns `401` and no `Authorization` header was sent. +| No subject token was found for a propagation/exchange scheme. For HTTP triggers, confirm the endpoint is secured with `quarkus-oidc` and the token is exposed under the `access_token` identity attribute; for programmatic triggers, pass it under the `subjectToken` input key. See <>. + +| The wrong mode is used (e.g. exchange instead of propagation). +| Modes are derived, not declared. Re-check the precedence in <> — `token-propagation-enabled=true` always wins, and a per-scheme `token-exchange-enabled` overrides the global flag. + +| Startup/runtime error: _"OIDC client not configured: "_. +| The scheme resolved to an oidc-client name that has no `quarkus.oidc-client..*` configuration. Either add that client or set `oidc-client-name` on the scheme. See <>. + +| Token is missing only on async / background executions. +| `SecurityIdentity` is request-scoped and unavailable off the request thread. Pass the token via the `subjectToken` input instead of relying on the identity. + +| Header still absent even though the scheme is configured. +| The task must *reference* the scheme via `use("")` on the HTTP/OpenAPI endpoint, and the name must match `quarkus.flow.oidc.auth.` exactly. Schemes are only applied to `call` HTTP and OpenAPI tasks. +|=== + +== Configuration reference + +include::includes/quarkus-flow-oidc_quarkus.flow.adoc[] + +== See also + +* xref:http-openapi-tasks.adoc[Call HTTP and OpenAPI services] — defining the tasks these schemes secure. +* xref:secrets.adoc[Resolve secrets securely] — for static credentials and API keys. +* link:https://quarkus.io/guides/security-openid-connect-client-reference[Quarkus: OIDC Client reference] — configuring `quarkus-oidc-client`. +* link:https://datatracker.ietf.org/doc/html/rfc6749[RFC 6749] — The OAuth 2.0 Authorization Framework (client credentials grant: section 4.4). +* link:https://datatracker.ietf.org/doc/html/rfc8693[RFC 8693] — OAuth 2.0 Token Exchange. \ No newline at end of file diff --git a/oidc/deployment/pom.xml b/oidc/deployment/pom.xml new file mode 100644 index 000000000..9cc9d1005 --- /dev/null +++ b/oidc/deployment/pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + + io.quarkiverse.flow + quarkus-flow-oidc-parent + 1.0.0-SNAPSHOT + + + quarkus-flow-oidc-deployment + Quarkus Flow :: OIDC :: Deployment + + + + io.quarkus + quarkus-arc-deployment + + + io.quarkus + quarkus-oidc-client-deployment + + + io.quarkiverse.flow + quarkus-flow-oidc + ${project.version} + + + + + + + maven-compiler-plugin + + + default-compile + + + + io.quarkus + quarkus-extension-processor + ${quarkus.version} + + + + + + + + + + diff --git a/oidc/deployment/src/main/java/io/quarkiverse/flow/oidc/deployment/FlowOidcProcessor.java b/oidc/deployment/src/main/java/io/quarkiverse/flow/oidc/deployment/FlowOidcProcessor.java new file mode 100644 index 000000000..96899f95e --- /dev/null +++ b/oidc/deployment/src/main/java/io/quarkiverse/flow/oidc/deployment/FlowOidcProcessor.java @@ -0,0 +1,58 @@ +package io.quarkiverse.flow.oidc.deployment; + +import io.quarkiverse.flow.oidc.AuthenticationRegistry; +import io.quarkiverse.flow.oidc.QuarkusContextPropagator; +import io.quarkiverse.flow.oidc.SubjectTokenExtractor; +import io.quarkiverse.flow.oidc.cache.InMemoryTokenCacheRepository; +import io.quarkiverse.flow.oidc.cache.TokenRefreshMonitor; +import io.quarkiverse.flow.oidc.client.OidcClientProvider; +import io.quarkiverse.flow.oidc.client.TokenExchangeClient; +import io.quarkiverse.flow.oidc.config.AuthConfigResolver; +import io.quarkiverse.flow.oidc.lifecycle.TokenCleanupListener; +import io.quarkiverse.flow.oidc.providers.CachedTokenSource; +import io.quarkiverse.flow.oidc.providers.ClientCredentialsProvider; +import io.quarkiverse.flow.oidc.providers.TokenExchangeProvider; +import io.quarkiverse.flow.oidc.providers.TokenPropagationProvider; +import io.quarkus.arc.deployment.AdditionalBeanBuildItem; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.builditem.FeatureBuildItem; +import io.quarkus.deployment.builditem.nativeimage.ServiceProviderBuildItem; +import io.serverlessworkflow.impl.executors.http.HttpRequestDecorator; + +public class FlowOidcProcessor { + + private static final String FEATURE = "flow-oidc"; + + @BuildStep + FeatureBuildItem feature() { + return new FeatureBuildItem(FEATURE); + } + + @BuildStep + AdditionalBeanBuildItem beans() { + return AdditionalBeanBuildItem.builder() + .setUnremovable() + .addBeanClasses( + AuthenticationRegistry.class, + AuthConfigResolver.class, + SubjectTokenExtractor.class, + QuarkusContextPropagator.class, + OidcClientProvider.class, + TokenExchangeClient.class, + InMemoryTokenCacheRepository.class, + TokenRefreshMonitor.class, + TokenPropagationProvider.class, + ClientCredentialsProvider.class, + TokenExchangeProvider.class, + CachedTokenSource.class, + TokenCleanupListener.class) + .build(); + } + + @BuildStep + void registerDecoratorForNative(BuildProducer serviceProviders) { + serviceProviders.produce( + ServiceProviderBuildItem.allProvidersFromClassPath(HttpRequestDecorator.class.getName())); + } +} diff --git a/oidc/integration-tests/pom.xml b/oidc/integration-tests/pom.xml new file mode 100644 index 000000000..ca0007de9 --- /dev/null +++ b/oidc/integration-tests/pom.xml @@ -0,0 +1,145 @@ + + + 4.0.0 + + io.quarkiverse.flow + quarkus-flow-oidc-parent + 1.0.0-SNAPSHOT + + + true + 1.5.1 + + + quarkus-flow-oidc-integration-tests + Quarkus Flow :: OIDC :: Integration Tests + + + + io.quarkiverse.flow + quarkus-flow + ${project.version} + + + io.quarkus + quarkus-oidc-client + + + io.quarkus + quarkus-rest-jackson + + + + io.quarkus + quarkus-junit5 + test + + + io.quarkus + quarkus-junit5-mockito + test + + + org.awaitility + awaitility + test + + + io.quarkus + quarkus-test-security + test + + + io.quarkiverse.wiremock + quarkus-wiremock-test + ${quarkus.wiremock.version} + test + + + io.rest-assured + rest-assured + test + + + org.assertj + assertj-core + test + + + + + io.quarkiverse.flow + quarkus-flow-deployment + ${project.version} + test + + + io.quarkiverse.flow + quarkus-flow-oidc-deployment + ${project.version} + test + + + + + + + io.quarkus + quarkus-maven-plugin + true + + + + build + + + + + + maven-failsafe-plugin + + + + integration-test + verify + + + + + + ${project.build.directory}/${project.build.finalName}-runner + + org.jboss.logmanager.LogManager + ${maven.home} + + + + + + + + + native-image + + + native + + + + + + maven-surefire-plugin + + ${native.surefire.skip} + + + + + + false + false + true + + + + diff --git a/oidc/integration-tests/src/main/java/io/quarkiverse/flow/oidc/it/ClientCredentialsFlow.java b/oidc/integration-tests/src/main/java/io/quarkiverse/flow/oidc/it/ClientCredentialsFlow.java new file mode 100644 index 000000000..4c71b8395 --- /dev/null +++ b/oidc/integration-tests/src/main/java/io/quarkiverse/flow/oidc/it/ClientCredentialsFlow.java @@ -0,0 +1,38 @@ +package io.quarkiverse.flow.oidc.it; + +import java.net.URI; +import java.time.Duration; + +import jakarta.enterprise.context.ApplicationScoped; + +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import io.quarkiverse.flow.Flow; +import io.serverlessworkflow.api.types.Workflow; +import io.serverlessworkflow.fluent.func.FuncWorkflowBuilder; +import io.serverlessworkflow.fluent.func.dsl.FuncDSL; + +/** + * Calls a downstream service with no auth declared in the DSL. The {@code Authorization} header is attached + * by the Flow OIDC decorator (mode {@code client-credentials}, acquiring a token from a named oidc-client). + */ +@ApplicationScoped +public class ClientCredentialsFlow extends Flow { + + @ConfigProperty(name = "downstream.url") + String downstreamUrl; + + @Override + public Workflow descriptor() { + return FuncWorkflowBuilder.workflow("oidc-cc", "quarkus-flow") + .use(use -> use.authentications(auth -> { + })) + .tasks(FuncDSL.wait(Duration.ofMillis(500)), + FuncDSL.call( + FuncDSL.http("callClientCredentials") + .GET() + .uri(URI.create(downstreamUrl + "/cc/resource"), + FuncDSL.use("cc")))) + .build(); + } +} diff --git a/oidc/integration-tests/src/main/java/io/quarkiverse/flow/oidc/it/OidcFlowResource.java b/oidc/integration-tests/src/main/java/io/quarkiverse/flow/oidc/it/OidcFlowResource.java new file mode 100644 index 000000000..543cccf64 --- /dev/null +++ b/oidc/integration-tests/src/main/java/io/quarkiverse/flow/oidc/it/OidcFlowResource.java @@ -0,0 +1,51 @@ +package io.quarkiverse.flow.oidc.it; + +import java.util.Map; + +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.Response; + +import io.serverlessworkflow.impl.WorkflowModel; + +@Path("/flow-oidc") +public class OidcFlowResource { + + @Inject + PropagationFlow propagationFlow; + + @Inject + ClientCredentialsFlow clientCredentialsFlow; + + @GET + @Path("/propagation") + public Response propagation(@QueryParam("subjectToken") String subjectToken) { + WorkflowModel model = propagationFlow.instance(Map.of("subjectToken", subjectToken)).start().join(); + return Response.ok(model.asJavaObject()).build(); + } + + @GET + @Path("/client-credentials") + public Response clientCredentials(@QueryParam("async") boolean async) { + if (async) { + clientCredentialsFlow.instance().start(); + return Response.status(Response.Status.ACCEPTED).build(); + } + WorkflowModel model = clientCredentialsFlow.instance().start().join(); + return Response.ok(model.asJavaObject()).build(); + } + + @GET + @Path("/protected") + public Response securityIdentity(@QueryParam("async") boolean async) { + if (async) { + propagationFlow.instance().start(); + return Response.status(Response.Status.ACCEPTED).build(); + } + WorkflowModel model = propagationFlow.instance().start().join(); + return Response.ok(model.asJavaObject()).build(); + } + +} diff --git a/oidc/integration-tests/src/main/java/io/quarkiverse/flow/oidc/it/PropagationFlow.java b/oidc/integration-tests/src/main/java/io/quarkiverse/flow/oidc/it/PropagationFlow.java new file mode 100644 index 000000000..5ae83f54c --- /dev/null +++ b/oidc/integration-tests/src/main/java/io/quarkiverse/flow/oidc/it/PropagationFlow.java @@ -0,0 +1,38 @@ +package io.quarkiverse.flow.oidc.it; + +import java.net.URI; +import java.util.Map; + +import jakarta.enterprise.context.ApplicationScoped; + +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import io.quarkiverse.flow.Flow; +import io.serverlessworkflow.api.types.Workflow; +import io.serverlessworkflow.fluent.func.FuncWorkflowBuilder; +import io.serverlessworkflow.fluent.func.dsl.FuncDSL; + +/** + * Calls a downstream service with no auth declared in the DSL. The {@code Authorization} header is attached + * entirely by the Flow OIDC decorator (mode {@code propagation}, scheme matched by workflow name). + */ +@ApplicationScoped +public class PropagationFlow extends Flow { + + @ConfigProperty(name = "downstream.url") + String downstreamUrl; + + @Override + public Workflow descriptor() { + return FuncWorkflowBuilder.workflow("oidc-propagation", "quarkus-flow") + .use(use -> use.authentications(auth -> { + })) + .tasks(FuncDSL.set(Map.of("token", "propagation")), + FuncDSL.call( + FuncDSL.http("callPropagation") + .GET() + .uri(URI.create(downstreamUrl + "/propagation/resource"), + FuncDSL.use("propagation")))) + .build(); + } +} diff --git a/oidc/integration-tests/src/test/java/io/quarkiverse/flow/oidc/it/AuthConfigResolverTest.java b/oidc/integration-tests/src/test/java/io/quarkiverse/flow/oidc/it/AuthConfigResolverTest.java new file mode 100644 index 000000000..16e2e6669 --- /dev/null +++ b/oidc/integration-tests/src/test/java/io/quarkiverse/flow/oidc/it/AuthConfigResolverTest.java @@ -0,0 +1,144 @@ +package io.quarkiverse.flow.oidc.it; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import io.quarkiverse.flow.oidc.AuthenticationMode; +import io.quarkiverse.flow.oidc.config.AuthConfigResolver; +import io.quarkiverse.flow.oidc.config.FlowOidcConfig; +import io.quarkiverse.flow.oidc.config.FlowOidcConfig.AuthSchemeConfig; +import io.quarkiverse.flow.oidc.config.FlowOidcConfig.TokenExchangeConfig; + +class AuthConfigResolverTest { + + private static final boolean SUBJECT_TOKEN_AVAILABLE = true; + private static final boolean NO_SUBJECT_TOKEN = false; + + private static AuthConfigResolver resolver(boolean globalExchangeEnabled, AuthSchemeConfig scheme) { + TokenExchangeConfig exchange = mock(TokenExchangeConfig.class); + lenient().when(exchange.enabled()).thenReturn(globalExchangeEnabled); + + FlowOidcConfig config = mock(FlowOidcConfig.class); + when(config.tokenExchange()).thenReturn(exchange); + when(config.auth()).thenReturn(scheme == null ? Map.of() : Map.of("svc", scheme)); + + return new AuthConfigResolver(config); + } + + private static AuthSchemeConfig scheme(Optional propagation, Optional exchange) { + AuthSchemeConfig scheme = mock(AuthSchemeConfig.class); + lenient().when(scheme.tokenPropagationEnabled()).thenReturn(propagation); + lenient().when(scheme.tokenExchangeEnabled()).thenReturn(exchange); + return scheme; + } + + @Test + @DisplayName("Propagation enabled should wins over exchange") + void propagation_enabled_wins_over_exchange() { + AuthConfigResolver resolver = resolver(true, scheme(Optional.of(true), Optional.of(true))); + assertThat(resolver.resolveMode("svc", SUBJECT_TOKEN_AVAILABLE)).isEqualTo(AuthenticationMode.TOKEN_PROPAGATION); + } + + @Test + @DisplayName("Subject token present defaults to exchange") + void subject_token_present_defaults_to_exchange() { + AuthConfigResolver resolver = resolver(true, scheme(Optional.empty(), Optional.empty())); + assertThat(resolver.resolveMode("svc", SUBJECT_TOKEN_AVAILABLE)).isEqualTo(AuthenticationMode.TOKEN_EXCHANGE); + } + + @Test + @DisplayName("No subject token defaults to client credentials") + void no_subject_token_defaults_to_client_credentials() { + AuthConfigResolver resolver = resolver(true, scheme(Optional.empty(), Optional.empty())); + assertThat(resolver.resolveMode("svc", NO_SUBJECT_TOKEN)).isEqualTo(AuthenticationMode.CLIENT_CREDENTIALS); + } + + @Test + @DisplayName("Global exchange disabled falls back to client credentials") + void global_exchange_disabled_falls_back_to_client_credentials() { + AuthConfigResolver resolver = resolver(false, scheme(Optional.empty(), Optional.empty())); + assertThat(resolver.resolveMode("svc", SUBJECT_TOKEN_AVAILABLE)).isEqualTo(AuthenticationMode.CLIENT_CREDENTIALS); + } + + @Test + @DisplayName("Scheme exchange flag overrides global disabled") + void scheme_exchange_flag_overrides_global_disabled() { + AuthConfigResolver resolver = resolver(false, scheme(Optional.empty(), Optional.of(true))); + assertThat(resolver.resolveMode("svc", NO_SUBJECT_TOKEN)).isEqualTo(AuthenticationMode.TOKEN_EXCHANGE); + } + + @Test + @DisplayName("Scheme specific propagation overrides global exchange enabled") + void scheme_specific_propagation_overrides_global_exchange_enabled() { + // Global: exchange enabled, Scheme: propagation enabled + AuthConfigResolver resolver = resolver(true, scheme(Optional.of(true), Optional.empty())); + assertThat(resolver.resolveMode("svc", SUBJECT_TOKEN_AVAILABLE)).isEqualTo(AuthenticationMode.TOKEN_PROPAGATION); + } + + @Test + @DisplayName("Scheme specific exchange disabled overrides global enabled") + void scheme_specific_exchange_disabled_overrides_global_enabled() { + // Global: exchange enabled, Scheme: exchange explicitly disabled + AuthConfigResolver resolver = resolver(true, scheme(Optional.empty(), Optional.of(false))); + assertThat(resolver.resolveMode("svc", SUBJECT_TOKEN_AVAILABLE)).isEqualTo(AuthenticationMode.CLIENT_CREDENTIALS); + } + + @Test + @DisplayName("Global exchange enabled used when scheme not specified") + void global_exchange_enabled_used_when_scheme_not_specified() { + // Global: exchange enabled, Scheme: no specific config, subject token available + AuthConfigResolver resolver = resolver(true, scheme(Optional.empty(), Optional.empty())); + assertThat(resolver.resolveMode("svc", SUBJECT_TOKEN_AVAILABLE)).isEqualTo(AuthenticationMode.TOKEN_EXCHANGE); + } + + @Test + @DisplayName("Global exchange disabled used when scheme not specified") + void global_exchange_disabled_used_when_scheme_not_specified() { + // Global: exchange disabled, Scheme: no specific config + AuthConfigResolver resolver = resolver(false, scheme(Optional.empty(), Optional.empty())); + assertThat(resolver.resolveMode("svc", SUBJECT_TOKEN_AVAILABLE)).isEqualTo(AuthenticationMode.CLIENT_CREDENTIALS); + } + + @Test + @DisplayName("Precedence scheme propagation over scheme exchange") + void precedence_scheme_propagation_over_scheme_exchange() { + // Both propagation and exchange enabled at scheme level - propagation wins + AuthConfigResolver resolver = resolver(true, scheme(Optional.of(true), Optional.of(true))); + assertThat(resolver.resolveMode("svc", SUBJECT_TOKEN_AVAILABLE)).isEqualTo(AuthenticationMode.TOKEN_PROPAGATION); + } + + @Test + @DisplayName("Precedence scheme exchange over global and smart default") + void precedence_scheme_exchange_over_global_and_smart_default() { + // Global disabled, no subject token (would default to CLIENT_CREDENTIALS) + // But scheme explicitly enables exchange - scheme wins + AuthConfigResolver resolver = resolver(false, scheme(Optional.empty(), Optional.of(true))); + assertThat(resolver.resolveMode("svc", NO_SUBJECT_TOKEN)).isEqualTo(AuthenticationMode.TOKEN_EXCHANGE); + } + + @Test + @DisplayName("Smart default with subject token when no config") + void smart_default_with_subject_token_when_no_config() { + // Global enabled, no scheme config, subject token available + // Smart default: use exchange + AuthConfigResolver resolver = resolver(true, scheme(Optional.empty(), Optional.empty())); + assertThat(resolver.resolveMode("svc", SUBJECT_TOKEN_AVAILABLE)).isEqualTo(AuthenticationMode.TOKEN_EXCHANGE); + } + + @Test + @DisplayName("Smart default without subject token when no config") + void smart_default_without_subject_token_when_no_config() { + // Global enabled, no scheme config, no subject token + // Smart default: service-to-service (CLIENT_CREDENTIALS) + AuthConfigResolver resolver = resolver(true, scheme(Optional.empty(), Optional.empty())); + assertThat(resolver.resolveMode("svc", NO_SUBJECT_TOKEN)).isEqualTo(AuthenticationMode.CLIENT_CREDENTIALS); + } +} diff --git a/oidc/integration-tests/src/test/java/io/quarkiverse/flow/oidc/it/InMemoryTokenCacheRepositoryTest.java b/oidc/integration-tests/src/test/java/io/quarkiverse/flow/oidc/it/InMemoryTokenCacheRepositoryTest.java new file mode 100644 index 000000000..5284b4648 --- /dev/null +++ b/oidc/integration-tests/src/test/java/io/quarkiverse/flow/oidc/it/InMemoryTokenCacheRepositoryTest.java @@ -0,0 +1,70 @@ +package io.quarkiverse.flow.oidc.it; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.time.Instant; +import java.util.Set; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import io.quarkiverse.flow.oidc.cache.CachedToken; +import io.quarkiverse.flow.oidc.cache.InMemoryTokenCacheRepository; +import io.quarkiverse.flow.oidc.cache.TokenCacheKey; + +class InMemoryTokenCacheRepositoryTest { + + @Test + @DisplayName("Subject token is hashed in cache key") + void subject_token_is_hashed_in_cache_key() { + TokenCacheKey key = TokenCacheKey.from("scheme", "super-secret-token", "aud"); + assertThat(key.subjectTokenHash()) + .doesNotContain("super-secret-token") + .hasSize(64); // SHA-256 hex + } + + @Test + @DisplayName("Store token is retrievable until expiry") + void stored_token_is_retrievable_until_expiry() { + InMemoryTokenCacheRepository repo = new InMemoryTokenCacheRepository(); + TokenCacheKey key = TokenCacheKey.from("scheme", "subject", ""); + repo.store(new CachedToken(key, "tok", Instant.now().plusSeconds(60), Instant.now(), Set.of()), "i1"); + + assertThat(repo.get(key)).isPresent().get().extracting(CachedToken::token).isEqualTo("tok"); + + repo.store(new CachedToken(key, "expired", Instant.now().minusSeconds(1), Instant.now(), Set.of()), "i1"); + assertThat(repo.get(key)).isEmpty(); + } + + @Test + @DisplayName("Token shared across instances evicted only when last instance unlinks") + void token_shared_across_instances_evicted_only_when_last_instance_unlinks() { + InMemoryTokenCacheRepository repo = new InMemoryTokenCacheRepository(); + TokenCacheKey key = TokenCacheKey.from("scheme", "subject", ""); + Instant expiry = Instant.now().plusSeconds(300); + + repo.store(new CachedToken(key, "tok", expiry, Instant.now(), Set.of()), "i1"); + repo.store(new CachedToken(key, "tok", expiry, Instant.now(), Set.of()), "i2"); + + repo.unlinkInstance("i1"); + assertThat(repo.contains(key)).as("token kept while i2 still uses it").isTrue(); + + repo.unlinkInstance("i2"); + assertThat(repo.contains(key)).as("orphaned token evicted").isFalse(); + } + + @Test + @DisplayName("Near expiry tokens are reported for refresh") + void near_expiry_tokens_are_reported_for_refresh() { + InMemoryTokenCacheRepository repo = new InMemoryTokenCacheRepository(); + TokenCacheKey soon = TokenCacheKey.from("scheme", "soon", ""); + TokenCacheKey later = TokenCacheKey.from("scheme", "later", ""); + repo.store(new CachedToken(soon, "a", Instant.now().plusSeconds(30), Instant.now(), Set.of()), "i1"); + repo.store(new CachedToken(later, "b", Instant.now().plusSeconds(3600), Instant.now(), Set.of()), "i1"); + + assertThat(repo.getTokensNearingExpiry(Duration.ofMinutes(5))) + .extracting(CachedToken::key) + .containsExactly(soon); + } +} diff --git a/oidc/integration-tests/src/test/java/io/quarkiverse/flow/oidc/it/OidcFlowTest.java b/oidc/integration-tests/src/test/java/io/quarkiverse/flow/oidc/it/OidcFlowTest.java new file mode 100644 index 000000000..203320e06 --- /dev/null +++ b/oidc/integration-tests/src/test/java/io/quarkiverse/flow/oidc/it/OidcFlowTest.java @@ -0,0 +1,98 @@ +package io.quarkiverse.flow.oidc.it; + +import static org.hamcrest.Matchers.is; + +import java.time.Duration; +import java.util.List; + +import org.awaitility.Awaitility; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; + +import com.github.tomakehurst.wiremock.client.WireMock; +import com.github.tomakehurst.wiremock.matching.EqualToPattern; +import com.github.tomakehurst.wiremock.matching.RequestPatternBuilder; +import com.github.tomakehurst.wiremock.verification.LoggedRequest; + +import io.quarkiverse.wiremock.devservice.ConnectWireMock; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.security.SecurityAttribute; +import io.quarkus.test.security.TestSecurity; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; + +@DisabledOnOs(OS.WINDOWS) +@QuarkusTest +@ConnectWireMock +class OidcFlowTest { + + WireMock wireMock; + + @Test + @DisplayName("Propagation scheme attaches subject token downstream") + void propagation_scheme_attaches_subject_token_downstream() { + RestAssured.given() + .accept(ContentType.JSON) + .queryParam("subjectToken", "static-token-123") + .get("/flow-oidc/propagation") + .then() + .statusCode(200) + .body("authorized", is(true)) + .body("via", is("propagation")); + } + + @Test + @DisplayName("Client credentials scheme acquires and attaches token downstream") + void client_credentials_scheme_acquires_and_attaches_token_downstream() { + RestAssured.given() + .accept(ContentType.JSON) + .get("/flow-oidc/client-credentials") + .then() + .statusCode(200) + .body("authorized", is(true)) + .body("via", is("client-credentials")); + } + + @Test + @DisplayName("Client credentials scheme acquires and attaches token downstream (ASYNC)") + void client_credentials_scheme_acquires_and_attaches_token_downstream_async() { + RestAssured.given() + .accept(ContentType.JSON) + .get("/flow-oidc/client-credentials?async=true") + .then() + .statusCode(202); + + Awaitility.await() + .atMost(Duration.ofSeconds(10)) + .untilAsserted(() -> { + wireMock.verifyThat(1, RequestPatternBuilder + .newRequestPattern().withUrl("/cc/resource")); + }); + } + + @Test + @TestSecurity(user = "cruz", roles = { "admin", "user" }, attributes = { + @SecurityAttribute(key = "access_token", value = "dummy-RSax7ZTj0RRUHC170HBEcOWZvX7fTZFmy8TUSZqyc7PWRzcXATfjDL005mcMROmV") + }) + @DisplayName("Get SecurityIdentity and attaches token to downstream (ASYNC)") + void security_identity_scheme_acquires_and_attaches_token_downstream_async() { + RestAssured.given() + .accept(ContentType.JSON) + .get("/flow-oidc/protected?async=true") + .then() + .statusCode(202); + + Awaitility.await() + .atMost(Duration.ofSeconds(10)) + .untilAsserted(() -> { + List loggedRequests = wireMock.find(RequestPatternBuilder + .newRequestPattern().withUrl("/propagation/resource").withHeader("Authorization", + new EqualToPattern( + "Bearer dummy-RSax7ZTj0RRUHC170HBEcOWZvX7fTZFmy8TUSZqyc7PWRzcXATfjDL005mcMROmV"))); + Assertions.assertEquals(1, loggedRequests.size()); + }); + } +} diff --git a/oidc/integration-tests/src/test/resources/application.properties b/oidc/integration-tests/src/test/resources/application.properties new file mode 100644 index 000000000..c719925e5 --- /dev/null +++ b/oidc/integration-tests/src/test/resources/application.properties @@ -0,0 +1,26 @@ +quarkus.wiremock.devservices.files-mapping=src/test/resources + +downstream.url=http://localhost:${quarkus.wiremock.devservices.port} + +# Named oidc-client backing the client-credentials scheme (points at the WireMock token endpoint) +quarkus.oidc-client.demo.auth-server-url=http://localhost:${quarkus.wiremock.devservices.port} +quarkus.oidc-client.demo.discovery-enabled=false +quarkus.oidc-client.demo.token-path=/oidc/token +quarkus.oidc-client.demo.client-id=flow-client +quarkus.oidc-client.demo.credentials.secret=flow-secret +quarkus.oidc-client.demo.grant.type=client + +# Flow OIDC: scheme 'propagation' referenced by the task via FuncDSL.use("propagation"). +# token-propagation-enabled forwards the subject token (supplied here via workflow input). +quarkus.flow.oidc.auth.propagation.token-propagation-enabled=true + +# Flow OIDC: scheme 'cc' referenced by the task via FuncDSL.use("cc"). +# No subject token + no propagation -> AuthConfigResolver derives client-credentials. +quarkus.flow.oidc.auth.cc.oidc-client-name=demo + +quarkus.flow.oidc.auth."subject-from-dsl".oidc-client-name=demo + +# Disable Keycloak (we are using WireMock) +quarkus.keycloak.devservices.enabled=false + +quarkus.http.test-port=0 \ No newline at end of file diff --git a/oidc/integration-tests/src/test/resources/mappings/downstream-client-credentials.json b/oidc/integration-tests/src/test/resources/mappings/downstream-client-credentials.json new file mode 100644 index 000000000..fd87f560c --- /dev/null +++ b/oidc/integration-tests/src/test/resources/mappings/downstream-client-credentials.json @@ -0,0 +1,21 @@ +{ + "request": { + "method": "GET", + "urlPath": "/cc/resource", + "headers": { + "Authorization": { + "equalTo": "Bearer cc-access-token" + } + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "authorized": true, + "via": "client-credentials" + } + } +} diff --git a/oidc/integration-tests/src/test/resources/mappings/downstream-propagation-security-identity.json b/oidc/integration-tests/src/test/resources/mappings/downstream-propagation-security-identity.json new file mode 100644 index 000000000..80c56f8cf --- /dev/null +++ b/oidc/integration-tests/src/test/resources/mappings/downstream-propagation-security-identity.json @@ -0,0 +1,21 @@ +{ + "request": { + "method": "GET", + "urlPath": "/propagation/resource", + "headers": { + "Authorization": { + "equalTo": "Bearer dummy-RSax7ZTj0RRUHC170HBEcOWZvX7fTZFmy8TUSZqyc7PWRzcXATfjDL005mcMROmV" + } + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "authorized": true, + "via": "propagation" + } + } +} diff --git a/oidc/integration-tests/src/test/resources/mappings/downstream-propagation.json b/oidc/integration-tests/src/test/resources/mappings/downstream-propagation.json new file mode 100644 index 000000000..bc7d6f378 --- /dev/null +++ b/oidc/integration-tests/src/test/resources/mappings/downstream-propagation.json @@ -0,0 +1,21 @@ +{ + "request": { + "method": "GET", + "urlPath": "/propagation/resource", + "headers": { + "Authorization": { + "equalTo": "Bearer static-token-123" + } + } + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "authorized": true, + "via": "propagation" + } + } +} diff --git a/oidc/integration-tests/src/test/resources/mappings/oidc-token.json b/oidc/integration-tests/src/test/resources/mappings/oidc-token.json new file mode 100644 index 000000000..d8fd70e27 --- /dev/null +++ b/oidc/integration-tests/src/test/resources/mappings/oidc-token.json @@ -0,0 +1,22 @@ +{ + "request": { + "method": "POST", + "urlPath": "/oidc/token", + "bodyPatterns": [ + { + "contains": "grant_type=client_credentials" + } + ] + }, + "response": { + "status": 200, + "headers": { + "Content-Type": "application/json" + }, + "jsonBody": { + "access_token": "cc-access-token", + "token_type": "Bearer", + "expires_in": 3600 + } + } +} diff --git a/oidc/pom.xml b/oidc/pom.xml new file mode 100644 index 000000000..c74019f7b --- /dev/null +++ b/oidc/pom.xml @@ -0,0 +1,20 @@ + + + 4.0.0 + + io.quarkiverse.flow + quarkus-flow-parent + 1.0.0-SNAPSHOT + + + quarkus-flow-oidc-parent + pom + Quarkus Flow :: OIDC :: Parent + + + runtime + deployment + integration-tests + + + diff --git a/oidc/runtime/pom.xml b/oidc/runtime/pom.xml new file mode 100644 index 000000000..9f7f86d50 --- /dev/null +++ b/oidc/runtime/pom.xml @@ -0,0 +1,74 @@ + + + 4.0.0 + + io.quarkiverse.flow + quarkus-flow-oidc-parent + 1.0.0-SNAPSHOT + + + quarkus-flow-oidc + Quarkus Flow :: OIDC :: Runtime + + + + io.quarkus + quarkus-arc + + + io.quarkus + quarkus-oidc-client + + + io.serverlessworkflow + serverlessworkflow-impl-core + + + io.serverlessworkflow + serverlessworkflow-impl-http + + + + + + + io.quarkus + quarkus-extension-maven-plugin + ${quarkus.version} + + + compile + + extension-descriptor + + + ${project.groupId}:${project.artifactId}-deployment:${project.version} + + + io.quarkus:quarkus-oidc-client + + + + + + + maven-compiler-plugin + + + default-compile + + + + io.quarkus + quarkus-extension-processor + + + true + + + + + + + + diff --git a/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/AuthenticationContext.java b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/AuthenticationContext.java new file mode 100644 index 000000000..3d0e95abf --- /dev/null +++ b/oidc/runtime/src/main/java/io/quarkiverse/flow/oidc/AuthenticationContext.java @@ -0,0 +1,41 @@ +package io.quarkiverse.flow.oidc; + +import java.util.Optional; + +import io.quarkiverse.flow.oidc.config.FlowOidcConfig.AuthSchemeConfig; +import io.serverlessworkflow.impl.TaskContext; +import io.serverlessworkflow.impl.WorkflowContext; + +/** + * Everything a {@link AuthenticationProvider} needs to produce a token for one HTTP call: the resolved + * scheme name and its configuration, the executing workflow/task contexts, and the caller's subject token + * (already extracted once by {@link AuthenticationRegistry}; empty when none is available). + */ +public record AuthenticationContext( + String schemeName, + AuthSchemeConfig schemeConfig, + WorkflowContext workflowContext, + TaskContext taskContext, + Optional 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: + *

    + *
  1. Explicit workflow input: under {@code quarkus.flow.oidc.subject-token.input-key} + * (useful for non-HTTP / programmatic triggers);
  2. + *
  3. {@code SecurityIdentity}: the + * {@code quarkus.flow.oidc.subject-token.security-identity-attribute} attribute, for HTTP-triggered + * workflows running on the request thread.
  4. + *
+ *

+ * 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